vm run redesign: one command, three modes

`vm run` now covers bare sandbox (no args), workspace sandbox (path),
and workspace+command (path -- cmd) in a single entry point. Replaces
the old print-next-steps-and-exit behaviour: bare and workspace modes
drop into interactive ssh, command mode execs via ssh and propagates
the remote exit code through banger's own exit status.

- path argument is optional; --branch / --from still require a path.
- workspace prep and mise tooling bootstrap only run when a path is
  given; command mode skips the bootstrap.
- remote command exit status is wrapped as exitCodeError so main() can
  propagate it instead of collapsing every failure to 1.
- README: promote vm run with three-mode examples; demote vm create
  to a scripting primitive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thales Maciel 2026-04-17 14:00:45 -03:00
parent 8f4be112c2
commit feb679a301
No known key found for this signature in database
GPG key ID: 33112E6833C34679
4 changed files with 376 additions and 225 deletions

View file

@ -496,13 +496,22 @@ func newVMRunCommand() *cobra.Command {
fromRef = "HEAD"
)
cmd := &cobra.Command{
Use: "run [path]",
Short: "Create repo-backed VM and print next steps",
Long: "Create a VM for a local git repository, prepare /root/repo inside the guest, start best-effort mise tooling bootstrap, and print manual access commands.",
Args: maxArgsUsage(1, "usage: banger vm run [path]"),
Use: "run [path] [-- command args...]",
Short: "Create and enter a sandbox VM",
Long: strings.TrimSpace(`
Create a sandbox VM and either drop into an interactive shell or run a command.
Three modes:
banger vm run bare sandbox, drops into ssh
banger vm run ./repo workspace sandbox, drops into ssh at /root/repo
banger vm run ./repo -- make test workspace, runs command, exits with its status
`),
Args: cobra.ArbitraryArgs,
Example: strings.TrimSpace(`
banger vm run
banger vm run ../repo --name agent-box --branch feature/demo
banger vm run ../repo -- make test
banger vm run -- uname -a
`),
RunE: func(cmd *cobra.Command, args []string) error {
if cmd.Flags().Changed("branch") && strings.TrimSpace(branchName) == "" {
@ -512,13 +521,25 @@ func newVMRunCommand() *cobra.Command {
return errors.New("--from requires --branch")
}
sourcePath := ""
if len(args) == 1 {
sourcePath = args[0]
pathArgs, commandArgs := splitVMRunArgs(cmd, args)
if len(pathArgs) > 1 {
return errors.New("usage: banger vm run [path] [-- command args...]")
}
spec, err := inspectVMRunRepo(cmd.Context(), sourcePath, branchName, fromRef)
if err != nil {
return err
sourcePath := ""
if len(pathArgs) == 1 {
sourcePath = pathArgs[0]
}
if sourcePath == "" && strings.TrimSpace(branchName) != "" {
return errors.New("--branch requires a path argument")
}
var specPtr *vmRunRepoSpec
if sourcePath != "" {
spec, err := inspectVMRunRepo(cmd.Context(), sourcePath, branchName, fromRef)
if err != nil {
return err
}
specPtr = &spec
}
layout, err := paths.Resolve()
@ -529,8 +550,14 @@ func newVMRunCommand() *cobra.Command {
if err != nil {
return err
}
if err := validateVMRunPrereqs(cfg); err != nil {
return err
if specPtr != nil {
if err := validateVMRunPrereqs(cfg); err != nil {
return err
}
} else {
if err := validateSSHPrereqs(cfg); err != nil {
return err
}
}
params, err := vmCreateParamsFromFlags(cmd, name, imageName, vcpu, memory, systemOverlaySize, workDiskSize, natEnabled, false)
if err != nil {
@ -543,7 +570,7 @@ func newVMRunCommand() *cobra.Command {
if err != nil {
return err
}
return runVMRun(cmd.Context(), layout.SocketPath, cfg, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), params, spec)
return runVMRun(cmd.Context(), layout.SocketPath, cfg, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), params, specPtr, commandArgs)
},
}
cmd.Flags().StringVar(&name, "name", "", "vm name")
@ -2502,7 +2529,35 @@ func parseNullSeparatedOutput(output []byte) []string {
return values
}
func runVMRun(ctx context.Context, socketPath string, cfg model.DaemonConfig, stdin io.Reader, stdout, stderr io.Writer, params api.VMCreateParams, spec vmRunRepoSpec) error {
// splitVMRunArgs partitions cobra positional args into the optional path
// argument and the trailing command (everything after a `--` separator).
// The path slice may contain 0..1 entries; the command slice may be empty.
func splitVMRunArgs(cmd *cobra.Command, args []string) (pathArgs, commandArgs []string) {
dash := cmd.ArgsLenAtDash()
if dash < 0 {
return args, nil
}
if dash > len(args) {
dash = len(args)
}
return args[:dash], args[dash:]
}
// exitCodeError wraps a remote command's exit status so the CLI's main()
// can propagate it verbatim. Setup errors and other failures stay as
// regular errors.
type exitCodeError struct {
Code int
}
func (e exitCodeError) Error() string {
return fmt.Sprintf("exit status %d", e.Code)
}
// ExitCode exposes the code for callers using errors.As.
func (e exitCodeError) ExitCode() int { return e.Code }
func runVMRun(ctx context.Context, socketPath string, cfg model.DaemonConfig, stdin io.Reader, stdout, stderr io.Writer, params api.VMCreateParams, spec *vmRunRepoSpec, command []string) error {
progress := newVMRunProgressRenderer(stderr)
vm, err := runVMCreate(ctx, socketPath, stderr, params)
if err != nil {
@ -2512,34 +2567,51 @@ func runVMRun(ctx context.Context, socketPath string, cfg model.DaemonConfig, st
if vmRef == "" {
vmRef = shortID(vm.ID)
}
progress.render("preparing guest workspace")
if _, err := vmWorkspacePrepareFunc(ctx, socketPath, api.VMWorkspacePrepareParams{
IDOrName: vmRef,
SourcePath: spec.SourcePath,
GuestPath: vmRunGuestDir(),
Branch: spec.BranchName,
From: spec.FromRef,
Mode: string(model.WorkspacePrepareModeShallowOverlay),
}); err != nil {
return fmt.Errorf("vm %q is running but workspace prepare failed: %w", vmRef, err)
}
sshAddress := net.JoinHostPort(vm.Runtime.GuestIP, "22")
progress.render("waiting for guest ssh")
if err := guestWaitForSSHFunc(ctx, sshAddress, cfg.SSHKeyPath, 250*time.Millisecond); err != nil {
return fmt.Errorf("vm %q is running but guest ssh is unavailable: %w", vmRef, err)
}
client, err := guestDialFunc(ctx, sshAddress, cfg.SSHKeyPath)
if spec != nil {
progress.render("preparing guest workspace")
if _, err := vmWorkspacePrepareFunc(ctx, socketPath, api.VMWorkspacePrepareParams{
IDOrName: vmRef,
SourcePath: spec.SourcePath,
GuestPath: vmRunGuestDir(),
Branch: spec.BranchName,
From: spec.FromRef,
Mode: string(model.WorkspacePrepareModeShallowOverlay),
}); err != nil {
return fmt.Errorf("vm %q is running but workspace prepare failed: %w", vmRef, err)
}
if len(command) == 0 {
client, err := guestDialFunc(ctx, sshAddress, cfg.SSHKeyPath)
if err != nil {
return fmt.Errorf("vm %q is running but guest ssh is unavailable: %w", vmRef, err)
}
if err := startVMRunToolingHarness(ctx, client, *spec, progress); err != nil {
printVMRunWarning(stderr, fmt.Sprintf("guest tooling bootstrap start failed: %v", err))
}
_ = client.Close()
}
}
sshArgs, err := sshCommandArgs(cfg, vm.Runtime.GuestIP, command)
if err != nil {
return fmt.Errorf("vm %q is running but guest ssh is unavailable: %w", vmRef, err)
return fmt.Errorf("vm %q is running but ssh args could not be built: %w", vmRef, err)
}
defer client.Close()
if err := startVMRunToolingHarness(ctx, client, spec, progress); err != nil {
printVMRunWarning(stderr, fmt.Sprintf("guest tooling bootstrap start failed: %v", err))
if len(command) > 0 {
progress.render("running command in guest")
if err := sshExecFunc(ctx, stdin, stdout, stderr, sshArgs); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return exitCodeError{Code: exitErr.ExitCode()}
}
return err
}
return nil
}
if progress != nil {
progress.render("printing next steps")
}
return printVMRunNextSteps(stdout, vm)
progress.render("attaching to guest")
return runSSHSession(ctx, socketPath, vmRef, stdin, stdout, stderr, sshArgs)
}
func importVMRunRepoToGuest(ctx context.Context, client vmRunGuestClient, spec vmRunRepoSpec, progress *vmRunProgressRenderer) error {
@ -2774,33 +2846,6 @@ func vmRunToolingHarnessLaunchScript(spec vmRunRepoSpec) string {
return script.String()
}
func printVMRunNextSteps(out io.Writer, vm model.VMRecord) error {
if out == nil {
return nil
}
vmRef := strings.TrimSpace(vm.Name)
if vmRef == "" {
vmRef = shortID(vm.ID)
}
hostRef := strings.TrimSpace(vm.Runtime.DNSName)
if hostRef == "" {
hostRef = strings.TrimSpace(vm.Runtime.GuestIP)
}
guestDir := vmRunGuestDir()
_, err := fmt.Fprintf(out, `VM ready.
Name: %s
Host: %s
Repo: %s
Next:
banger vm ssh %s
opencode attach http://%s:4096 --dir %s
banger vm acp %s
banger vm ssh %s -- "cd %s && claude"
banger vm ssh %s -- "cd %s && pi"
`, vmRef, hostRef, guestDir, vmRef, hostRef, guestDir, vmRef, vmRef, guestDir, vmRef, guestDir)
return err
}
func formatVMRunStepError(action string, err error, log string) error {
log = strings.TrimSpace(log)
if log == "" {