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:
parent
9400bab6fd
commit
b0a9d64f4a
3 changed files with 81 additions and 18 deletions
20
CHANGELOG.md
20
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
35
internal/cli/vm_exec_test.go
Normal file
35
internal/cli/vm_exec_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue