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]
|
## [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
|
## [v0.1.8] - 2026-05-01
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,14 @@ func (d *deps) newVMExecCommand() *cobra.Command {
|
||||||
Use: "exec <id-or-name> -- <command> [args...]",
|
Use: "exec <id-or-name> -- <command> [args...]",
|
||||||
Short: "Run a command in the VM workspace with the repo toolchain",
|
Short: "Run a command in the VM workspace with the repo toolchain",
|
||||||
Long: strings.TrimSpace(`
|
Long: strings.TrimSpace(`
|
||||||
Run a command inside a persistent VM, automatically cd-ing into the
|
Run a command inside a persistent VM, wrapping it with 'mise exec' so
|
||||||
prepared workspace and wrapping the command with 'mise exec' so all
|
all mise-managed tools (Go, Node, Python, etc.) are on PATH.
|
||||||
mise-managed tools (Go, Node, Python, etc.) are on PATH.
|
|
||||||
|
|
||||||
The workspace path comes from the last 'vm workspace prepare' or
|
If the VM has a prepared workspace (from 'vm workspace prepare' or
|
||||||
'vm run ./repo' on this VM. If the host repo has advanced since then,
|
'vm run ./repo'), the command runs from that directory and a stale-
|
||||||
banger warns; pass --auto-prepare to re-sync the workspace first.
|
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.
|
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)
|
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)
|
execGuestPath := strings.TrimSpace(guestPath)
|
||||||
if execGuestPath == "" {
|
if execGuestPath == "" {
|
||||||
execGuestPath = vm.Workspace.GuestPath
|
execGuestPath = strings.TrimSpace(vm.Workspace.GuestPath)
|
||||||
}
|
|
||||||
if execGuestPath == "" {
|
|
||||||
execGuestPath = "/root/repo"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dirty-workspace check: compare stored HEAD with current host HEAD.
|
// 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
|
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.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)
|
_ = cmd.RegisterFlagCompletionFunc("guest-path", cobra.NoFileCompletions)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildVMExecScript returns the bash -lc argument that cd's into the
|
// buildVMExecScript returns the bash -lc argument that runs the
|
||||||
// workspace and runs the command through mise exec when mise is
|
// command through mise exec when mise is available, falling back to a
|
||||||
// available, falling back to a plain exec if it's not. Each command
|
// 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
|
// argument is shell-quoted so spaces and special characters survive
|
||||||
// the bash re-parse inside the -lc string.
|
// the bash re-parse inside the -lc string.
|
||||||
func buildVMExecScript(guestPath string, command []string) string {
|
func buildVMExecScript(guestPath string, command []string) string {
|
||||||
|
|
@ -147,12 +152,15 @@ func buildVMExecScript(guestPath string, command []string) string {
|
||||||
parts[i] = shellQuote(a)
|
parts[i] = shellQuote(a)
|
||||||
}
|
}
|
||||||
quotedCmd := strings.Join(parts, " ")
|
quotedCmd := strings.Join(parts, " ")
|
||||||
return fmt.Sprintf(
|
body := fmt.Sprintf(
|
||||||
"cd %s && if command -v mise >/dev/null 2>&1; then mise exec -- %s; else %s; fi",
|
"if command -v mise >/dev/null 2>&1; then mise exec -- %s; else %s; fi",
|
||||||
shellQuote(guestPath),
|
|
||||||
quotedCmd,
|
quotedCmd,
|
||||||
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
|
// 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