From 0192c712a074c852d839ef2e63441948561f1f0e Mon Sep 17 00:00:00 2001 From: acmore Date: Sat, 2 May 2026 17:09:05 +0800 Subject: [PATCH 1/7] feat(config): add spec.ssh.shell field with validation Co-Authored-By: Claude Opus 4.6 --- internal/config/config.go | 6 ++++++ internal/config/config_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index d13a5f2..ff7a54b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -151,6 +151,7 @@ type LifecycleSpec struct { } type SSHSpec struct { + Shell string `yaml:"shell"` User string `yaml:"user"` PrivateKeyPath string `yaml:"privateKeyPath"` AutoDetectPorts *bool `yaml:"autoDetectPorts"` @@ -335,6 +336,11 @@ func (d *DevEnvironment) Validate() error { if err := validateAgents(d.Spec.Agents); err != nil { return err } + if shell := strings.TrimSpace(d.Spec.SSH.Shell); shell != "" { + if !filepath.IsAbs(shell) { + return fmt.Errorf("spec.ssh.shell must be an absolute path, got %q", shell) + } + } return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 72244e6..8855f88 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -231,6 +231,32 @@ func TestValidateAllowsInterPodSSHToOverrideDisabledSidecars(t *testing.T) { } } +func TestValidateRejectsRelativeShellPath(t *testing.T) { + cfg := validConfig() + cfg.Spec.SSH.Shell = "bash" + if err := cfg.Validate(); err == nil { + t.Fatal("expected validation error for relative shell path") + } +} + +func TestValidateAcceptsAbsoluteShellPath(t *testing.T) { + cfg := validConfig() + cfg.Spec.SSH.Shell = "/bin/zsh" + cfg.SetDefaults() + if err := cfg.Validate(); err != nil { + t.Fatalf("unexpected validation error: %v", err) + } +} + +func TestValidateAcceptsEmptyShellPath(t *testing.T) { + cfg := validConfig() + cfg.Spec.SSH.Shell = "" + cfg.SetDefaults() + if err := cfg.Validate(); err != nil { + t.Fatalf("unexpected validation error: %v", err) + } +} + func TestValidateRejectsNegativeSyncthingRescanInterval(t *testing.T) { cfg := validConfig() cfg.Spec.Sync.Syncthing.RescanIntervalSeconds = -1 From bc6caa721d0a1697a53af0b4c11725336b9c1a21 Mon Sep 17 00:00:00 2001 From: acmore Date: Sat, 2 May 2026 17:10:31 +0800 Subject: [PATCH 2/7] feat(config): add shell to workload snapshot for drift detection Co-Authored-By: Claude Opus 4.6 --- internal/cli/workload.go | 2 +- internal/config/snapshot.go | 4 ++- internal/config/snapshot_test.go | 42 +++++++++++++++++++++++++------- 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/internal/cli/workload.go b/internal/cli/workload.go index 88936e6..54448af 100644 --- a/internal/cli/workload.go +++ b/internal/cli/workload.go @@ -28,7 +28,7 @@ func sessionRuntime(cfg *config.DevEnvironment, cfgPath, sessionName, workloadNa manifestResolvedPath = workload.ResolveManifestPath(cfgPath, cfg.Spec.Workload.ManifestPath) } - snap := config.BuildWorkloadSnapshot(cfg, workspaceMountPath, targetContainer, tmux, preStop, manifestPath, manifestResolvedPath) + snap := config.BuildWorkloadSnapshot(cfg, workspaceMountPath, targetContainer, tmux, cfg.Spec.SSH.Shell, preStop, manifestPath, manifestResolvedPath) snapJSON, _ := snap.JSON() snapHash, _ := snap.SHA256() diff --git a/internal/config/snapshot.go b/internal/config/snapshot.go index 61ab6bc..05fcd6c 100644 --- a/internal/config/snapshot.go +++ b/internal/config/snapshot.go @@ -26,12 +26,13 @@ type LastAppliedWorkloadSpec struct { WorkspaceMountPath string `json:"workspaceMountPath"` TargetContainer string `json:"targetContainer"` Tmux bool `json:"tmux"` + Shell string `json:"shell,omitempty"` PreStop string `json:"preStop"` ManifestPath string `json:"manifestPath,omitempty"` ManifestSHA256 string `json:"manifestSHA256,omitempty"` } -func BuildWorkloadSnapshot(cfg *DevEnvironment, workspaceMountPath, targetContainer string, tmux bool, preStop, manifestPath, manifestResolvedPath string) LastAppliedWorkloadSpec { +func BuildWorkloadSnapshot(cfg *DevEnvironment, workspaceMountPath, targetContainer string, tmux bool, shell string, preStop, manifestPath, manifestResolvedPath string) LastAppliedWorkloadSpec { kind := cfg.Spec.Workload.Type if kind == "" { kind = "pod" @@ -49,6 +50,7 @@ func BuildWorkloadSnapshot(cfg *DevEnvironment, workspaceMountPath, targetContai WorkspaceMountPath: workspaceMountPath, TargetContainer: targetContainer, Tmux: tmux, + Shell: shell, PreStop: preStop, ManifestPath: manifestPath, } diff --git a/internal/config/snapshot_test.go b/internal/config/snapshot_test.go index 9eecbef..e84b438 100644 --- a/internal/config/snapshot_test.go +++ b/internal/config/snapshot_test.go @@ -28,7 +28,7 @@ func TestBuildWorkloadSnapshotPod(t *testing.T) { }, }, } - snap := BuildWorkloadSnapshot(cfg, "/workspace", "dev", true, "echo bye", "", "") + snap := BuildWorkloadSnapshot(cfg, "/workspace", "dev", true, "", "echo bye", "", "") if snap.Version != "v1" { t.Fatalf("expected version v1, got %s", snap.Version) } @@ -75,8 +75,8 @@ func TestWorkloadSnapshotHashIncludesSidecarResources(t *testing.T) { }, }, } - snap1 := BuildWorkloadSnapshot(cfg1, "/workspace", "dev", false, "", "", "") - snap2 := BuildWorkloadSnapshot(cfg2, "/workspace", "dev", false, "", "", "") + snap1 := BuildWorkloadSnapshot(cfg1, "/workspace", "dev", false, "", "", "", "") + snap2 := BuildWorkloadSnapshot(cfg2, "/workspace", "dev", false, "", "", "", "") h1, _ := snap1.SHA256() h2, _ := snap2.SHA256() if h1 == h2 { @@ -103,8 +103,8 @@ func TestBuildWorkloadSnapshotExcludesNonWorkloadFields(t *testing.T) { Sync: SyncSpec{Paths: []string{"src/"}}, }, } - snap1 := BuildWorkloadSnapshot(cfg1, "/workspace", "dev", false, "", "", "") - snap2 := BuildWorkloadSnapshot(cfg2, "/workspace", "dev", false, "", "", "") + snap1 := BuildWorkloadSnapshot(cfg1, "/workspace", "dev", false, "", "", "", "") + snap2 := BuildWorkloadSnapshot(cfg2, "/workspace", "dev", false, "", "", "", "") h1, _ := snap1.SHA256() h2, _ := snap2.SHA256() if h1 != h2 { @@ -112,6 +112,30 @@ func TestBuildWorkloadSnapshotExcludesNonWorkloadFields(t *testing.T) { } } +func TestBuildWorkloadSnapshotShellChangeAffectsHash(t *testing.T) { + cfg1 := &DevEnvironment{ + Spec: DevEnvSpec{ + Workload: WorkloadSpec{Type: "pod"}, + Sidecar: SidecarSpec{Image: "img:1"}, + SSH: SSHSpec{Shell: ""}, + }, + } + cfg2 := &DevEnvironment{ + Spec: DevEnvSpec{ + Workload: WorkloadSpec{Type: "pod"}, + Sidecar: SidecarSpec{Image: "img:1"}, + SSH: SSHSpec{Shell: "/bin/zsh"}, + }, + } + snap1 := BuildWorkloadSnapshot(cfg1, "/workspace", "dev", false, "", "", "", "") + snap2 := BuildWorkloadSnapshot(cfg2, "/workspace", "dev", false, "/bin/zsh", "", "", "") + h1, _ := snap1.SHA256() + h2, _ := snap2.SHA256() + if h1 == h2 { + t.Fatal("expected shell changes to affect workload hash") + } +} + func TestComputeManifestSHA256(t *testing.T) { f := t.TempDir() + "/job.yaml" os.WriteFile(f, []byte("apiVersion: batch/v1\nkind: Job\n"), 0o644) @@ -142,7 +166,7 @@ func TestBuildWorkloadSnapshotGenericIncludesManifestHash(t *testing.T) { Sidecar: SidecarSpec{Image: "img:1"}, }, } - snap := BuildWorkloadSnapshot(cfg, "/workspace", "dev", false, "", "job.yaml", f) + snap := BuildWorkloadSnapshot(cfg, "/workspace", "dev", false, "", "", "job.yaml", f) if snap.ManifestSHA256 == "" { t.Fatal("expected manifest hash for job workload") } @@ -171,7 +195,7 @@ func TestBuildWorkloadSnapshotUsesEffectiveInjectForInterPodSSH(t *testing.T) { }, } - snap := BuildWorkloadSnapshot(cfg, "/workspace", "dev", false, "", "pytorchjob.yaml", "") + snap := BuildWorkloadSnapshot(cfg, "/workspace", "dev", false, "", "", "pytorchjob.yaml", "") if len(snap.Workload.Inject) != 2 { t.Fatalf("expected 2 inject specs, got %d", len(snap.Workload.Inject)) } @@ -187,8 +211,8 @@ func TestWorkloadSnapshotHashIgnoresManifestPath(t *testing.T) { Sidecar: SidecarSpec{Image: "img:1"}, }, } - snap1 := BuildWorkloadSnapshot(cfg, "/workspace", "dev", false, "", "job.yaml", "/tmp/a/job.yaml") - snap2 := BuildWorkloadSnapshot(cfg, "/workspace", "dev", false, "", "/Users/me/src/job.yaml", "/tmp/b/job.yaml") + snap1 := BuildWorkloadSnapshot(cfg, "/workspace", "dev", false, "", "", "job.yaml", "/tmp/a/job.yaml") + snap2 := BuildWorkloadSnapshot(cfg, "/workspace", "dev", false, "", "", "/Users/me/src/job.yaml", "/tmp/b/job.yaml") snap1.ManifestSHA256 = "same" snap2.ManifestSHA256 = "same" From 7e9bf2adc809f052253e6083fb58bbfa7a07e174 Mon Sep 17 00:00:00 2001 From: acmore Date: Sat, 2 May 2026 17:13:27 +0800 Subject: [PATCH 3/7] feat(kube): inject OKDEV_SHELL env var and wire shell through workload runtime Co-Authored-By: Claude Opus 4.6 --- internal/cli/workload.go | 3 +- internal/kube/podspec.go | 12 ++++++- internal/kube/podspec_test.go | 49 ++++++++++++++++++++++++++ internal/workload/generic.go | 3 +- internal/workload/pod.go | 6 ++-- internal/workload/pod_snapshot_test.go | 4 +-- internal/workload/pod_test.go | 4 +-- 7 files changed, 72 insertions(+), 9 deletions(-) diff --git a/internal/cli/workload.go b/internal/cli/workload.go index 54448af..213efae 100644 --- a/internal/cli/workload.go +++ b/internal/cli/workload.go @@ -37,7 +37,7 @@ func sessionRuntime(cfg *config.DevEnvironment, cfgPath, sessionName, workloadNa rt := workload.NewPodRuntime( sessionName, labels, annotations, podSpec, volumes, workspaceMountPath, cfg.Spec.Sidecar.Image, cfg.Spec.Sidecar.Resources, - tmux, preStop, targetContainer, + tmux, cfg.Spec.SSH.Shell, preStop, targetContainer, ) rt.WorkloadNameOverride = workloadName rt.LastAppliedSpecJSON = snapJSON @@ -53,6 +53,7 @@ func sessionRuntime(cfg *config.DevEnvironment, cfgPath, sessionName, workloadNa SidecarImage: cfg.Spec.Sidecar.Image, SidecarResources: cfg.Spec.Sidecar.Resources, Tmux: tmux, + Shell: cfg.Spec.SSH.Shell, PreStop: preStop, TargetContainer: targetContainer, Volumes: volumes, diff --git a/internal/kube/podspec.go b/internal/kube/podspec.go index 779bc2b..56cbf0f 100644 --- a/internal/kube/podspec.go +++ b/internal/kube/podspec.go @@ -11,10 +11,14 @@ import ( var semverTagPattern = regexp.MustCompile(`^v?\d+\.\d+\.\d+([.-][0-9A-Za-z.-]+)?$`) func PreparePodSpec(podSpec corev1.PodSpec, volumes []corev1.Volume, workspaceMountPath, sidecarImage string, sidecarResources corev1.ResourceRequirements, tmux bool, preStop string) (corev1.PodSpec, error) { - return PreparePodSpecForTarget(podSpec, volumes, workspaceMountPath, sidecarImage, sidecarResources, tmux, preStop, "dev") + return PreparePodSpecForTargetWithShell(podSpec, volumes, workspaceMountPath, sidecarImage, sidecarResources, tmux, preStop, "dev", "") } func PreparePodSpecForTarget(podSpec corev1.PodSpec, volumes []corev1.Volume, workspaceMountPath, sidecarImage string, sidecarResources corev1.ResourceRequirements, tmux bool, preStop string, targetContainer string) (corev1.PodSpec, error) { + return PreparePodSpecForTargetWithShell(podSpec, volumes, workspaceMountPath, sidecarImage, sidecarResources, tmux, preStop, targetContainer, "") +} + +func PreparePodSpecForTargetWithShell(podSpec corev1.PodSpec, volumes []corev1.Volume, workspaceMountPath, sidecarImage string, sidecarResources corev1.ResourceRequirements, tmux bool, preStop string, targetContainer string, shell string) (corev1.PodSpec, error) { if strings.TrimSpace(sidecarImage) == "" { return corev1.PodSpec{}, fmt.Errorf("sidecar image cannot be empty") } @@ -82,6 +86,12 @@ func PreparePodSpecForTarget(podSpec corev1.PodSpec, volumes []corev1.Volume, wo Value: "1", }) } + if strings.TrimSpace(shell) != "" { + spec.Containers[targetIndex].Env = ensureEnvVar(spec.Containers[targetIndex].Env, corev1.EnvVar{ + Name: "OKDEV_SHELL", + Value: shell, + }) + } } InjectPreStopForTarget(spec, preStop, targetContainer) diff --git a/internal/kube/podspec_test.go b/internal/kube/podspec_test.go index 46fb4ff..9bab9ae 100644 --- a/internal/kube/podspec_test.go +++ b/internal/kube/podspec_test.go @@ -180,6 +180,55 @@ func TestPreparePodSpecSetsDevTmuxEnvWhenEnabled(t *testing.T) { t.Fatal("expected OKDEV_TMUX=1 on dev container when tmux is enabled") } +func TestPreparePodSpecSetsShellEnvWhenConfigured(t *testing.T) { + spec, err := PreparePodSpecForTargetWithShell(corev1.PodSpec{}, nil, "/workspace", "ghcr.io/acmore/okdev-sidecar:edge", corev1.ResourceRequirements{}, false, "", "dev", "/bin/zsh") + if err != nil { + t.Fatal(err) + } + dev := findContainer(spec.Containers, "dev") + if dev == nil { + t.Fatal("dev container not found") + } + for _, env := range dev.Env { + if env.Name == "OKDEV_SHELL" && env.Value == "/bin/zsh" { + return + } + } + t.Fatal("expected OKDEV_SHELL=/bin/zsh on dev container") +} + +func TestPreparePodSpecDoesNotSetShellEnvWhenEmpty(t *testing.T) { + spec, err := PreparePodSpecForTargetWithShell(corev1.PodSpec{}, nil, "/workspace", "ghcr.io/acmore/okdev-sidecar:edge", corev1.ResourceRequirements{}, false, "", "dev", "") + if err != nil { + t.Fatal(err) + } + dev := findContainer(spec.Containers, "dev") + if dev == nil { + t.Fatal("dev container not found") + } + for _, env := range dev.Env { + if env.Name == "OKDEV_SHELL" { + t.Fatal("expected OKDEV_SHELL to not be set when shell is empty") + } + } +} + +func TestPreparePodSpecShellNotOnSidecar(t *testing.T) { + spec, err := PreparePodSpecForTargetWithShell(corev1.PodSpec{}, nil, "/workspace", "ghcr.io/acmore/okdev-sidecar:edge", corev1.ResourceRequirements{}, false, "", "dev", "/bin/zsh") + if err != nil { + t.Fatal(err) + } + sidecar := findContainer(spec.Containers, "okdev-sidecar") + if sidecar == nil { + t.Fatal("sidecar container not found") + } + for _, env := range sidecar.Env { + if env.Name == "OKDEV_SHELL" { + t.Fatal("expected OKDEV_SHELL to not be set on sidecar") + } + } +} + func TestPreparePodSpecForTargetUsesNamedContainer(t *testing.T) { spec, err := PreparePodSpecForTarget(corev1.PodSpec{ Containers: []corev1.Container{ diff --git a/internal/workload/generic.go b/internal/workload/generic.go index 53e5ac7..e075cda 100644 --- a/internal/workload/generic.go +++ b/internal/workload/generic.go @@ -29,6 +29,7 @@ type GenericRuntime struct { SidecarImage string SidecarResources corev1.ResourceRequirements Tmux bool + Shell string PreStop string TargetContainer string Volumes []corev1.Volume @@ -119,7 +120,7 @@ func (r *GenericRuntime) Apply(ctx context.Context, k ApplyClient, namespace str } else { templateLabels["okdev.io/mesh-role"] = "receiver" } - template.Spec, err = kube.PreparePodSpecForTarget(template.Spec, r.Volumes, r.WorkspaceMountPath, r.SidecarImage, r.SidecarResources, r.Tmux, r.PreStop, r.interactiveContainer()) + template.Spec, err = kube.PreparePodSpecForTargetWithShell(template.Spec, r.Volumes, r.WorkspaceMountPath, r.SidecarImage, r.SidecarResources, r.Tmux, r.PreStop, r.interactiveContainer(), r.Shell) if err != nil { return err } diff --git a/internal/workload/pod.go b/internal/workload/pod.go index db64949..c65d2fd 100644 --- a/internal/workload/pod.go +++ b/internal/workload/pod.go @@ -21,13 +21,14 @@ type PodRuntime struct { SidecarImage string SidecarResources corev1.ResourceRequirements Tmux bool + Shell string PreStop string TargetContainer string LastAppliedSpecJSON string LastAppliedSpecHash string } -func NewPodRuntime(sessionName string, labels, annotations map[string]string, podSpec corev1.PodSpec, volumes []corev1.Volume, workspaceMountPath, sidecarImage string, sidecarResources corev1.ResourceRequirements, tmux bool, preStop, targetContainer string) *PodRuntime { +func NewPodRuntime(sessionName string, labels, annotations map[string]string, podSpec corev1.PodSpec, volumes []corev1.Volume, workspaceMountPath, sidecarImage string, sidecarResources corev1.ResourceRequirements, tmux bool, shell string, preStop, targetContainer string) *PodRuntime { return &PodRuntime{ SessionName: sessionName, Labels: labels, @@ -38,6 +39,7 @@ func NewPodRuntime(sessionName string, labels, annotations map[string]string, po SidecarImage: sidecarImage, SidecarResources: sidecarResources, Tmux: tmux, + Shell: shell, PreStop: preStop, TargetContainer: targetContainer, } @@ -59,7 +61,7 @@ func (r *PodRuntime) WorkloadRef() (string, string, string, error) { } func (r *PodRuntime) Apply(ctx context.Context, k ApplyClient, namespace string) error { - prepared, err := kube.PreparePodSpecForTarget(r.PodSpec, r.Volumes, r.WorkspaceMountPath, r.SidecarImage, r.SidecarResources, r.Tmux, r.PreStop, r.effectiveTargetContainer()) + prepared, err := kube.PreparePodSpecForTargetWithShell(r.PodSpec, r.Volumes, r.WorkspaceMountPath, r.SidecarImage, r.SidecarResources, r.Tmux, r.PreStop, r.effectiveTargetContainer(), r.Shell) if err != nil { return err } diff --git a/internal/workload/pod_snapshot_test.go b/internal/workload/pod_snapshot_test.go index 8bc4533..4aedd2c 100644 --- a/internal/workload/pod_snapshot_test.go +++ b/internal/workload/pod_snapshot_test.go @@ -21,7 +21,7 @@ func (c *captureApplyClient) Apply(_ context.Context, _ string, manifest []byte) func TestPodRuntimeApplySetsLastAppliedAnnotations(t *testing.T) { rt := NewPodRuntime("sess", nil, nil, corev1.PodSpec{Containers: []corev1.Container{{Name: "dev", Image: "ubuntu:22.04"}}}, - nil, "/workspace", "sidecar:1", corev1.ResourceRequirements{}, false, "", "dev", + nil, "/workspace", "sidecar:1", corev1.ResourceRequirements{}, false, "", "", "dev", ) rt.LastAppliedSpecJSON = `{"version":"v1"}` rt.LastAppliedSpecHash = "abc123" @@ -48,7 +48,7 @@ func TestPodRuntimeApplySetsLastAppliedAnnotations(t *testing.T) { func TestPodRuntimeApplyOmitsAnnotationsWhenEmpty(t *testing.T) { rt := NewPodRuntime("sess", nil, nil, corev1.PodSpec{Containers: []corev1.Container{{Name: "dev", Image: "ubuntu:22.04"}}}, - nil, "/workspace", "sidecar:1", corev1.ResourceRequirements{}, false, "", "dev", + nil, "/workspace", "sidecar:1", corev1.ResourceRequirements{}, false, "", "", "dev", ) client := &captureApplyClient{} diff --git a/internal/workload/pod_test.go b/internal/workload/pod_test.go index 94da7ba..90b4d4f 100644 --- a/internal/workload/pod_test.go +++ b/internal/workload/pod_test.go @@ -76,7 +76,7 @@ func TestPodRuntimeLifecycle(t *testing.T) { VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}, }}, "/workspace", "ghcr.io/acmore/okdev:edge", - corev1.ResourceRequirements{}, false, "", "", + corev1.ResourceRequirements{}, false, "", "", "", ) if rt.Kind() != TypePod { t.Fatalf("unexpected kind: %s", rt.Kind()) @@ -128,7 +128,7 @@ func TestPodRuntimeSelectTargetUsesConfiguredContainer(t *testing.T) { map[string]string{"okdev.io/managed": "true"}, nil, corev1.PodSpec{}, nil, "/workspace", "ghcr.io/acmore/okdev:edge", - corev1.ResourceRequirements{}, false, "", "trainer", + corev1.ResourceRequirements{}, false, "", "", "trainer", ) target, err := rt.SelectTarget(context.Background(), &fakeTargetClient{}, "default") if err != nil { From e1c55b3cb756b36c5ccf6a9eebdafc1dc6666c40 Mon Sep 17 00:00:00 2001 From: acmore Date: Sat, 2 May 2026 17:15:07 +0800 Subject: [PATCH 4/7] feat(sshd): per-session interactive shell resolution with zshrc bootstrap Co-Authored-By: Claude Opus 4.6 --- cmd/okdev-sshd/main.go | 46 +++++++++++++++++--- cmd/okdev-sshd/main_test.go | 86 +++++++++++++++++++++++++++++++++++-- 2 files changed, 123 insertions(+), 9 deletions(-) diff --git a/cmd/okdev-sshd/main.go b/cmd/okdev-sshd/main.go index 91668bc..2ca80a6 100644 --- a/cmd/okdev-sshd/main.go +++ b/cmd/okdev-sshd/main.go @@ -78,7 +78,12 @@ func newServer(addr, shell string, keys []ssh.PublicKey) *ssh.Server { } func detectShell() string { - for _, sh := range []string{"/bin/bash", "/bin/sh"} { + if env := strings.TrimSpace(os.Getenv("OKDEV_SHELL")); env != "" { + if _, err := os.Stat(env); err == nil { + return env + } + } + for _, sh := range []string{"/bin/bash", "/bin/zsh", "/bin/sh"} { if _, err := os.Stat(sh); err == nil { return sh } @@ -86,6 +91,24 @@ func detectShell() string { return "/bin/sh" } +func resolveInteractiveShell(serverShell string) string { + if env := strings.TrimSpace(os.Getenv("OKDEV_SHELL")); env != "" { + if _, err := os.Stat(env); err == nil { + return env + } + } + for _, sh := range []string{"/bin/bash", "/bin/zsh", "/bin/sh"} { + if _, err := os.Stat(sh); err == nil { + return sh + } + } + return serverShell +} + +func isZshShell(shell string) bool { + return strings.HasSuffix(shell, "/zsh") +} + func loadAuthorizedKeys(path string) ([]ssh.PublicKey, error) { data, err := os.ReadFile(path) if err != nil { @@ -135,10 +158,11 @@ func sessionHandler(shell string) ssh.Handler { func buildCmd(s ssh.Session, shell string, extraEnv []string) *exec.Cmd { var cmd *exec.Cmd if len(s.RawCommand()) == 0 { - if script := interactiveLoginScript(s, shell); script != "" { + interactiveShell := resolveInteractiveShell(shell) + if script := interactiveLoginScript(s, interactiveShell); script != "" { cmd = exec.Command(shell, "-lc", script) } else { - cmd = exec.Command(shell, "-l") + cmd = exec.Command(interactiveShell, "-l") } } else { cmd = exec.Command(shell, "-lc", s.RawCommand()) @@ -147,10 +171,10 @@ func buildCmd(s ssh.Session, shell string, extraEnv []string) *exec.Cmd { return cmd } -func interactiveLoginScript(s ssh.Session, shell string) string { +func interactiveLoginScript(s ssh.Session, interactiveShell string) string { return buildInteractiveLoginScript( sessionEnvMap(s), - shell, + interactiveShell, strings.TrimSpace(os.Getenv("OKDEV_WORKSPACE")), strings.TrimSpace(os.Getenv("OKDEV_TMUX")), ) @@ -165,6 +189,10 @@ func buildInteractiveLoginScript(sessionEnv map[string]string, shell, workspace, parts = append(parts, fmt.Sprintf("if [ -x %s ]; then %s 2>&1 || echo 'warning: postAttach script failed' >&2; fi", postAttach, postAttach)) } + if zshScript := zshBootstrapScript(workspace, shell); zshScript != "" { + parts = append(parts, zshScript) + } + parts = append(parts, terminalBootstrapScript()) if tmuxFlag == "1" && sessionEnv["OKDEV_NO_TMUX"] != "1" { @@ -175,6 +203,14 @@ func buildInteractiveLoginScript(sessionEnv map[string]string, shell, workspace, return strings.Join(parts, "; ") } +func zshBootstrapScript(workspace, shell string) string { + if !isZshShell(shell) || workspace == "" { + return "" + } + zshrc := shellQuote(strings.TrimRight(workspace, "/") + "/.okdev/zshrc") + return fmt.Sprintf("if [ -f %s ] && [ ! -e ~/.zshrc ]; then printf '%%s\\n' 'if [ -f %s ]; then' ' source %s' 'fi' > ~/.zshrc; fi", zshrc, zshrc, zshrc) +} + func terminalBootstrapScript() string { return `if [ "${TERM:-}" = "xterm-ghostty" ]; then export TERM=xterm-256color; fi` } diff --git a/cmd/okdev-sshd/main_test.go b/cmd/okdev-sshd/main_test.go index 51feb62..b907784 100644 --- a/cmd/okdev-sshd/main_test.go +++ b/cmd/okdev-sshd/main_test.go @@ -48,7 +48,7 @@ func TestNewServerAddsPublicKeyHandlerWhenKeysProvided(t *testing.T) { func TestDetectShellReturnsExistingShell(t *testing.T) { got := detectShell() - if got != "/bin/bash" && got != "/bin/sh" { + if got != "/bin/bash" && got != "/bin/zsh" && got != "/bin/sh" { t.Fatalf("unexpected shell %q", got) } if _, err := os.Stat(got); err != nil { @@ -56,6 +56,46 @@ func TestDetectShellReturnsExistingShell(t *testing.T) { } } +func TestDetectShellReadsOKDEVShellEnv(t *testing.T) { + t.Setenv("OKDEV_SHELL", "/bin/sh") + got := detectShell() + if got != "/bin/sh" { + t.Fatalf("expected /bin/sh from OKDEV_SHELL, got %q", got) + } +} + +func TestDetectShellIgnoresNonexistentOKDEVShell(t *testing.T) { + t.Setenv("OKDEV_SHELL", "/definitely/missing/zsh") + got := detectShell() + if got == "/definitely/missing/zsh" { + t.Fatal("expected detectShell to ignore nonexistent OKDEV_SHELL path") + } +} + +func TestResolveInteractiveShellUsesOKDEVShell(t *testing.T) { + t.Setenv("OKDEV_SHELL", "/bin/sh") + got := resolveInteractiveShell("/bin/bash") + if got != "/bin/sh" { + t.Fatalf("expected /bin/sh from OKDEV_SHELL, got %q", got) + } +} + +func TestResolveInteractiveShellIgnoresNonexistentOKDEVShell(t *testing.T) { + t.Setenv("OKDEV_SHELL", "/definitely/missing/zsh") + got := resolveInteractiveShell("/bin/sh") + if got == "/definitely/missing/zsh" { + t.Fatal("expected resolveInteractiveShell to ignore nonexistent OKDEV_SHELL path") + } +} + +func TestResolveInteractiveShellFallsBackToDetection(t *testing.T) { + t.Setenv("OKDEV_SHELL", "") + got := resolveInteractiveShell("/bin/sh") + if got != "/bin/bash" && got != "/bin/zsh" && got != "/bin/sh" { + t.Fatalf("expected a valid shell from fallback detection, got %q", got) + } +} + func TestLoadAuthorizedKeysMissingFileReturnsNil(t *testing.T) { keys, err := loadAuthorizedKeys("/definitely/missing/authorized_keys") if err != nil { @@ -118,6 +158,33 @@ func TestBuildInteractiveLoginScript(t *testing.T) { } } +func TestBuildInteractiveLoginScriptWithZshSourcesZshrc(t *testing.T) { + script := buildInteractiveLoginScript( + map[string]string{}, + "/bin/zsh", + "/workspace/demo", + "1", + ) + if !strings.Contains(script, ".okdev/zshrc") { + t.Fatalf("expected zsh bootstrap to source .okdev/zshrc: %s", script) + } + if !strings.Contains(script, "exec '/bin/zsh' -l") { + t.Fatalf("expected login shell exec with zsh: %s", script) + } +} + +func TestBuildInteractiveLoginScriptWithBashDoesNotSourceZshrc(t *testing.T) { + script := buildInteractiveLoginScript( + map[string]string{}, + "/bin/bash", + "/workspace/demo", + "1", + ) + if strings.Contains(script, ".okdev/zshrc") { + t.Fatalf("expected bash bootstrap to not source .okdev/zshrc: %s", script) + } +} + func TestBuildInteractiveLoginScriptSkipsTmuxWhenDisabled(t *testing.T) { script := buildInteractiveLoginScript( map[string]string{"OKDEV_NO_TMUX": "1"}, @@ -219,10 +286,21 @@ func TestSessionEnvMap(t *testing.T) { func TestBuildCmdInteractiveShell(t *testing.T) { t.Setenv("OKDEV_WORKSPACE", "") t.Setenv("OKDEV_TMUX", "") + t.Setenv("OKDEV_SHELL", "") cmd := buildCmd(fakeSessionCmd{}, "/bin/sh", nil) - want := `/bin/sh -lc if [ "${TERM:-}" = "xterm-ghostty" ]; then export TERM=xterm-256color; fi; exec '/bin/sh' -l` - if got := strings.Join(cmd.Args, " "); got != want { - t.Fatalf("unexpected interactive args: %q", got) + got := strings.Join(cmd.Args, " ") + // The bootstrap script runs via the server shell (/bin/sh -lc ...), + // but the final exec uses the resolved interactive shell. + if !strings.HasPrefix(got, "/bin/sh -lc") { + t.Fatalf("expected server shell /bin/sh to run the bootstrap script: %q", got) + } + if !strings.Contains(got, "xterm-ghostty") { + t.Fatalf("expected terminal bootstrap in script: %q", got) + } + // The final exec should use whatever resolveInteractiveShell returns. + resolved := resolveInteractiveShell("/bin/sh") + if !strings.Contains(got, "exec '"+resolved+"' -l") { + t.Fatalf("expected exec with resolved interactive shell %q: %q", resolved, got) } } From 230017611b2b8f00710277b4b39a3e3e20bb7e07 Mon Sep 17 00:00:00 2001 From: acmore Date: Sat, 2 May 2026 17:16:37 +0800 Subject: [PATCH 5/7] feat(template): add shell support to template vars and basic template Co-Authored-By: Claude Opus 4.6 --- internal/cli/migrate.go | 3 +++ internal/config/template.go | 3 ++- internal/config/templates/basic.yaml.tmpl | 3 +++ .../templates/zsh-setup.example.sh.tmpl | 27 +++++++++++++++++++ internal/config/templates/zshrc.tmpl | 4 +++ 5 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 internal/config/templates/zsh-setup.example.sh.tmpl create mode 100644 internal/config/templates/zshrc.tmpl diff --git a/internal/cli/migrate.go b/internal/cli/migrate.go index adcc59d..a36722e 100644 --- a/internal/cli/migrate.go +++ b/internal/cli/migrate.go @@ -405,6 +405,9 @@ func templateVarsForMigrate(cfg *config.DevEnvironment, cfgPath string) *config. if sshUser := strings.TrimSpace(cfg.Spec.SSH.User); sshUser != "" { vars.SSHUser = sshUser } + if shell := strings.TrimSpace(cfg.Spec.SSH.Shell); shell != "" { + vars.Shell = shell + } if sidecarImage := strings.TrimSpace(cfg.Spec.Sidecar.Image); sidecarImage != "" { vars.SidecarImage = sidecarImage } diff --git a/internal/config/template.go b/internal/config/template.go index e275aba..cf4377f 100644 --- a/internal/config/template.go +++ b/internal/config/template.go @@ -23,7 +23,7 @@ type templateHTTPDoer interface { Do(*http.Request) (*http.Response, error) } -//go:embed templates/*.yaml.tmpl templates/manifests/*.yaml.tmpl +//go:embed templates/*.yaml.tmpl templates/*.tmpl templates/*.sh.tmpl templates/manifests/*.yaml.tmpl var embeddedTemplates embed.FS // builtinNames maps template names to their embedded file paths. @@ -95,6 +95,7 @@ type TemplateVars struct { SyncLocal string SyncRemote string SSHUser string + Shell string Ports []PortVar WorkloadType string ManifestPath string diff --git a/internal/config/templates/basic.yaml.tmpl b/internal/config/templates/basic.yaml.tmpl index 4834937..2faf904 100644 --- a/internal/config/templates/basic.yaml.tmpl +++ b/internal/config/templates/basic.yaml.tmpl @@ -37,6 +37,9 @@ spec: {{- end }} ssh: user: {{ .SSHUser }} +{{- if .Shell }} + shell: {{ .Shell }} +{{- end }} keepAliveIntervalSeconds: 30 keepAliveTimeoutSeconds: 90 sidecar: diff --git a/internal/config/templates/zsh-setup.example.sh.tmpl b/internal/config/templates/zsh-setup.example.sh.tmpl new file mode 100644 index 0000000..3c9ddaf --- /dev/null +++ b/internal/config/templates/zsh-setup.example.sh.tmpl @@ -0,0 +1,27 @@ +#!/bin/sh +# Example: install zsh and optional frameworks/plugins. +# Review, pin versions, and invoke this from lifecycle.postCreate or +# .okdev/post-create.sh if desired. +# +# Install zsh if not present: +# if ! command -v zsh >/dev/null 2>&1; then +# if command -v apt-get >/dev/null 2>&1; then +# apt-get update && apt-get install -y zsh +# elif command -v apk >/dev/null 2>&1; then +# apk add --no-cache zsh +# elif command -v yum >/dev/null 2>&1; then +# yum install -y zsh +# fi +# fi +# +# Install oh-my-zsh: +# if [ ! -d "$HOME/.oh-my-zsh" ]; then +# sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended +# fi +# +# Install plugins: +# ZSH_CUSTOM="${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}" +# [ -d "$ZSH_CUSTOM/plugins/zsh-autosuggestions" ] || \ +# git clone https://github.com/zsh-users/zsh-autosuggestions "$ZSH_CUSTOM/plugins/zsh-autosuggestions" +# [ -d "$ZSH_CUSTOM/plugins/zsh-syntax-highlighting" ] || \ +# git clone https://github.com/zsh-users/zsh-syntax-highlighting "$ZSH_CUSTOM/plugins/zsh-syntax-highlighting" diff --git a/internal/config/templates/zshrc.tmpl b/internal/config/templates/zshrc.tmpl new file mode 100644 index 0000000..c76a017 --- /dev/null +++ b/internal/config/templates/zshrc.tmpl @@ -0,0 +1,4 @@ +if [ -n "${ZSH_VERSION:-}" ]; then + autoload -Uz colors && colors + PROMPT='%F{cyan}%n@%m%f:%F{blue}%~%f %# ' +fi From 5ea5460c81b55a4f8e602c51649aa9a3942d9ca2 Mon Sep 17 00:00:00 2001 From: acmore Date: Sat, 2 May 2026 18:04:24 +0800 Subject: [PATCH 6/7] feat(init): add --shell flag with zsh file scaffolding When --shell /bin/zsh is passed to okdev init, scaffold .okdev/zshrc and .okdev/zsh-setup.example.sh alongside the main config. Print guidance noting that zsh must be available in the image or installed via lifecycle hooks. Co-Authored-By: Claude Opus 4.6 --- internal/cli/init.go | 55 +++++++++++++++++++++++++++++++++++++++ internal/cli/init_test.go | 42 ++++++++++++++++++++++++++++++ internal/cli/prompt.go | 4 +++ 3 files changed, 101 insertions(+) diff --git a/internal/cli/init.go b/internal/cli/init.go index a7cc281..2847c88 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -32,6 +32,7 @@ func newInitCmd(opts *Options) *cobra.Command { var syncLocalOverride string var syncRemoteOverride string var sshUserOverride string + var shellOverride string var stignorePreset string var setFlags []string @@ -70,6 +71,7 @@ func newInitCmd(opts *Options) *cobra.Command { SyncLocal: syncLocalOverride, SyncRemote: syncRemoteOverride, SSHUser: sshUserOverride, + Shell: shellOverride, } applyOverrides(vars, overrides) applyWorkloadDefaults(vars) @@ -159,6 +161,12 @@ func newInitCmd(opts *Options) *cobra.Command { } } + zshFiles, err := scaffoldZshFiles(abs, vars, force, cmd.OutOrStdout()) + if err != nil { + return err + } + scaffolded = append(scaffolded, zshFiles...) + fmt.Fprintf(cmd.OutOrStdout(), "Wrote %s\n", abs) if resolvedPreset != "" { fmt.Fprintf(cmd.OutOrStdout(), "Using .stignore preset: %s\n", resolvedPreset) @@ -188,6 +196,7 @@ func newInitCmd(opts *Options) *cobra.Command { cmd.Flags().StringVar(&syncLocalOverride, "sync-local", "", "Local sync path") cmd.Flags().StringVar(&syncRemoteOverride, "sync-remote", "", "Remote sync path") cmd.Flags().StringVar(&sshUserOverride, "ssh-user", "", "SSH user") + cmd.Flags().StringVar(&shellOverride, "shell", "", "Shell for interactive SSH sessions (e.g., /bin/zsh)") cmd.Flags().StringVar(&stignorePreset, "stignore-preset", "", "Local .stignore preset: default|python|node|go|rust") cmd.Flags().StringArrayVar(&setFlags, "set", nil, "Set a template variable (repeatable: --set key=value)") return cmd @@ -644,6 +653,52 @@ func detectSTIgnorePreset(dir string) string { return "" } +func scaffoldZshFiles(configPath string, vars *config.TemplateVars, force bool, w io.Writer) ([]string, error) { + if !isZshShellPath(vars.Shell) { + return nil, nil + } + var wrote []string + + zshrcPath := resolveInitScaffoldFilePath(configPath, ".okdev/zshrc") + if _, err := os.Stat(zshrcPath); err != nil || force { + content, err := config.RenderEmbeddedTemplate("templates/zshrc.tmpl", vars) + if err != nil { + return nil, fmt.Errorf("render zshrc template: %w", err) + } + if err := os.MkdirAll(filepath.Dir(zshrcPath), 0o755); err != nil { + return nil, fmt.Errorf("create zshrc directory: %w", err) + } + if err := os.WriteFile(zshrcPath, []byte(content), 0o644); err != nil { + return nil, fmt.Errorf("write zshrc: %w", err) + } + wrote = append(wrote, zshrcPath) + } + + examplePath := resolveInitScaffoldFilePath(configPath, ".okdev/zsh-setup.example.sh") + if _, err := os.Stat(examplePath); err != nil || force { + content, err := config.RenderEmbeddedTemplate("templates/zsh-setup.example.sh.tmpl", vars) + if err != nil { + return nil, fmt.Errorf("render zsh-setup example template: %w", err) + } + if err := os.WriteFile(examplePath, []byte(content), 0o644); err != nil { + return nil, fmt.Errorf("write zsh-setup example: %w", err) + } + wrote = append(wrote, examplePath) + } + + if len(wrote) > 0 { + fmt.Fprintln(w, "Note: spec.ssh.shell affects interactive SSH sessions only.") + fmt.Fprintln(w, " zsh must exist in the image or be installed by your lifecycle hook.") + fmt.Fprintln(w, " Review .okdev/zsh-setup.example.sh for oh-my-zsh/plugin setup recipes.") + } + + return wrote, nil +} + +func isZshShellPath(shell string) bool { + return strings.HasSuffix(strings.TrimSpace(shell), "/zsh") +} + func writeInitSTIgnore(configPath string, rendered []byte, templateRef string, stignorePreset string, force bool, projectDirs ...string) (string, bool, error) { var cfg config.DevEnvironment if err := yaml.Unmarshal(rendered, &cfg); err != nil { diff --git a/internal/cli/init_test.go b/internal/cli/init_test.go index 43fb44e..755a748 100644 --- a/internal/cli/init_test.go +++ b/internal/cli/init_test.go @@ -1072,3 +1072,45 @@ spec: t.Fatalf("expected shadowed basic template ref to be persisted, got:\n%s", string(raw)) } } + +func TestInitWithZshShellScaffoldsZshFiles(t *testing.T) { + tmp := t.TempDir() + oldwd, _ := os.Getwd() + t.Cleanup(func() { _ = os.Chdir(oldwd) }) + if err := os.Chdir(tmp); err != nil { + t.Fatal(err) + } + + cmd := newInitCmd(&Options{}) + cmd.SetArgs([]string{"--yes", "--shell", "/bin/zsh"}) + cmd.SetIn(strings.NewReader("")) + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + if err := cmd.Execute(); err != nil { + t.Fatalf("init: %v", err) + } + + zshrcPath := filepath.Join(tmp, ".okdev", "zshrc") + if _, err := os.Stat(zshrcPath); err != nil { + t.Fatalf("expected .okdev/zshrc to be scaffolded: %v", err) + } + examplePath := filepath.Join(tmp, ".okdev", "zsh-setup.example.sh") + if _, err := os.Stat(examplePath); err != nil { + t.Fatalf("expected .okdev/zsh-setup.example.sh to be scaffolded: %v", err) + } + + cfgRaw, err := os.ReadFile(filepath.Join(tmp, ".okdev.yaml")) + if err != nil { + t.Fatalf("read config: %v", err) + } + if !strings.Contains(string(cfgRaw), "shell: /bin/zsh") { + t.Fatalf("expected config to contain shell: /bin/zsh, got:\n%s", cfgRaw) + } + + if !strings.Contains(out.String(), "zsh-setup.example.sh") { + t.Fatalf("expected guidance message in output, got %q", out.String()) + } +} diff --git a/internal/cli/prompt.go b/internal/cli/prompt.go index e19cbd6..72ca319 100644 --- a/internal/cli/prompt.go +++ b/internal/cli/prompt.go @@ -26,6 +26,7 @@ type InitOverrides struct { SyncLocal string SyncRemote string SSHUser string + Shell string } // applyOverrides applies non-empty flag values to template vars. @@ -66,6 +67,9 @@ func applyOverrides(vars *config.TemplateVars, o InitOverrides) { if o.SSHUser != "" { vars.SSHUser = o.SSHUser } + if o.Shell != "" { + vars.Shell = o.Shell + } } // detectDefaultName returns a sensible default for the environment name From a637932c0e7ab1659304258e1af47cb3ab5427f5 Mon Sep 17 00:00:00 2001 From: acmore Date: Sat, 2 May 2026 18:44:39 +0800 Subject: [PATCH 7/7] fix(zsh): limit shell override to interactive ssh --- cmd/okdev-sshd/main.go | 7 +--- cmd/okdev-sshd/main_test.go | 12 +++---- internal/cli/migrate.go | 66 ++++++++++++++++++++++++++++++++++ internal/cli/migrate_test.go | 68 ++++++++++++++++++++++++++++++++++++ 4 files changed, 141 insertions(+), 12 deletions(-) diff --git a/cmd/okdev-sshd/main.go b/cmd/okdev-sshd/main.go index 2ca80a6..c5108b0 100644 --- a/cmd/okdev-sshd/main.go +++ b/cmd/okdev-sshd/main.go @@ -78,12 +78,7 @@ func newServer(addr, shell string, keys []ssh.PublicKey) *ssh.Server { } func detectShell() string { - if env := strings.TrimSpace(os.Getenv("OKDEV_SHELL")); env != "" { - if _, err := os.Stat(env); err == nil { - return env - } - } - for _, sh := range []string{"/bin/bash", "/bin/zsh", "/bin/sh"} { + for _, sh := range []string{"/bin/bash", "/bin/sh"} { if _, err := os.Stat(sh); err == nil { return sh } diff --git a/cmd/okdev-sshd/main_test.go b/cmd/okdev-sshd/main_test.go index b907784..8b41baf 100644 --- a/cmd/okdev-sshd/main_test.go +++ b/cmd/okdev-sshd/main_test.go @@ -48,7 +48,7 @@ func TestNewServerAddsPublicKeyHandlerWhenKeysProvided(t *testing.T) { func TestDetectShellReturnsExistingShell(t *testing.T) { got := detectShell() - if got != "/bin/bash" && got != "/bin/zsh" && got != "/bin/sh" { + if got != "/bin/bash" && got != "/bin/sh" { t.Fatalf("unexpected shell %q", got) } if _, err := os.Stat(got); err != nil { @@ -56,19 +56,19 @@ func TestDetectShellReturnsExistingShell(t *testing.T) { } } -func TestDetectShellReadsOKDEVShellEnv(t *testing.T) { +func TestDetectShellIgnoresOKDEVShellEnv(t *testing.T) { t.Setenv("OKDEV_SHELL", "/bin/sh") got := detectShell() - if got != "/bin/sh" { - t.Fatalf("expected /bin/sh from OKDEV_SHELL, got %q", got) + if got != "/bin/bash" && got != "/bin/sh" { + t.Fatalf("expected command shell fallback to ignore OKDEV_SHELL, got %q", got) } } func TestDetectShellIgnoresNonexistentOKDEVShell(t *testing.T) { t.Setenv("OKDEV_SHELL", "/definitely/missing/zsh") got := detectShell() - if got == "/definitely/missing/zsh" { - t.Fatal("expected detectShell to ignore nonexistent OKDEV_SHELL path") + if got != "/bin/bash" && got != "/bin/sh" { + t.Fatalf("expected command shell fallback to ignore nonexistent OKDEV_SHELL, got %q", got) } } diff --git a/internal/cli/migrate.go b/internal/cli/migrate.go index a36722e..4849c7f 100644 --- a/internal/cli/migrate.go +++ b/internal/cli/migrate.go @@ -55,6 +55,9 @@ func newMigrateCmd(opts *Options) *cobra.Command { } if len(result.Applied) == 0 { + if err := scaffoldMigrateZshFiles(cfgPath, cmd.OutOrStdout()); err != nil { + fmt.Fprintf(cmd.OutOrStdout(), "warning: failed to scaffold zsh files: %v\n", err) + } fmt.Fprintln(cmd.OutOrStdout(), "Config is already up to date.") return nil } @@ -90,6 +93,10 @@ func newMigrateCmd(opts *Options) *cobra.Command { return fmt.Errorf("write migrated config %q: %w", cfgPath, err) } + if err := scaffoldMigrateZshFiles(cfgPath, w); err != nil { + fmt.Fprintf(w, "warning: failed to scaffold zsh files: %v\n", err) + } + fmt.Fprintf(w, "\nWrote migrated config to %s", cfgPath) if !noBackup { fmt.Fprintf(w, " (backup: %s.bak)", cfgPath) @@ -187,6 +194,10 @@ func runMigrateTemplate(cmd *cobra.Command, cfgPath, templateRef string, setFlag return fmt.Errorf("write migrated companion file %q: %w", file.path, err) } } + if err := scaffoldMigrateZshFiles(cfgPath, w); err != nil { + fmt.Fprintf(w, "warning: failed to scaffold zsh files: %v\n", err) + } + fmt.Fprintf(w, "Wrote migrated config to %s", cfgPath) if !noBackup { fmt.Fprintf(w, " (backup: %s.bak)", cfgPath) @@ -485,3 +496,58 @@ func setTemplateResourceStrings(cpuOut, memoryOut *string, resources corev1.Reso } } } + +func scaffoldMigrateZshFiles(cfgPath string, w io.Writer) error { + raw, err := os.ReadFile(cfgPath) + if err != nil { + return nil + } + var cfg config.DevEnvironment + if err := sigs_yaml.Unmarshal(raw, &cfg); err != nil { + return nil + } + shell := strings.TrimSpace(cfg.Spec.SSH.Shell) + if !strings.HasSuffix(shell, "/zsh") { + return nil + } + + okdevDir := filepath.Dir(cfgPath) + if filepath.Base(okdevDir) != ".okdev" { + okdevDir = filepath.Join(filepath.Dir(cfgPath), ".okdev") + } + + vars := config.NewTemplateVars() + + zshrcPath := filepath.Join(okdevDir, "zshrc") + if _, err := os.Stat(zshrcPath); os.IsNotExist(err) { + content, err := config.RenderEmbeddedTemplate("templates/zshrc.tmpl", vars) + if err != nil { + return fmt.Errorf("render zshrc template: %w", err) + } + if err := os.MkdirAll(okdevDir, 0o755); err != nil { + return fmt.Errorf("create .okdev directory: %w", err) + } + if err := os.WriteFile(zshrcPath, []byte(content), 0o644); err != nil { + return fmt.Errorf("write zshrc: %w", err) + } + fmt.Fprintf(w, "Wrote %s\n", zshrcPath) + } + + examplePath := filepath.Join(okdevDir, "zsh-setup.example.sh") + if _, err := os.Stat(examplePath); os.IsNotExist(err) { + content, err := config.RenderEmbeddedTemplate("templates/zsh-setup.example.sh.tmpl", vars) + if err != nil { + return fmt.Errorf("render zsh-setup example: %w", err) + } + if err := os.WriteFile(examplePath, []byte(content), 0o644); err != nil { + return fmt.Errorf("write zsh-setup example: %w", err) + } + fmt.Fprintf(w, "Wrote %s\n", examplePath) + } + + fmt.Fprintln(w, "Note: spec.ssh.shell affects interactive SSH sessions only.") + fmt.Fprintln(w, " zsh must exist in the image or be installed by your lifecycle hook.") + fmt.Fprintln(w, " Review .okdev/zsh-setup.example.sh for oh-my-zsh/plugin setup recipes.") + + return nil +} diff --git a/internal/cli/migrate_test.go b/internal/cli/migrate_test.go index bcee75c..bd604dd 100644 --- a/internal/cli/migrate_test.go +++ b/internal/cli/migrate_test.go @@ -9,6 +9,74 @@ import ( "testing" ) +func TestMigrateScaffoldsZshFilesWhenShellConfigured(t *testing.T) { + tmp := t.TempDir() + cfgPath := filepath.Join(tmp, ".okdev.yaml") + if err := os.WriteFile(cfgPath, []byte(`apiVersion: okdev.io/v1alpha1 +kind: DevEnvironment +metadata: + name: demo +spec: + ssh: + shell: /bin/zsh +`), 0o644); err != nil { + t.Fatal(err) + } + + cmd := newMigrateCmd(&Options{ConfigPath: cfgPath}) + cmd.SetArgs([]string{"--no-backup"}) + cmd.SetIn(strings.NewReader("")) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(io.Discard) + + if err := cmd.Execute(); err != nil { + t.Fatalf("migrate execute: %v", err) + } + + if _, err := os.Stat(filepath.Join(tmp, ".okdev", "zshrc")); err != nil { + t.Fatalf("expected migrate to scaffold .okdev/zshrc: %v", err) + } + if _, err := os.Stat(filepath.Join(tmp, ".okdev", "zsh-setup.example.sh")); err != nil { + t.Fatalf("expected migrate to scaffold .okdev/zsh-setup.example.sh: %v", err) + } + if !strings.Contains(out.String(), "interactive SSH sessions only") { + t.Fatalf("expected migrate output to include zsh guidance, got:\n%s", out.String()) + } +} + +func TestMigrateSkipsZshScaffoldWhenShellNotZsh(t *testing.T) { + tmp := t.TempDir() + cfgPath := filepath.Join(tmp, ".okdev.yaml") + if err := os.WriteFile(cfgPath, []byte(`apiVersion: okdev.io/v1alpha1 +kind: DevEnvironment +metadata: + name: demo +spec: + ssh: + shell: /bin/bash +`), 0o644); err != nil { + t.Fatal(err) + } + + cmd := newMigrateCmd(&Options{ConfigPath: cfgPath}) + cmd.SetArgs([]string{"--no-backup"}) + cmd.SetIn(strings.NewReader("")) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + if err := cmd.Execute(); err != nil { + t.Fatalf("migrate execute: %v", err) + } + + if _, err := os.Stat(filepath.Join(tmp, ".okdev", "zshrc")); !os.IsNotExist(err) { + t.Fatalf("expected no zshrc scaffold for non-zsh shell, err=%v", err) + } + if _, err := os.Stat(filepath.Join(tmp, ".okdev", "zsh-setup.example.sh")); !os.IsNotExist(err) { + t.Fatalf("expected no zsh setup scaffold for non-zsh shell, err=%v", err) + } +} + type migrateTemplateFixture struct { configPath string manifestPath string