Skip to content

Commit 737727a

Browse files
committed
fix(config): preserve mounts and harden init prompts
1 parent b88d4b8 commit 737727a

6 files changed

Lines changed: 290 additions & 36 deletions

File tree

internal/cli/common.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,15 @@ func (s *transientStatus) stop() {
185185
}
186186

187187
func isTerminalWriter(w io.Writer) bool {
188-
f, ok := w.(interface{ Fd() uintptr })
188+
return isTerminalFD(w)
189+
}
190+
191+
func isTerminalReader(r io.Reader) bool {
192+
return isTerminalFD(r)
193+
}
194+
195+
func isTerminalFD(v any) bool {
196+
f, ok := v.(interface{ Fd() uintptr })
189197
if !ok {
190198
return false
191199
}

internal/cli/init.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ func newInitCmd(opts *Options) *cobra.Command {
1515
var yes bool
1616
var nameOverride string
1717
var nsOverride string
18+
var sidecarImageOverride string
19+
var syncLocalOverride string
20+
var syncRemoteOverride string
21+
var sshUserOverride string
1822

1923
cmd := &cobra.Command{
2024
Use: "init",
@@ -35,10 +39,17 @@ func newInitCmd(opts *Options) *cobra.Command {
3539
}
3640

3741
vars := config.NewTemplateVars()
38-
overrides := InitOverrides{Name: nameOverride, Namespace: nsOverride}
42+
overrides := InitOverrides{
43+
Name: nameOverride,
44+
Namespace: nsOverride,
45+
SidecarImage: sidecarImageOverride,
46+
SyncLocal: syncLocalOverride,
47+
SyncRemote: syncRemoteOverride,
48+
SSHUser: sshUserOverride,
49+
}
3950
applyOverrides(vars, overrides)
4051

41-
if err := promptInteractive(vars, yes); err != nil {
52+
if err := promptInteractive(vars, overrides, cmd.InOrStdin(), cmd.OutOrStdout(), yes, isTerminalReader(cmd.InOrStdin())); err != nil {
4253
return err
4354
}
4455

@@ -64,5 +75,9 @@ func newInitCmd(opts *Options) *cobra.Command {
6475
cmd.Flags().BoolVar(&yes, "yes", false, "Non-interactive mode, accept all defaults")
6576
cmd.Flags().StringVar(&nameOverride, "name", "", "Environment name")
6677
cmd.Flags().StringVar(&nsOverride, "namespace", "", "Namespace")
78+
cmd.Flags().StringVar(&sidecarImageOverride, "sidecar-image", "", "Sidecar image")
79+
cmd.Flags().StringVar(&syncLocalOverride, "sync-local", "", "Local sync path")
80+
cmd.Flags().StringVar(&syncRemoteOverride, "sync-remote", "", "Remote sync path")
81+
cmd.Flags().StringVar(&sshUserOverride, "ssh-user", "", "SSH user")
6782
return cmd
6883
}

internal/cli/prompt.go

Lines changed: 90 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
11
package cli
22

33
import (
4+
"bufio"
45
"fmt"
6+
"io"
57
"os"
68
"path/filepath"
9+
"strings"
710

811
"github.com/acmore/okdev/internal/config"
912
)
1013

1114
// InitOverrides holds flag-provided values that skip prompting.
1215
type InitOverrides struct {
13-
Name string
14-
Namespace string
16+
Name string
17+
Namespace string
18+
SidecarImage string
19+
SyncLocal string
20+
SyncRemote string
21+
SSHUser string
1522
}
1623

1724
// applyOverrides applies non-empty flag values to template vars.
@@ -22,6 +29,18 @@ func applyOverrides(vars *config.TemplateVars, o InitOverrides) {
2229
if o.Namespace != "" {
2330
vars.Namespace = o.Namespace
2431
}
32+
if o.SidecarImage != "" {
33+
vars.SidecarImage = o.SidecarImage
34+
}
35+
if o.SyncLocal != "" {
36+
vars.SyncLocal = o.SyncLocal
37+
}
38+
if o.SyncRemote != "" {
39+
vars.SyncRemote = o.SyncRemote
40+
}
41+
if o.SSHUser != "" {
42+
vars.SSHUser = o.SSHUser
43+
}
2544
}
2645

2746
// detectDefaultName returns a sensible default for the environment name
@@ -34,59 +53,101 @@ func detectDefaultName() string {
3453
return filepath.Base(wd)
3554
}
3655

56+
func (o InitOverrides) hasName() bool { return o.Name != "" }
57+
func (o InitOverrides) hasNamespace() bool { return o.Namespace != "" }
58+
func (o InitOverrides) hasSidecarImage() bool { return o.SidecarImage != "" }
59+
func (o InitOverrides) hasSyncLocal() bool { return o.SyncLocal != "" }
60+
func (o InitOverrides) hasSyncRemote() bool { return o.SyncRemote != "" }
61+
func (o InitOverrides) hasSSHUser() bool { return o.SSHUser != "" }
62+
3763
// promptInteractive runs interactive prompts to fill in template vars.
3864
// Only prompts for values not already set by flags.
3965
// When nonInteractive is true, uses defaults without prompting.
40-
func promptInteractive(vars *config.TemplateVars, nonInteractive bool) error {
66+
func promptInteractive(vars *config.TemplateVars, overrides InitOverrides, in io.Reader, out io.Writer, nonInteractive bool, interactive bool) error {
4167
if vars.Name == "" {
4268
vars.Name = detectDefaultName()
4369
}
4470
if nonInteractive {
4571
return nil
4672
}
73+
if !interactive {
74+
return fmt.Errorf("interactive init requires a TTY; rerun with --yes or pass explicit flags")
75+
}
4776

48-
// Interactive prompts using basic fmt.Scanln.
49-
// Each prompt shows the current default and allows the user to accept or override.
50-
var input string
77+
reader := bufio.NewReader(in)
5178

52-
input = promptString("Environment name", vars.Name)
53-
if input != "" {
54-
vars.Name = input
79+
if !overrides.hasName() {
80+
input, err := promptString(reader, out, "Environment name", vars.Name)
81+
if err != nil {
82+
return err
83+
}
84+
if input != "" {
85+
vars.Name = input
86+
}
5587
}
5688

57-
input = promptString("Namespace", vars.Namespace)
58-
if input != "" {
59-
vars.Namespace = input
89+
if !overrides.hasNamespace() {
90+
input, err := promptString(reader, out, "Namespace", vars.Namespace)
91+
if err != nil {
92+
return err
93+
}
94+
if input != "" {
95+
vars.Namespace = input
96+
}
6097
}
6198

62-
input = promptString("Sidecar image", vars.SidecarImage)
63-
if input != "" {
64-
vars.SidecarImage = input
99+
if !overrides.hasSidecarImage() {
100+
input, err := promptString(reader, out, "Sidecar image", vars.SidecarImage)
101+
if err != nil {
102+
return err
103+
}
104+
if input != "" {
105+
vars.SidecarImage = input
106+
}
65107
}
66108

67-
input = promptString("Sync local path", vars.SyncLocal)
68-
if input != "" {
69-
vars.SyncLocal = input
109+
if !overrides.hasSyncLocal() {
110+
input, err := promptString(reader, out, "Sync local path", vars.SyncLocal)
111+
if err != nil {
112+
return err
113+
}
114+
if input != "" {
115+
vars.SyncLocal = input
116+
}
70117
}
71118

72-
input = promptString("Sync remote path", vars.SyncRemote)
73-
if input != "" {
74-
vars.SyncRemote = input
119+
if !overrides.hasSyncRemote() {
120+
input, err := promptString(reader, out, "Sync remote path", vars.SyncRemote)
121+
if err != nil {
122+
return err
123+
}
124+
if input != "" {
125+
vars.SyncRemote = input
126+
}
75127
}
76128

77-
input = promptString("SSH user", vars.SSHUser)
78-
if input != "" {
79-
vars.SSHUser = input
129+
if !overrides.hasSSHUser() {
130+
input, err := promptString(reader, out, "SSH user", vars.SSHUser)
131+
if err != nil {
132+
return err
133+
}
134+
if input != "" {
135+
vars.SSHUser = input
136+
}
80137
}
81138

82139
return nil
83140
}
84141

85142
// promptString prints a prompt with a default and reads user input.
86143
// Returns empty string if user accepts default (just presses Enter).
87-
func promptString(label, defaultVal string) string {
88-
fmt.Printf("? %s: (%s) ", label, defaultVal)
89-
var input string
90-
fmt.Scanln(&input)
91-
return input
144+
func promptString(reader *bufio.Reader, out io.Writer, label, defaultVal string) (string, error) {
145+
if _, err := fmt.Fprintf(out, "? %s: (%s) ", label, defaultVal); err != nil {
146+
return "", err
147+
}
148+
line, err := reader.ReadString('\n')
149+
if err != nil {
150+
return "", fmt.Errorf("read %s: %w", strings.ToLower(label), err)
151+
}
152+
return strings.TrimSpace(line), nil
92153
}

internal/cli/prompt_test.go

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package cli
22

33
import (
4+
"bytes"
5+
"strings"
46
"testing"
57

68
"github.com/acmore/okdev/internal/config"
@@ -9,8 +11,12 @@ import (
911
func TestApplyFlagOverrides(t *testing.T) {
1012
vars := config.NewTemplateVars()
1113
overrides := InitOverrides{
12-
Name: "my-env",
13-
Namespace: "staging",
14+
Name: "my-env",
15+
Namespace: "staging",
16+
SidecarImage: "ghcr.io/acmore/okdev:test",
17+
SyncLocal: "./local",
18+
SyncRemote: "/remote",
19+
SSHUser: "devuser",
1420
}
1521
applyOverrides(vars, overrides)
1622

@@ -20,6 +26,18 @@ func TestApplyFlagOverrides(t *testing.T) {
2026
if vars.Namespace != "staging" {
2127
t.Fatalf("expected namespace staging, got %q", vars.Namespace)
2228
}
29+
if vars.SidecarImage != "ghcr.io/acmore/okdev:test" {
30+
t.Fatalf("expected sidecar image override, got %q", vars.SidecarImage)
31+
}
32+
if vars.SyncLocal != "./local" {
33+
t.Fatalf("expected sync local override, got %q", vars.SyncLocal)
34+
}
35+
if vars.SyncRemote != "/remote" {
36+
t.Fatalf("expected sync remote override, got %q", vars.SyncRemote)
37+
}
38+
if vars.SSHUser != "devuser" {
39+
t.Fatalf("expected ssh user override, got %q", vars.SSHUser)
40+
}
2341
}
2442

2543
func TestApplyFlagOverridesEmptyNoChange(t *testing.T) {
@@ -32,3 +50,51 @@ func TestApplyFlagOverridesEmptyNoChange(t *testing.T) {
3250
t.Fatalf("expected name to remain original, got %q", vars.Name)
3351
}
3452
}
53+
54+
func TestPromptInteractiveRequiresTTYUnlessYes(t *testing.T) {
55+
vars := config.NewTemplateVars()
56+
err := promptInteractive(vars, InitOverrides{}, strings.NewReader(""), &bytes.Buffer{}, false, false)
57+
if err == nil {
58+
t.Fatal("expected non-interactive prompt attempt to fail without --yes")
59+
}
60+
if !strings.Contains(err.Error(), "--yes") {
61+
t.Fatalf("expected error to mention --yes, got %v", err)
62+
}
63+
}
64+
65+
func TestPromptInteractiveSkipsOverriddenFields(t *testing.T) {
66+
vars := config.NewTemplateVars()
67+
overrides := InitOverrides{
68+
Name: "flag-name",
69+
Namespace: "flag-ns",
70+
}
71+
applyOverrides(vars, overrides)
72+
73+
input := strings.NewReader("custom-image\n./src\n/app\nalice\n")
74+
var out bytes.Buffer
75+
if err := promptInteractive(vars, overrides, input, &out, false, true); err != nil {
76+
t.Fatalf("promptInteractive returned error: %v", err)
77+
}
78+
79+
if vars.Name != "flag-name" {
80+
t.Fatalf("expected overridden name to remain unchanged, got %q", vars.Name)
81+
}
82+
if vars.Namespace != "flag-ns" {
83+
t.Fatalf("expected overridden namespace to remain unchanged, got %q", vars.Namespace)
84+
}
85+
if vars.SidecarImage != "custom-image" {
86+
t.Fatalf("expected prompted sidecar image, got %q", vars.SidecarImage)
87+
}
88+
if vars.SyncLocal != "./src" {
89+
t.Fatalf("expected prompted sync local, got %q", vars.SyncLocal)
90+
}
91+
if vars.SyncRemote != "/app" {
92+
t.Fatalf("expected prompted sync remote, got %q", vars.SyncRemote)
93+
}
94+
if vars.SSHUser != "alice" {
95+
t.Fatalf("expected prompted ssh user, got %q", vars.SSHUser)
96+
}
97+
if strings.Contains(out.String(), "Environment name") || strings.Contains(out.String(), "Namespace") {
98+
t.Fatalf("expected overridden fields to be skipped, got prompts:\n%s", out.String())
99+
}
100+
}

internal/config/migrate.go

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,22 @@ func setKey(mapping *yaml.Node, key string, value *yaml.Node) {
9797
)
9898
}
9999

100+
func findNamedSequenceItem(seq *yaml.Node, name string) *yaml.Node {
101+
if seq == nil || seq.Kind != yaml.SequenceNode {
102+
return nil
103+
}
104+
for _, item := range seq.Content {
105+
if item == nil || item.Kind != yaml.MappingNode {
106+
continue
107+
}
108+
_, value := findKey(item, "name")
109+
if value != nil && value.Value == name {
110+
return item
111+
}
112+
}
113+
return nil
114+
}
115+
100116
// DefaultMigrations is the ordered list of all config migrations.
101117
var DefaultMigrations = []Migration{
102118
workspaceToVolumesMigration(),
@@ -193,7 +209,9 @@ func workspaceToVolumesMigration() Migration {
193209
// Merge with existing volumes if present
194210
_, existingVolumes := findKey(spec, "volumes")
195211
if existingVolumes != nil && existingVolumes.Kind == yaml.SequenceNode {
196-
existingVolumes.Content = append(existingVolumes.Content, volumeMapping)
212+
if findNamedSequenceItem(existingVolumes, "workspace") == nil {
213+
existingVolumes.Content = append(existingVolumes.Content, volumeMapping)
214+
}
197215
} else {
198216
setKey(spec, "volumes", volumesSeq)
199217
}
@@ -210,7 +228,14 @@ func workspaceToVolumesMigration() Migration {
210228
if c.Kind == yaml.MappingNode {
211229
_, nameNode := findKey(c, "name")
212230
if nameNode != nil && nameNode.Value == "dev" {
213-
setKey(c, "volumeMounts", vmSeq)
231+
_, existingMounts := findKey(c, "volumeMounts")
232+
if existingMounts != nil && existingMounts.Kind == yaml.SequenceNode {
233+
if findNamedSequenceItem(existingMounts, "workspace") == nil {
234+
existingMounts.Content = append(existingMounts.Content, vmMapping)
235+
}
236+
} else {
237+
setKey(c, "volumeMounts", vmSeq)
238+
}
214239
goto podTemplateDone
215240
}
216241
}

0 commit comments

Comments
 (0)