package cli import ( "context" "errors" "fmt" "os/exec" "strings" "banger/internal/api" "banger/internal/model" "banger/internal/rpc" "github.com/spf13/cobra" ) func (d *deps) newVMExecCommand() *cobra.Command { var guestPath string var autoPrepare bool cmd := &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. 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. Exit code of the guest command is propagated verbatim. `), Example: strings.TrimSpace(` banger vm exec dev -- make test banger vm exec dev -- go build ./... banger vm exec dev --auto-prepare -- npm ci && npm test banger vm exec dev --guest-path /root/other -- make lint `), Args: cobra.ArbitraryArgs, RunE: func(cmd *cobra.Command, args []string) error { // Split on -- : everything before is [vm-name], everything after is the command. dash := cmd.ArgsLenAtDash() var vmRef string var command []string switch { case dash < 0: // No -- separator: first arg is VM, rest is command. if len(args) < 2 { return errors.New("usage: banger vm exec -- [args...]") } vmRef = args[0] command = args[1:] case dash == 0 || len(args[dash:]) == 0: return errors.New("usage: banger vm exec -- [args...]") default: vmRef = args[:dash][0] command = args[dash:] } layout, cfg, err := d.ensureDaemon(cmd.Context()) if err != nil { return err } if err := validateSSHPrereqs(cfg); err != nil { return err } // Fetch the full VM record — we need Workspace and GuestIP. result, err := rpc.Call[api.VMShowResult](cmd.Context(), layout.SocketPath, "vm.show", api.VMRefParams{IDOrName: vmRef}) if err != nil { return err } vm := result.VM if vm.State != model.VMStateRunning { return fmt.Errorf("vm %q is not running (state: %s)", vm.Name, vm.State) } // Resolve effective guest workspace path. execGuestPath := strings.TrimSpace(guestPath) if execGuestPath == "" { execGuestPath = vm.Workspace.GuestPath } if execGuestPath == "" { execGuestPath = "/root/repo" } // Dirty-workspace check: compare stored HEAD with current host HEAD. isDirty, currentHead, _ := d.vmExecDirtyCheck(cmd.Context(), vm.Workspace) if isDirty { storedShort := shortRef(vm.Workspace.HeadCommit) currentShort := shortRef(currentHead) preparedLabel := relativeTime(vm.Workspace.PreparedAt) if autoPrepare && vm.Workspace.SourcePath != "" { _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "[vm exec] workspace stale (prepared %s from %s, host HEAD now %s) — re-preparing\n", preparedLabel, storedShort, currentShort) if err := validateVMRunPrereqs(cfg); err != nil { return err } if _, err := d.vmWorkspacePrepare(cmd.Context(), layout.SocketPath, api.VMWorkspacePrepareParams{ IDOrName: vmRef, SourcePath: vm.Workspace.SourcePath, GuestPath: execGuestPath, Mode: string(model.WorkspacePrepareModeShallowOverlay), }); err != nil { return fmt.Errorf("auto-prepare workspace: %w", err) } } else { _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "[vm exec] warning: workspace stale (prepared %s from %s, host HEAD now %s) — use --auto-prepare to re-sync\n", preparedLabel, storedShort, currentShort) } } // Build and run the exec script. script := buildVMExecScript(execGuestPath, command) sshArgs, err := sshCommandArgs(cfg, vm.Runtime.GuestIP, []string{"bash", "-lc", script}) if err != nil { return fmt.Errorf("vm %q: build ssh args: %w", vm.Name, err) } if err := d.sshExec(cmd.Context(), cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), sshArgs); err != nil { var exitErr *exec.ExitError if errors.As(err, &exitErr) { return ExitCodeError{Code: exitErr.ExitCode()} } return err } return nil }, } cmd.Flags().StringVar(&guestPath, "guest-path", "", "workspace directory in the guest (default: from last workspace prepare, or /root/repo)") 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 // 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 { parts := make([]string, len(command)) for i, a := range command { 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), quotedCmd, quotedCmd, ) } // vmExecDirtyCheck compares the HEAD commit stored in the VM's // workspace record against the current HEAD of the host repo. Returns // (false, "", nil) when the check can't be performed (no workspace // recorded, path gone, not a repo, git not installed) so callers // treat unknown as "not dirty" rather than blocking the exec. func (d *deps) vmExecDirtyCheck(ctx context.Context, ws model.VMWorkspace) (isDirty bool, currentHead string, err error) { if ws.SourcePath == "" || ws.HeadCommit == "" { return false, "", nil } out, err := d.hostCommandOutput(ctx, "git", "-C", ws.SourcePath, "rev-parse", "HEAD") if err != nil { // Source path gone, not a git repo, or git not installed — // treat as unknown rather than blocking. return false, "", nil } currentHead = strings.TrimSpace(string(out)) return currentHead != ws.HeadCommit, currentHead, nil } // shortRef returns the first 8 characters of a git ref / commit SHA // for display. Returns the full string if it's already short. func shortRef(ref string) string { if len(ref) > 8 { return ref[:8] } return ref }