fix: drop /root/repo fallback in vm exec for unbound VMs

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) <noreply@anthropic.com>
This commit is contained in:
Thales Maciel 2026-05-01 17:06:46 -03:00
parent 9400bab6fd
commit b0a9d64f4a
No known key found for this signature in database
GPG key ID: 33112E6833C34679
3 changed files with 81 additions and 18 deletions

View file

@ -21,13 +21,14 @@ func (d *deps) newVMExecCommand() *cobra.Command {
Use: "exec <id-or-name> -- <command> [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