From b0a9d64f4af8ad739bf58e2c201d091dee3f9395 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Fri, 1 May 2026 17:06:46 -0300 Subject: [PATCH] fix: drop /root/repo fallback in vm exec for unbound VMs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vm exec defaulted execGuestPath to /root/repo whenever the VM had no recorded workspace, so running it against a plain VM (one that never had vm workspace prepare / vm run ./repo) blew up with 'cd: /root/repo: No such file or directory' — surfaced via the login shell's mise activate hook because bash -lc sources profile.d before the explicit cd. Now auto-cd only fires when --guest-path is passed or the VM actually has a workspace recorded; otherwise the command runs from root's home. Mise wrapping unchanged — without a .mise.toml it's a no-op. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 20 ++++++++++++++++ internal/cli/vm_exec.go | 44 +++++++++++++++++++++--------------- internal/cli/vm_exec_test.go | 35 ++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 18 deletions(-) create mode 100644 internal/cli/vm_exec_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 23bdabc..f0eb0e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,26 @@ changed between versions. ## [Unreleased] +### Fixed + +- `vm exec` no longer falls back to `cd /root/repo` on VMs that have + no recorded workspace. Previously, running `vm exec` against a plain + VM (one that never had `vm workspace prepare` / `vm run ./repo`) + blew up with `cd: /root/repo: No such file or directory` — surfaced + via the login shell's mise activate hook because `bash -lc` sources + profile.d before the explicit cd. Now the auto-cd only fires when + the user passes `--guest-path` or the VM actually has a workspace + recorded; otherwise the command runs from root's home. Mise wrapping + is unchanged — without a `.mise.toml` it's a no-op. + +### Changed + +- `vm exec --guest-path` default in `--help` now reads "from last + workspace prepare; otherwise root's home" (was "or /root/repo"). + Anyone who relied on the implicit `/root/repo` default for a VM that + has a repo there but no workspace record must now pass + `--guest-path /root/repo` explicitly. + ## [v0.1.8] - 2026-05-01 ### Fixed diff --git a/internal/cli/vm_exec.go b/internal/cli/vm_exec.go index cfd8453..2ec862a 100644 --- a/internal/cli/vm_exec.go +++ b/internal/cli/vm_exec.go @@ -21,13 +21,14 @@ func (d *deps) newVMExecCommand() *cobra.Command { Use: "exec -- [args...]", Short: "Run a command in the VM workspace with the repo toolchain", Long: strings.TrimSpace(` -Run a command inside a persistent VM, automatically cd-ing into the -prepared workspace and wrapping the command with 'mise exec' so all -mise-managed tools (Go, Node, Python, etc.) are on PATH. +Run a command inside a persistent VM, wrapping it with 'mise exec' so +all mise-managed tools (Go, Node, Python, etc.) are on PATH. -The workspace path comes from the last 'vm workspace prepare' or -'vm run ./repo' on this VM. If the host repo has advanced since then, -banger warns; pass --auto-prepare to re-sync the workspace first. +If the VM has a prepared workspace (from 'vm workspace prepare' or +'vm run ./repo'), the command runs from that directory and a stale- +workspace warning is printed when the host repo has advanced since the +last prepare; pass --auto-prepare to re-sync first. Otherwise the +command runs from root's home directory. --guest-path overrides both. Exit code of the guest command is propagated verbatim. `), @@ -76,13 +77,14 @@ Exit code of the guest command is propagated verbatim. return fmt.Errorf("vm %q is not running (state: %s)", vm.Name, vm.State) } - // Resolve effective guest workspace path. + // Resolve effective guest workspace path. Empty means "no + // cd": run from the SSH session's default cwd ($HOME). We + // only auto-cd when the user explicitly passed --guest-path + // or the VM actually has a recorded workspace — otherwise + // arbitrary VMs (no repo) would fail with cd errors. execGuestPath := strings.TrimSpace(guestPath) if execGuestPath == "" { - execGuestPath = vm.Workspace.GuestPath - } - if execGuestPath == "" { - execGuestPath = "/root/repo" + execGuestPath = strings.TrimSpace(vm.Workspace.GuestPath) } // Dirty-workspace check: compare stored HEAD with current host HEAD. @@ -130,15 +132,18 @@ Exit code of the guest command is propagated verbatim. return nil }, } - cmd.Flags().StringVar(&guestPath, "guest-path", "", "workspace directory in the guest (default: from last workspace prepare, or /root/repo)") + cmd.Flags().StringVar(&guestPath, "guest-path", "", "workspace directory in the guest (default: from last workspace prepare; otherwise root's home)") cmd.Flags().BoolVar(&autoPrepare, "auto-prepare", false, "re-sync the workspace from the host repo before running if it's stale") _ = cmd.RegisterFlagCompletionFunc("guest-path", cobra.NoFileCompletions) return cmd } -// buildVMExecScript returns the bash -lc argument that cd's into the -// workspace and runs the command through mise exec when mise is -// available, falling back to a plain exec if it's not. Each command +// buildVMExecScript returns the bash -lc argument that runs the +// command through mise exec when mise is available, falling back to a +// plain exec if it's not. When guestPath is non-empty, the script +// cd's into it first (workspace mode); when empty, the command runs +// from the SSH session's default cwd so VMs without a prepared +// workspace don't blow up on a non-existent /root/repo. Each command // argument is shell-quoted so spaces and special characters survive // the bash re-parse inside the -lc string. func buildVMExecScript(guestPath string, command []string) string { @@ -147,12 +152,15 @@ func buildVMExecScript(guestPath string, command []string) string { parts[i] = shellQuote(a) } quotedCmd := strings.Join(parts, " ") - return fmt.Sprintf( - "cd %s && if command -v mise >/dev/null 2>&1; then mise exec -- %s; else %s; fi", - shellQuote(guestPath), + body := fmt.Sprintf( + "if command -v mise >/dev/null 2>&1; then mise exec -- %s; else %s; fi", quotedCmd, quotedCmd, ) + if guestPath == "" { + return body + } + return fmt.Sprintf("cd %s && %s", shellQuote(guestPath), body) } // vmExecDirtyCheck compares the HEAD commit stored in the VM's diff --git a/internal/cli/vm_exec_test.go b/internal/cli/vm_exec_test.go new file mode 100644 index 0000000..e57f5af --- /dev/null +++ b/internal/cli/vm_exec_test.go @@ -0,0 +1,35 @@ +package cli + +import ( + "strings" + "testing" +) + +func TestBuildVMExecScriptWithGuestPath(t *testing.T) { + got := buildVMExecScript("/root/repo", []string{"make", "test"}) + want := "cd '/root/repo' && if command -v mise >/dev/null 2>&1; then mise exec -- 'make' 'test'; else 'make' 'test'; fi" + if got != want { + t.Fatalf("buildVMExecScript with path:\n got: %q\n want: %q", got, want) + } +} + +func TestBuildVMExecScriptWithoutGuestPath(t *testing.T) { + got := buildVMExecScript("", []string{"whoami"}) + want := "if command -v mise >/dev/null 2>&1; then mise exec -- 'whoami'; else 'whoami'; fi" + if got != want { + t.Fatalf("buildVMExecScript without path:\n got: %q\n want: %q", got, want) + } + if strings.Contains(got, "cd ") { + t.Fatalf("expected no cd when guestPath is empty, got: %q", got) + } +} + +func TestBuildVMExecScriptShellQuotesPathWithSpaces(t *testing.T) { + got := buildVMExecScript("/tmp/with space", []string{"echo", "a b"}) + if !strings.Contains(got, "cd '/tmp/with space'") { + t.Fatalf("expected guest path to be shell-quoted, got: %q", got) + } + if !strings.Contains(got, "mise exec -- 'echo' 'a b'") { + t.Fatalf("expected command args to be shell-quoted, got: %q", got) + } +}