package cli import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net" "net/url" "os" "os/exec" "path/filepath" "sort" "strings" "sync" "syscall" "text/tabwriter" "time" "banger/internal/api" "banger/internal/buildinfo" "banger/internal/config" "banger/internal/daemon" "banger/internal/guest" "banger/internal/hostnat" "banger/internal/imagepreset" "banger/internal/model" "banger/internal/paths" "banger/internal/rpc" "banger/internal/system" "banger/internal/toolingplan" "banger/internal/vmdns" "banger/internal/vsockagent" "github.com/spf13/cobra" ) var ( bangerdPathFunc = paths.BangerdPath daemonExePath = func(pid int) string { return filepath.Join("/proc", fmt.Sprintf("%d", pid), "exe") } doctorFunc = daemon.Doctor sshExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error { sshCmd := exec.CommandContext(ctx, "ssh", args...) sshCmd.Stdout = stdout sshCmd.Stderr = stderr sshCmd.Stdin = stdin return sshCmd.Run() } opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error { opencodeCmd := exec.CommandContext(ctx, "opencode", args...) opencodeCmd.Stdout = stdout opencodeCmd.Stderr = stderr opencodeCmd.Stdin = stdin return opencodeCmd.Run() } hostOpencodeAttachSupportedFunc = hostOpencodeAttachSupported hostCommandOutputFunc = func(ctx context.Context, name string, args ...string) ([]byte, error) { cmd := exec.CommandContext(ctx, name, args...) output, err := cmd.CombinedOutput() if err == nil { return output, nil } command := strings.TrimSpace(strings.Join(append([]string{name}, args...), " ")) detail := strings.TrimSpace(string(output)) if detail == "" { return output, fmt.Errorf("%s: %w", command, err) } return output, fmt.Errorf("%s: %w: %s", command, err, detail) } vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) { return rpc.Call[api.VMHealthResult](ctx, socketPath, "vm.health", api.VMRefParams{IDOrName: idOrName}) } daemonPingFunc = func(ctx context.Context, socketPath string) (api.PingResult, error) { return rpc.Call[api.PingResult](ctx, socketPath, "ping", api.Empty{}) } vmCreateBeginFunc = func(ctx context.Context, socketPath string, params api.VMCreateParams) (api.VMCreateBeginResult, error) { return rpc.Call[api.VMCreateBeginResult](ctx, socketPath, "vm.create.begin", params) } vmCreateStatusFunc = func(ctx context.Context, socketPath, operationID string) (api.VMCreateStatusResult, error) { return rpc.Call[api.VMCreateStatusResult](ctx, socketPath, "vm.create.status", api.VMCreateStatusParams{ID: operationID}) } vmCreateCancelFunc = func(ctx context.Context, socketPath, operationID string) error { _, err := rpc.Call[api.Empty](ctx, socketPath, "vm.create.cancel", api.VMCreateStatusParams{ID: operationID}) return err } vmPortsFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMPortsResult, error) { return rpc.Call[api.VMPortsResult](ctx, socketPath, "vm.ports", api.VMRefParams{IDOrName: idOrName}) } guestWaitForSSHFunc = func(ctx context.Context, address, privateKeyPath string, interval time.Duration) error { return guest.WaitForSSH(ctx, address, privateKeyPath, interval) } guestDialFunc = func(ctx context.Context, address, privateKeyPath string) (vmRunGuestClient, error) { return guest.Dial(ctx, address, privateKeyPath) } prepareVMRunRepoCopyFunc = prepareVMRunRepoCopy buildVMRunToolingPlanFunc = toolingplan.Build cwdFunc = os.Getwd ) type vmRunGuestClient interface { Close() error UploadFile(ctx context.Context, remotePath string, mode os.FileMode, data []byte, logWriter io.Writer) error RunScript(ctx context.Context, script string, logWriter io.Writer) error StreamTar(ctx context.Context, sourceDir, remoteCommand string, logWriter io.Writer) error StreamTarEntries(ctx context.Context, sourceDir string, entries []string, remoteCommand string, logWriter io.Writer) error } type vmRunRepoSpec struct { SourcePath string RepoRoot string RepoName string HeadCommit string CurrentBranch string BranchName string BaseCommit string OriginURL string GitUserName string GitUserEmail string OverlayPaths []string } const vmRunShallowFetchDepth = 10 const vmRunToolingHarnessModel = "opencode/mimo-v2-pro-free" const vmRunToolingHarnessTimeoutSeconds = 45 const vmRunToolingInstallTimeoutSeconds = 120 const vmRunToolingHarnessPrompt = `You are preparing a development VM for this repository. Inspect the repository for developer tools and binaries that are clearly needed to work on it. Look at files like .mise.toml, .tool-versions, README/setup docs, CI config, task runners, scripts, and build manifests. Rules: - Use mise only for installs. - Do not edit repository files. - Prefer repo-declared versions first. - If a tool is clearly required but not pinned, you may install a conservative guest-global tool with mise. - Skip ambiguous installs instead of guessing. - End with a short summary of what you installed and what you skipped.` func NewBangerCommand() *cobra.Command { root := &cobra.Command{ Use: "banger", Short: "Manage development VMs and images", SilenceUsage: true, SilenceErrors: true, RunE: helpNoArgs, } root.CompletionOptions.DisableDefaultCmd = true root.AddCommand(newDaemonCommand(), newDoctorCommand(), newImageCommand(), newInternalCommand(), newVersionCommand(), newVMCommand()) return root } func newDoctorCommand() *cobra.Command { return &cobra.Command{ Use: "doctor", Short: "Check host and runtime readiness", Args: noArgsUsage("usage: banger doctor"), RunE: func(cmd *cobra.Command, args []string) error { report, err := doctorFunc(cmd.Context()) if err != nil { return err } if err := printDoctorReport(cmd.OutOrStdout(), report); err != nil { return err } if report.HasFailures() { return errors.New("doctor found failing checks") } return nil }, } } func newVersionCommand() *cobra.Command { return &cobra.Command{ Use: "version", Short: "Show banger build information", Args: noArgsUsage("usage: banger version"), RunE: func(cmd *cobra.Command, args []string) error { _, err := fmt.Fprint(cmd.OutOrStdout(), formatBuildInfoBlock(buildinfo.Current())) return err }, } } func newInternalCommand() *cobra.Command { cmd := &cobra.Command{ Use: "internal", Hidden: true, RunE: helpNoArgs, } cmd.AddCommand( newInternalNATCommand(), newInternalWorkSeedCommand(), newInternalSSHKeyPathCommand(), newInternalFirecrackerPathCommand(), newInternalVSockAgentPathCommand(), newInternalPackagesCommand(), ) return cmd } func newInternalSSHKeyPathCommand() *cobra.Command { return &cobra.Command{ Use: "ssh-key-path", Hidden: true, Args: noArgsUsage("usage: banger internal ssh-key-path"), RunE: func(cmd *cobra.Command, args []string) error { layout, err := paths.Resolve() if err != nil { return err } cfg, err := config.Load(layout) if err != nil { return err } _, err = fmt.Fprintln(cmd.OutOrStdout(), cfg.SSHKeyPath) return err }, } } func newInternalFirecrackerPathCommand() *cobra.Command { return &cobra.Command{ Use: "firecracker-path", Hidden: true, Args: noArgsUsage("usage: banger internal firecracker-path"), RunE: func(cmd *cobra.Command, args []string) error { layout, err := paths.Resolve() if err != nil { return err } cfg, err := config.Load(layout) if err != nil { return err } if strings.TrimSpace(cfg.FirecrackerBin) == "" { return errors.New("firecracker binary not configured; install firecracker or set firecracker_bin") } _, err = fmt.Fprintln(cmd.OutOrStdout(), cfg.FirecrackerBin) return err }, } } func newInternalVSockAgentPathCommand() *cobra.Command { return &cobra.Command{ Use: "vsock-agent-path", Hidden: true, Args: noArgsUsage("usage: banger internal vsock-agent-path"), RunE: func(cmd *cobra.Command, args []string) error { path, err := paths.CompanionBinaryPath("banger-vsock-agent") if err != nil { return err } _, err = fmt.Fprintln(cmd.OutOrStdout(), path) return err }, } } func newInternalPackagesCommand() *cobra.Command { var docker bool cmd := &cobra.Command{ Use: "packages ", Hidden: true, Args: exactArgsUsage(1, "usage: banger internal packages [--docker]"), RunE: func(cmd *cobra.Command, args []string) error { var packages []string switch strings.TrimSpace(args[0]) { case "debian": packages = imagepreset.DebianBasePackages() if docker { packages = append(packages, "docker.io") } case "void": packages = imagepreset.VoidBasePackages() case "alpine": packages = imagepreset.AlpineBasePackages() default: return fmt.Errorf("unknown package preset %q", args[0]) } for _, pkg := range packages { if _, err := fmt.Fprintln(cmd.OutOrStdout(), pkg); err != nil { return err } } return nil }, } cmd.Flags().BoolVar(&docker, "docker", false, "include docker-specific additions") return cmd } func newInternalWorkSeedCommand() *cobra.Command { var rootfsPath string var outPath string cmd := &cobra.Command{ Use: "work-seed", Hidden: true, Args: noArgsUsage("usage: banger internal work-seed --rootfs [--out ]"), RunE: func(cmd *cobra.Command, args []string) error { rootfsPath = strings.TrimSpace(rootfsPath) outPath = strings.TrimSpace(outPath) if rootfsPath == "" { return errors.New("rootfs path is required") } if outPath == "" { outPath = system.WorkSeedPath(rootfsPath) } if err := system.EnsureSudo(cmd.Context()); err != nil { return err } return system.BuildWorkSeedImage(cmd.Context(), system.NewRunner(), rootfsPath, outPath) }, } cmd.Flags().StringVar(&rootfsPath, "rootfs", "", "rootfs image path") cmd.Flags().StringVar(&outPath, "out", "", "output work-seed image path") return cmd } func newInternalNATCommand() *cobra.Command { cmd := &cobra.Command{ Use: "nat", Hidden: true, RunE: helpNoArgs, } cmd.AddCommand( newInternalNATActionCommand("up", true), newInternalNATActionCommand("down", false), ) return cmd } func newInternalNATActionCommand(use string, enable bool) *cobra.Command { var guestIP string var tapDevice string cmd := &cobra.Command{ Use: use, Hidden: true, Args: noArgsUsage("usage: banger internal nat " + use + " --guest-ip --tap "), RunE: func(cmd *cobra.Command, args []string) error { guestIP = strings.TrimSpace(guestIP) tapDevice = strings.TrimSpace(tapDevice) if guestIP == "" { return errors.New("guest IP is required") } if tapDevice == "" { return errors.New("tap device is required") } if err := system.EnsureSudo(cmd.Context()); err != nil { return err } return hostnat.Ensure(cmd.Context(), system.NewRunner(), guestIP, tapDevice, enable) }, } cmd.Flags().StringVar(&guestIP, "guest-ip", "", "guest IPv4 address") cmd.Flags().StringVar(&tapDevice, "tap", "", "tap device name") return cmd } func newDaemonCommand() *cobra.Command { cmd := &cobra.Command{ Use: "daemon", Short: "Manage the banger daemon", RunE: helpNoArgs, } cmd.AddCommand( &cobra.Command{ Use: "status", Short: "Show daemon status", Args: noArgsUsage("usage: banger daemon status"), RunE: func(cmd *cobra.Command, args []string) error { layout, err := paths.Resolve() if err != nil { return err } cfg, err := config.Load(layout) if err != nil { return err } ping, pingErr := daemonPingFunc(cmd.Context(), layout.SocketPath) if pingErr != nil { if strings.TrimSpace(cfg.WebListenAddr) != "" { _, err = fmt.Fprintf(cmd.OutOrStdout(), "stopped\nsocket: %s\nlog: %s\ndns: %s\nweb: http://%s\n", layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr, cfg.WebListenAddr) return err } _, err = fmt.Fprintf(cmd.OutOrStdout(), "stopped\nsocket: %s\nlog: %s\ndns: %s\n", layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr) return err } info := buildinfo.Normalize(ping.Version, ping.Commit, ping.BuiltAt) if strings.TrimSpace(ping.WebURL) != "" { _, err = fmt.Fprintf(cmd.OutOrStdout(), "running\npid: %d\n%ssocket: %s\nlog: %s\ndns: %s\nweb: %s\n", ping.PID, formatBuildInfoBlock(info), layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr, ping.WebURL) return err } _, err = fmt.Fprintf(cmd.OutOrStdout(), "running\npid: %d\n%ssocket: %s\nlog: %s\ndns: %s\n", ping.PID, formatBuildInfoBlock(info), layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr) return err }, }, &cobra.Command{ Use: "stop", Short: "Stop the daemon", Args: noArgsUsage("usage: banger daemon stop"), RunE: func(cmd *cobra.Command, args []string) error { if err := system.EnsureSudo(cmd.Context()); err != nil { return err } layout, err := paths.Resolve() if err != nil { return err } _, err = rpc.Call[api.ShutdownResult](cmd.Context(), layout.SocketPath, "shutdown", api.Empty{}) if err != nil { if os.IsNotExist(err) || strings.Contains(err.Error(), "connect") { _, writeErr := fmt.Fprintln(cmd.OutOrStdout(), "daemon not running") return writeErr } return err } _, err = fmt.Fprintln(cmd.OutOrStdout(), "stopping") return err }, }, &cobra.Command{ Use: "socket", Short: "Print the daemon socket path", Args: noArgsUsage("usage: banger daemon socket"), RunE: func(cmd *cobra.Command, args []string) error { layout, err := paths.Resolve() if err != nil { return err } _, err = fmt.Fprintln(cmd.OutOrStdout(), layout.SocketPath) return err }, }, ) return cmd } func newVMCommand() *cobra.Command { cmd := &cobra.Command{ Use: "vm", Short: "Manage virtual machines", RunE: helpNoArgs, } cmd.AddCommand( newVMCreateCommand(), newVMRunCommand(), newVMListCommand(), newVMShowCommand(), newVMActionCommand("start", "Start a VM", "vm.start"), newVMActionCommand("stop", "Stop a VM", "vm.stop"), newVMKillCommand(), newVMActionCommand("restart", "Restart a VM", "vm.restart"), newVMActionCommand("delete", "Delete a VM", "vm.delete"), newVMSetCommand(), newVMSSHCommand(), newVMLogsCommand(), newVMStatsCommand(), newVMPortsCommand(), ) return cmd } func newVMRunCommand() *cobra.Command { var ( name string imageName string vcpu = model.DefaultVCPUCount memory = model.DefaultMemoryMiB systemOverlaySize = model.FormatSizeBytes(model.DefaultSystemOverlaySize) workDiskSize = model.FormatSizeBytes(model.DefaultWorkDiskSize) natEnabled bool branchName string fromRef = "HEAD" ) cmd := &cobra.Command{ Use: "run [path]", Short: "Create a repo-backed VM session and attach opencode", Args: maxArgsUsage(1, "usage: banger vm run [path]"), RunE: func(cmd *cobra.Command, args []string) error { if cmd.Flags().Changed("branch") && strings.TrimSpace(branchName) == "" { return errors.New("--branch requires a branch name") } if cmd.Flags().Changed("from") && strings.TrimSpace(branchName) == "" { return errors.New("--from requires --branch") } sourcePath := "" if len(args) == 1 { sourcePath = args[0] } spec, err := inspectVMRunRepo(cmd.Context(), sourcePath, branchName, fromRef) if err != nil { return err } layout, err := paths.Resolve() if err != nil { return err } cfg, err := config.Load(layout) if err != nil { return err } if err := validateVMRunPrereqs(cfg); err != nil { return err } params, err := vmCreateParamsFromFlags(cmd, name, imageName, vcpu, memory, systemOverlaySize, workDiskSize, natEnabled, false) if err != nil { return err } if err := system.EnsureSudo(cmd.Context()); err != nil { return err } layout, cfg, err = ensureDaemon(cmd.Context()) if err != nil { return err } return runVMRun(cmd.Context(), layout.SocketPath, cfg, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), params, spec) }, } cmd.Flags().StringVar(&name, "name", "", "vm name") cmd.Flags().StringVar(&imageName, "image", "", "image name or id") cmd.Flags().IntVar(&vcpu, "vcpu", model.DefaultVCPUCount, "vcpu count") cmd.Flags().IntVar(&memory, "memory", model.DefaultMemoryMiB, "memory in MiB") cmd.Flags().StringVar(&systemOverlaySize, "system-overlay-size", model.FormatSizeBytes(model.DefaultSystemOverlaySize), "system overlay size") cmd.Flags().StringVar(&workDiskSize, "disk-size", model.FormatSizeBytes(model.DefaultWorkDiskSize), "work disk size") cmd.Flags().BoolVar(&natEnabled, "nat", false, "enable NAT") cmd.Flags().StringVar(&branchName, "branch", "", "create and switch to a new guest branch") cmd.Flags().StringVar(&fromRef, "from", "HEAD", "base ref for --branch") return cmd } func newVMKillCommand() *cobra.Command { var signal string cmd := &cobra.Command{ Use: "kill ...", Short: "Send a signal to a VM process", Args: minArgsUsage(1, "usage: banger vm kill [--signal SIGTERM|SIGKILL|...] ..."), RunE: func(cmd *cobra.Command, args []string) error { if err := system.EnsureSudo(cmd.Context()); err != nil { return err } layout, _, err := ensureDaemon(cmd.Context()) if err != nil { return err } if len(args) > 1 { return runVMBatchAction(cmd, layout.SocketPath, args, func(ctx context.Context, id string) (model.VMRecord, error) { result, err := rpc.Call[api.VMShowResult]( ctx, layout.SocketPath, "vm.kill", api.VMKillParams{IDOrName: id, Signal: signal}, ) if err != nil { return model.VMRecord{}, err } return result.VM, nil }) } result, err := rpc.Call[api.VMShowResult]( cmd.Context(), layout.SocketPath, "vm.kill", api.VMKillParams{IDOrName: args[0], Signal: signal}, ) if err != nil { return err } return printVMSummary(cmd.OutOrStdout(), result.VM) }, } cmd.Flags().StringVar(&signal, "signal", "TERM", "signal name to send") return cmd } func newVMCreateCommand() *cobra.Command { var ( name string imageName string vcpu = model.DefaultVCPUCount memory = model.DefaultMemoryMiB systemOverlaySize = model.FormatSizeBytes(model.DefaultSystemOverlaySize) workDiskSize = model.FormatSizeBytes(model.DefaultWorkDiskSize) natEnabled bool noStart bool ) cmd := &cobra.Command{ Use: "create", Short: "Create a VM", Args: noArgsUsage("usage: banger vm create"), RunE: func(cmd *cobra.Command, args []string) error { params, err := vmCreateParamsFromFlags(cmd, name, imageName, vcpu, memory, systemOverlaySize, workDiskSize, natEnabled, noStart) if err != nil { return err } if err := system.EnsureSudo(cmd.Context()); err != nil { return err } layout, _, err := ensureDaemon(cmd.Context()) if err != nil { return err } vm, err := runVMCreate(cmd.Context(), layout.SocketPath, cmd.ErrOrStderr(), params) if err != nil { return err } return printVMSummary(cmd.OutOrStdout(), vm) }, } cmd.Flags().StringVar(&name, "name", "", "vm name") cmd.Flags().StringVar(&imageName, "image", "", "image name or id") cmd.Flags().IntVar(&vcpu, "vcpu", model.DefaultVCPUCount, "vcpu count") cmd.Flags().IntVar(&memory, "memory", model.DefaultMemoryMiB, "memory in MiB") cmd.Flags().StringVar(&systemOverlaySize, "system-overlay-size", model.FormatSizeBytes(model.DefaultSystemOverlaySize), "system overlay size") cmd.Flags().StringVar(&workDiskSize, "disk-size", model.FormatSizeBytes(model.DefaultWorkDiskSize), "work disk size") cmd.Flags().BoolVar(&natEnabled, "nat", false, "enable NAT") cmd.Flags().BoolVar(&noStart, "no-start", false, "create without starting") return cmd } func newVMListCommand() *cobra.Command { return &cobra.Command{ Use: "list", Short: "List VMs", Args: noArgsUsage("usage: banger vm list"), RunE: func(cmd *cobra.Command, args []string) error { layout, _, err := ensureDaemon(cmd.Context()) if err != nil { return err } result, err := rpc.Call[api.VMListResult](cmd.Context(), layout.SocketPath, "vm.list", api.Empty{}) if err != nil { return err } images, err := rpc.Call[api.ImageListResult](cmd.Context(), layout.SocketPath, "image.list", api.Empty{}) if err != nil { return err } return printVMListTable(cmd.OutOrStdout(), result.VMs, imageNameIndex(images.Images)) }, } } func newVMShowCommand() *cobra.Command { return &cobra.Command{ Use: "show ", Short: "Show VM details", Args: exactArgsUsage(1, "usage: banger vm show "), RunE: func(cmd *cobra.Command, args []string) error { layout, _, err := ensureDaemon(cmd.Context()) if err != nil { return err } result, err := rpc.Call[api.VMShowResult](cmd.Context(), layout.SocketPath, "vm.show", api.VMRefParams{IDOrName: args[0]}) if err != nil { return err } return printJSON(cmd.OutOrStdout(), result.VM) }, } } func newVMActionCommand(use, short, method string) *cobra.Command { return &cobra.Command{ Use: use + " ...", Short: short, Args: minArgsUsage(1, fmt.Sprintf("usage: banger vm %s ...", use)), RunE: func(cmd *cobra.Command, args []string) error { if err := system.EnsureSudo(cmd.Context()); err != nil { return err } layout, _, err := ensureDaemon(cmd.Context()) if err != nil { return err } if len(args) > 1 { return runVMBatchAction(cmd, layout.SocketPath, args, func(ctx context.Context, id string) (model.VMRecord, error) { result, err := rpc.Call[api.VMShowResult](ctx, layout.SocketPath, method, api.VMRefParams{IDOrName: id}) if err != nil { return model.VMRecord{}, err } return result.VM, nil }) } result, err := rpc.Call[api.VMShowResult](cmd.Context(), layout.SocketPath, method, api.VMRefParams{IDOrName: args[0]}) if err != nil { return err } return printVMSummary(cmd.OutOrStdout(), result.VM) }, } } func newVMSetCommand() *cobra.Command { var ( vcpu int memory int diskSize string nat bool noNat bool ) cmd := &cobra.Command{ Use: "set ...", Short: "Update stopped VM settings", Args: minArgsUsage(1, "usage: banger vm set [--vcpu N] [--memory MiB] [--disk-size SIZE] [--nat|--no-nat] ..."), RunE: func(cmd *cobra.Command, args []string) error { params, err := vmSetParamsFromFlags(args[0], vcpu, memory, diskSize, nat, noNat) if err != nil { return err } if err := system.EnsureSudo(cmd.Context()); err != nil { return err } layout, _, err := ensureDaemon(cmd.Context()) if err != nil { return err } if len(args) > 1 { return runVMBatchAction(cmd, layout.SocketPath, args, func(ctx context.Context, id string) (model.VMRecord, error) { batchParams := params batchParams.IDOrName = id result, err := rpc.Call[api.VMShowResult](ctx, layout.SocketPath, "vm.set", batchParams) if err != nil { return model.VMRecord{}, err } return result.VM, nil }) } result, err := rpc.Call[api.VMShowResult](cmd.Context(), layout.SocketPath, "vm.set", params) if err != nil { return err } return printVMSummary(cmd.OutOrStdout(), result.VM) }, } cmd.Flags().IntVar(&vcpu, "vcpu", -1, "vcpu count") cmd.Flags().IntVar(&memory, "memory", -1, "memory in MiB") cmd.Flags().StringVar(&diskSize, "disk-size", "", "new work disk size") cmd.Flags().BoolVar(&nat, "nat", false, "enable NAT") cmd.Flags().BoolVar(&noNat, "no-nat", false, "disable NAT") return cmd } func newVMSSHCommand() *cobra.Command { return &cobra.Command{ Use: "ssh [ssh args...]", Short: "SSH into a running VM", Args: minArgsUsage(1, "usage: banger vm ssh [ssh args...]"), RunE: func(cmd *cobra.Command, args []string) error { layout, cfg, err := ensureDaemon(cmd.Context()) if err != nil { return err } if err := validateSSHPrereqs(cfg); err != nil { return err } result, err := rpc.Call[api.VMSSHResult](cmd.Context(), layout.SocketPath, "vm.ssh", api.VMRefParams{IDOrName: args[0]}) if err != nil { return err } sshArgs, err := sshCommandArgs(cfg, result.GuestIP, args[1:]) if err != nil { return err } return runSSHSession(cmd.Context(), layout.SocketPath, result.Name, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), sshArgs) }, } } func newVMLogsCommand() *cobra.Command { var follow bool cmd := &cobra.Command{ Use: "logs ", Short: "Show VM logs", Args: exactArgsUsage(1, "usage: banger vm logs [-f] "), RunE: func(cmd *cobra.Command, args []string) error { layout, _, err := ensureDaemon(cmd.Context()) if err != nil { return err } result, err := rpc.Call[api.VMLogsResult](cmd.Context(), layout.SocketPath, "vm.logs", api.VMRefParams{IDOrName: args[0]}) if err != nil { return err } if result.LogPath == "" { return errors.New("vm has no log path") } return system.CopyStream(cmd.OutOrStdout(), system.TailCommand(result.LogPath, follow)) }, } cmd.Flags().BoolVarP(&follow, "follow", "f", false, "follow logs") return cmd } func newVMStatsCommand() *cobra.Command { return &cobra.Command{ Use: "stats ", Short: "Show VM stats", Args: exactArgsUsage(1, "usage: banger vm stats "), RunE: func(cmd *cobra.Command, args []string) error { layout, _, err := ensureDaemon(cmd.Context()) if err != nil { return err } result, err := rpc.Call[api.VMStatsResult](cmd.Context(), layout.SocketPath, "vm.stats", api.VMRefParams{IDOrName: args[0]}) if err != nil { return err } return printJSON(cmd.OutOrStdout(), result) }, } } func newVMPortsCommand() *cobra.Command { return &cobra.Command{ Use: "ports ", Short: "Show host-reachable listening guest ports", Args: exactArgsUsage(1, "usage: banger vm ports "), RunE: func(cmd *cobra.Command, args []string) error { layout, _, err := ensureDaemon(cmd.Context()) if err != nil { return err } result, err := vmPortsFunc(cmd.Context(), layout.SocketPath, args[0]) if err != nil { return err } return printVMPortsTable(cmd.OutOrStdout(), result) }, } } func newImageCommand() *cobra.Command { cmd := &cobra.Command{ Use: "image", Short: "Manage images", RunE: helpNoArgs, } cmd.AddCommand( newImageBuildCommand(), newImageRegisterCommand(), newImagePromoteCommand(), newImageListCommand(), newImageShowCommand(), newImageDeleteCommand(), ) return cmd } func newImageBuildCommand() *cobra.Command { var params api.ImageBuildParams cmd := &cobra.Command{ Use: "build", Short: "Build an image", Args: noArgsUsage("usage: banger image build"), RunE: func(cmd *cobra.Command, args []string) error { if err := absolutizeImageBuildPaths(¶ms); err != nil { return err } if err := system.EnsureSudo(cmd.Context()); err != nil { return err } layout, _, err := ensureDaemon(cmd.Context()) if err != nil { return err } result, err := rpc.Call[api.ImageShowResult](cmd.Context(), layout.SocketPath, "image.build", params) if err != nil { return err } return printImageSummary(cmd.OutOrStdout(), result.Image) }, } cmd.Flags().StringVar(¶ms.Name, "name", "", "image name") cmd.Flags().StringVar(¶ms.FromImage, "from-image", "", "registered base image id or name") cmd.Flags().StringVar(¶ms.Size, "size", "", "output image size") cmd.Flags().StringVar(¶ms.KernelPath, "kernel", "", "kernel path") cmd.Flags().StringVar(¶ms.InitrdPath, "initrd", "", "initrd path") cmd.Flags().StringVar(¶ms.ModulesDir, "modules", "", "modules dir") cmd.Flags().BoolVar(¶ms.Docker, "docker", false, "install docker") return cmd } func newImageRegisterCommand() *cobra.Command { var params api.ImageRegisterParams cmd := &cobra.Command{ Use: "register", Short: "Register or update an unmanaged image", Args: noArgsUsage("usage: banger image register --name --rootfs [--work-seed ] --kernel [--initrd ] [--modules ]"), RunE: func(cmd *cobra.Command, args []string) error { if err := absolutizeImageRegisterPaths(¶ms); err != nil { return err } if err := system.EnsureSudo(cmd.Context()); err != nil { return err } layout, _, err := ensureDaemon(cmd.Context()) if err != nil { return err } result, err := rpc.Call[api.ImageShowResult](cmd.Context(), layout.SocketPath, "image.register", params) if err != nil { return err } return printImageSummary(cmd.OutOrStdout(), result.Image) }, } cmd.Flags().StringVar(¶ms.Name, "name", "", "image name") cmd.Flags().StringVar(¶ms.RootfsPath, "rootfs", "", "rootfs path") cmd.Flags().StringVar(¶ms.WorkSeedPath, "work-seed", "", "work-seed path") cmd.Flags().StringVar(¶ms.KernelPath, "kernel", "", "kernel path") cmd.Flags().StringVar(¶ms.InitrdPath, "initrd", "", "initrd path") cmd.Flags().StringVar(¶ms.ModulesDir, "modules", "", "modules dir") cmd.Flags().BoolVar(¶ms.Docker, "docker", false, "mark image as docker-prepared") return cmd } func newImagePromoteCommand() *cobra.Command { return &cobra.Command{ Use: "promote ", Short: "Promote an unmanaged image to a managed artifact", Args: exactArgsUsage(1, "usage: banger image promote "), RunE: func(cmd *cobra.Command, args []string) error { if err := system.EnsureSudo(cmd.Context()); err != nil { return err } layout, _, err := ensureDaemon(cmd.Context()) if err != nil { return err } result, err := rpc.Call[api.ImageShowResult](cmd.Context(), layout.SocketPath, "image.promote", api.ImageRefParams{IDOrName: args[0]}) if err != nil { return err } return printImageSummary(cmd.OutOrStdout(), result.Image) }, } } func newImageListCommand() *cobra.Command { return &cobra.Command{ Use: "list", Short: "List images", Args: noArgsUsage("usage: banger image list"), RunE: func(cmd *cobra.Command, args []string) error { layout, _, err := ensureDaemon(cmd.Context()) if err != nil { return err } result, err := rpc.Call[api.ImageListResult](cmd.Context(), layout.SocketPath, "image.list", api.Empty{}) if err != nil { return err } return printImageListTable(cmd.OutOrStdout(), result.Images) }, } } func newImageShowCommand() *cobra.Command { return &cobra.Command{ Use: "show ", Short: "Show image details", Args: exactArgsUsage(1, "usage: banger image show "), RunE: func(cmd *cobra.Command, args []string) error { layout, _, err := ensureDaemon(cmd.Context()) if err != nil { return err } result, err := rpc.Call[api.ImageShowResult](cmd.Context(), layout.SocketPath, "image.show", api.ImageRefParams{IDOrName: args[0]}) if err != nil { return err } return printJSON(cmd.OutOrStdout(), result.Image) }, } } func newImageDeleteCommand() *cobra.Command { return &cobra.Command{ Use: "delete ", Short: "Delete an image", Args: exactArgsUsage(1, "usage: banger image delete "), RunE: func(cmd *cobra.Command, args []string) error { if err := system.EnsureSudo(cmd.Context()); err != nil { return err } layout, _, err := ensureDaemon(cmd.Context()) if err != nil { return err } result, err := rpc.Call[api.ImageShowResult](cmd.Context(), layout.SocketPath, "image.delete", api.ImageRefParams{IDOrName: args[0]}) if err != nil { return err } return printImageSummary(cmd.OutOrStdout(), result.Image) }, } } func helpNoArgs(cmd *cobra.Command, args []string) error { if len(args) != 0 { return fmt.Errorf("unknown arguments: %s", strings.Join(args, " ")) } return cmd.Help() } func noArgsUsage(usage string) cobra.PositionalArgs { return func(cmd *cobra.Command, args []string) error { if len(args) != 0 { return errors.New(usage) } return nil } } func exactArgsUsage(n int, usage string) cobra.PositionalArgs { return func(cmd *cobra.Command, args []string) error { if len(args) != n { return errors.New(usage) } return nil } } func minArgsUsage(n int, usage string) cobra.PositionalArgs { return func(cmd *cobra.Command, args []string) error { if len(args) < n { return errors.New(usage) } return nil } } func maxArgsUsage(n int, usage string) cobra.PositionalArgs { return func(cmd *cobra.Command, args []string) error { if len(args) > n { return errors.New(usage) } return nil } } type resolvedVMTarget struct { Index int Ref string VM model.VMRecord } type vmRefResolutionError struct { Index int Ref string Err error } type vmBatchActionResult struct { Target resolvedVMTarget VM model.VMRecord Err error } func runVMBatchAction(cmd *cobra.Command, socketPath string, refs []string, action func(context.Context, string) (model.VMRecord, error)) error { listResult, err := rpc.Call[api.VMListResult](cmd.Context(), socketPath, "vm.list", api.Empty{}) if err != nil { return err } targets, resolutionErrs := resolveVMTargets(listResult.VMs, refs) results := executeVMActionBatch(cmd.Context(), targets, action) failed := false for _, resolutionErr := range resolutionErrs { if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "%s: %v\n", resolutionErr.Ref, resolutionErr.Err); err != nil { return err } failed = true } for _, result := range results { if result.Err != nil { if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "%s: %v\n", result.Target.Ref, result.Err); err != nil { return err } failed = true continue } if err := printVMSummary(cmd.OutOrStdout(), result.VM); err != nil { return err } } if failed { return errors.New("one or more VM operations failed") } return nil } func resolveVMTargets(vms []model.VMRecord, refs []string) ([]resolvedVMTarget, []vmRefResolutionError) { targets := make([]resolvedVMTarget, 0, len(refs)) resolutionErrs := make([]vmRefResolutionError, 0) seen := make(map[string]struct{}, len(refs)) for index, ref := range refs { vm, err := resolveVMRef(vms, ref) if err != nil { resolutionErrs = append(resolutionErrs, vmRefResolutionError{Index: index, Ref: ref, Err: err}) continue } if _, ok := seen[vm.ID]; ok { continue } seen[vm.ID] = struct{}{} targets = append(targets, resolvedVMTarget{Index: index, Ref: ref, VM: vm}) } return targets, resolutionErrs } func resolveVMRef(vms []model.VMRecord, ref string) (model.VMRecord, error) { ref = strings.TrimSpace(ref) if ref == "" { return model.VMRecord{}, errors.New("vm id or name is required") } exactMatches := make([]model.VMRecord, 0, 1) for _, vm := range vms { if vm.ID == ref || vm.Name == ref { exactMatches = append(exactMatches, vm) } } switch len(exactMatches) { case 1: return exactMatches[0], nil case 0: default: return model.VMRecord{}, fmt.Errorf("multiple VMs match %q", ref) } prefixMatches := make([]model.VMRecord, 0, 1) for _, vm := range vms { if strings.HasPrefix(vm.ID, ref) || strings.HasPrefix(vm.Name, ref) { prefixMatches = append(prefixMatches, vm) } } switch len(prefixMatches) { case 1: return prefixMatches[0], nil case 0: return model.VMRecord{}, fmt.Errorf("vm %q not found", ref) default: return model.VMRecord{}, fmt.Errorf("multiple VMs match %q", ref) } } func executeVMActionBatch(ctx context.Context, targets []resolvedVMTarget, action func(context.Context, string) (model.VMRecord, error)) []vmBatchActionResult { results := make([]vmBatchActionResult, len(targets)) var wg sync.WaitGroup wg.Add(len(targets)) for index, target := range targets { index := index target := target go func() { defer wg.Done() vm, err := action(ctx, target.VM.ID) results[index] = vmBatchActionResult{ Target: target, VM: vm, Err: err, } }() } wg.Wait() return results } func ensureDaemon(ctx context.Context) (paths.Layout, model.DaemonConfig, error) { layout, err := paths.Resolve() if err != nil { return paths.Layout{}, model.DaemonConfig{}, err } cfg, err := config.Load(layout) if err != nil { return paths.Layout{}, model.DaemonConfig{}, err } if ping, err := daemonPingFunc(ctx, layout.SocketPath); err == nil { if daemonOutdated(ping.PID) { if err := restartDaemon(ctx, layout, ping.PID); err != nil { return paths.Layout{}, model.DaemonConfig{}, err } return layout, cfg, nil } return layout, cfg, nil } if err := startDaemon(ctx, layout); err != nil { return paths.Layout{}, model.DaemonConfig{}, err } return layout, cfg, nil } func daemonOutdated(pid int) bool { if pid <= 0 { return false } daemonBin, err := bangerdPathFunc() if err != nil { return false } currentInfo, err := os.Stat(daemonBin) if err != nil { return false } runningInfo, err := os.Stat(daemonExePath(pid)) if err != nil { return false } return !os.SameFile(currentInfo, runningInfo) } func restartDaemon(ctx context.Context, layout paths.Layout, pid int) error { stopCtx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() _, _ = rpc.Call[api.ShutdownResult](stopCtx, layout.SocketPath, "shutdown", api.Empty{}) if waitForPIDExit(pid, 2*time.Second) { return startDaemon(ctx, layout) } if proc, err := os.FindProcess(pid); err == nil { _ = proc.Signal(syscall.SIGTERM) } if !waitForPIDExit(pid, 2*time.Second) { return fmt.Errorf("timed out restarting stale daemon pid %d", pid) } return startDaemon(ctx, layout) } func waitForPIDExit(pid int, timeout time.Duration) bool { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { if !pidRunning(pid) { return true } time.Sleep(50 * time.Millisecond) } return !pidRunning(pid) } func pidRunning(pid int) bool { if pid <= 0 { return false } proc, err := os.FindProcess(pid) if err != nil { return false } return proc.Signal(syscall.Signal(0)) == nil } func startDaemon(ctx context.Context, layout paths.Layout) error { if err := paths.Ensure(layout); err != nil { return err } logFile, err := os.OpenFile(layout.DaemonLog, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { return err } defer logFile.Close() daemonBin, err := paths.BangerdPath() if err != nil { return err } cmd := buildDaemonCommand(daemonBin) cmd.Stdout = logFile cmd.Stderr = logFile cmd.Stdin = nil cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} if err := cmd.Start(); err != nil { return err } if err := rpc.WaitForSocket(layout.SocketPath, 5*time.Second); err != nil { return fmt.Errorf("daemon failed to start; inspect %s: %w", layout.DaemonLog, err) } return nil } func buildDaemonCommand(daemonBin string) *exec.Cmd { return exec.Command(daemonBin) } func vmSetParamsFromFlags(idOrName string, vcpu, memory int, diskSize string, nat, noNat bool) (api.VMSetParams, error) { if nat && noNat { return api.VMSetParams{}, errors.New("use only one of --nat or --no-nat") } params := api.VMSetParams{IDOrName: idOrName, WorkDiskSize: diskSize} if vcpu >= 0 { if err := validatePositiveSetting("vcpu", vcpu); err != nil { return api.VMSetParams{}, err } params.VCPUCount = &vcpu } if memory >= 0 { if err := validatePositiveSetting("memory", memory); err != nil { return api.VMSetParams{}, err } params.MemoryMiB = &memory } if nat || noNat { value := nat && !noNat params.NATEnabled = &value } if params.VCPUCount == nil && params.MemoryMiB == nil && params.WorkDiskSize == "" && params.NATEnabled == nil { return api.VMSetParams{}, errors.New("no VM settings changed") } return params, nil } func vmCreateParamsFromFlags(cmd *cobra.Command, name, imageName string, vcpu, memory int, systemOverlaySize, workDiskSize string, natEnabled, noStart bool) (api.VMCreateParams, error) { params := api.VMCreateParams{ Name: name, ImageName: imageName, NATEnabled: natEnabled, NoStart: noStart, } if cmd.Flags().Changed("vcpu") { if err := validatePositiveSetting("vcpu", vcpu); err != nil { return api.VMCreateParams{}, err } params.VCPUCount = &vcpu } if cmd.Flags().Changed("memory") { if err := validatePositiveSetting("memory", memory); err != nil { return api.VMCreateParams{}, err } params.MemoryMiB = &memory } if cmd.Flags().Changed("system-overlay-size") { params.SystemOverlaySize = systemOverlaySize } if cmd.Flags().Changed("disk-size") { params.WorkDiskSize = workDiskSize } return params, nil } func validatePositiveSetting(label string, value int) error { if value <= 0 { return fmt.Errorf("%s must be a positive integer", label) } return nil } func runSSHSession(ctx context.Context, socketPath, vmRef string, stdin io.Reader, stdout, stderr io.Writer, sshArgs []string) error { sshErr := sshExecFunc(ctx, stdin, stdout, stderr, sshArgs) if !shouldCheckSSHReminder(sshErr) || ctx.Err() != nil { return sshErr } pingCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() health, err := vmHealthFunc(pingCtx, socketPath, vmRef) if err != nil { _, _ = fmt.Fprintln(stderr, vsockagent.WarningMessage(vmRef, err)) return sshErr } if health.Healthy { name := health.Name if strings.TrimSpace(name) == "" { name = vmRef } _, _ = fmt.Fprintln(stderr, vsockagent.ReminderMessage(name)) } return sshErr } func shouldCheckSSHReminder(err error) bool { if err == nil { return true } var exitErr *exec.ExitError if !errors.As(err, &exitErr) { return false } return exitErr.ExitCode() != 255 } func sshCommandArgs(cfg model.DaemonConfig, guestIP string, extra []string) ([]string, error) { if guestIP == "" { return nil, errors.New("vm has no guest IP") } args := []string{} args = append(args, "-F", "/dev/null") if cfg.SSHKeyPath != "" { args = append(args, "-i", cfg.SSHKeyPath) } args = append( args, "-o", "IdentitiesOnly=yes", "-o", "BatchMode=yes", "-o", "PreferredAuthentications=publickey", "-o", "PasswordAuthentication=no", "-o", "KbdInteractiveAuthentication=no", "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "root@"+guestIP, ) args = append(args, extra...) return args, nil } func validateSSHPrereqs(cfg model.DaemonConfig) error { checks := system.NewPreflight() checks.RequireCommand("ssh", "install openssh-client") if strings.TrimSpace(cfg.SSHKeyPath) != "" { checks.RequireFile(cfg.SSHKeyPath, "ssh private key", `set "ssh_key_path" or let banger create its default key`) } return checks.Err("ssh preflight failed") } func validateVMRunPrereqs(cfg model.DaemonConfig) error { checks := system.NewPreflight() checks.RequireCommand("git", "install git") checks.RequireCommand("opencode", "install opencode") if strings.TrimSpace(cfg.SSHKeyPath) != "" { checks.RequireFile(cfg.SSHKeyPath, "ssh private key", `set "ssh_key_path" or let banger create its default key`) } return checks.Err("vm run preflight failed") } func inspectVMRunRepo(ctx context.Context, rawPath, branchName, fromRef string) (vmRunRepoSpec, error) { sourcePath, err := resolveVMRunSourcePath(rawPath) if err != nil { return vmRunRepoSpec{}, err } repoRoot, err := gitTrimmedOutput(ctx, sourcePath, "rev-parse", "--show-toplevel") if err != nil { return vmRunRepoSpec{}, fmt.Errorf("%s is not inside a git repository", sourcePath) } isBare, err := gitTrimmedOutput(ctx, repoRoot, "rev-parse", "--is-bare-repository") if err != nil { return vmRunRepoSpec{}, fmt.Errorf("inspect git repository %s: %w", repoRoot, err) } if isBare == "true" { return vmRunRepoSpec{}, fmt.Errorf("vm run requires a non-bare git repository: %s", repoRoot) } if err := ensureVMRunRepoHasNoSubmodules(ctx, repoRoot); err != nil { return vmRunRepoSpec{}, err } headCommit, err := gitTrimmedOutput(ctx, repoRoot, "rev-parse", "HEAD^{commit}") if err != nil { return vmRunRepoSpec{}, fmt.Errorf("git repository %s must have at least one commit", repoRoot) } currentBranch, err := gitTrimmedOutput(ctx, repoRoot, "branch", "--show-current") if err != nil { return vmRunRepoSpec{}, fmt.Errorf("resolve current branch for %s: %w", repoRoot, err) } baseCommit := headCommit branchName = strings.TrimSpace(branchName) if branchName != "" { fromRef = strings.TrimSpace(fromRef) if fromRef == "" { return vmRunRepoSpec{}, errors.New("--from cannot be empty") } baseCommit, err = gitTrimmedOutput(ctx, repoRoot, "rev-parse", fromRef+"^{commit}") if err != nil { return vmRunRepoSpec{}, fmt.Errorf("resolve --from %q: %w", fromRef, err) } } gitUserName, err := gitResolvedConfigValue(ctx, repoRoot, "user.name") if err != nil { return vmRunRepoSpec{}, fmt.Errorf("resolve git user.name for %s: %w", repoRoot, err) } gitUserEmail, err := gitResolvedConfigValue(ctx, repoRoot, "user.email") if err != nil { return vmRunRepoSpec{}, fmt.Errorf("resolve git user.email for %s: %w", repoRoot, err) } originURL, err := gitResolvedConfigValue(ctx, repoRoot, "remote.origin.url") if err != nil { return vmRunRepoSpec{}, fmt.Errorf("resolve origin url for %s: %w", repoRoot, err) } overlayPaths, err := listVMRunOverlayPaths(ctx, repoRoot) if err != nil { return vmRunRepoSpec{}, err } return vmRunRepoSpec{ SourcePath: sourcePath, RepoRoot: repoRoot, RepoName: filepath.Base(repoRoot), HeadCommit: headCommit, CurrentBranch: currentBranch, BranchName: branchName, BaseCommit: baseCommit, OriginURL: originURL, GitUserName: gitUserName, GitUserEmail: gitUserEmail, OverlayPaths: overlayPaths, }, nil } func resolveVMRunSourcePath(rawPath string) (string, error) { if strings.TrimSpace(rawPath) == "" { wd, err := cwdFunc() if err != nil { return "", err } rawPath = wd } absPath, err := filepath.Abs(rawPath) if err != nil { return "", err } info, err := os.Stat(absPath) if err != nil { return "", err } if !info.IsDir() { return "", fmt.Errorf("%s is not a directory", absPath) } return absPath, nil } func ensureVMRunRepoHasNoSubmodules(ctx context.Context, repoRoot string) error { output, err := gitOutput(ctx, repoRoot, "ls-files", "--stage", "-z") if err != nil { return fmt.Errorf("inspect git index for %s: %w", repoRoot, err) } for _, record := range parseNullSeparatedOutput(output) { if strings.HasPrefix(record, "160000 ") { return fmt.Errorf("vm run does not yet support git submodules: %s", repoRoot) } } return nil } func listVMRunOverlayPaths(ctx context.Context, repoRoot string) ([]string, error) { trackedOutput, err := gitOutput(ctx, repoRoot, "ls-files", "-z") if err != nil { return nil, fmt.Errorf("list tracked files for %s: %w", repoRoot, err) } untrackedOutput, err := gitOutput(ctx, repoRoot, "ls-files", "--others", "--exclude-standard", "-z") if err != nil { return nil, fmt.Errorf("list untracked files for %s: %w", repoRoot, err) } paths := make([]string, 0) seen := make(map[string]struct{}) for _, relPath := range parseNullSeparatedOutput(trackedOutput) { if relPath == "" { continue } if _, err := os.Lstat(filepath.Join(repoRoot, relPath)); err != nil { if os.IsNotExist(err) { continue } return nil, err } seen[relPath] = struct{}{} paths = append(paths, relPath) } for _, relPath := range parseNullSeparatedOutput(untrackedOutput) { if relPath == "" { continue } if _, ok := seen[relPath]; ok { continue } seen[relPath] = struct{}{} paths = append(paths, relPath) } sort.Strings(paths) return paths, nil } func gitOutput(ctx context.Context, dir string, args ...string) ([]byte, error) { fullArgs := make([]string, 0, len(args)+2) if strings.TrimSpace(dir) != "" { fullArgs = append(fullArgs, "-C", dir) } fullArgs = append(fullArgs, args...) return hostCommandOutputFunc(ctx, "git", fullArgs...) } func gitTrimmedOutput(ctx context.Context, dir string, args ...string) (string, error) { output, err := gitOutput(ctx, dir, args...) if err != nil { return "", err } return strings.TrimSpace(string(output)), nil } func gitResolvedConfigValue(ctx context.Context, dir, key string) (string, error) { return gitTrimmedOutput(ctx, dir, "config", "--default", "", "--get", key) } func parseNullSeparatedOutput(output []byte) []string { chunks := bytes.Split(output, []byte{0}) values := make([]string, 0, len(chunks)) for _, chunk := range chunks { value := strings.TrimSpace(string(chunk)) if value == "" { continue } values = append(values, value) } 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 { progress := newVMRunProgressRenderer(stderr) vm, err := runVMCreate(ctx, socketPath, stderr, params) if err != nil { return err } vmRef := strings.TrimSpace(vm.Name) if vmRef == "" { vmRef = shortID(vm.ID) } 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 err != nil { return fmt.Errorf("vm %q is running but guest ssh is unavailable: %w", vmRef, err) } defer client.Close() if err := importVMRunRepoToGuest(ctx, client, spec, progress); err != nil { return fmt.Errorf("vm %q is running but repo import failed: %w", vmRef, err) } if err := startVMRunToolingHarness(ctx, client, spec, progress); err != nil { printVMRunWarning(stderr, fmt.Sprintf("tooling harness start failed: %v", err)) } if err := runVMRunAttach(ctx, socketPath, vmRef, cfg, stdin, stdout, stderr, vm.Runtime.GuestIP, vmRunGuestDir(spec.RepoName), progress); err != nil { return fmt.Errorf("vm %q is running but opencode attach failed: %w", vmRef, err) } return nil } func importVMRunRepoToGuest(ctx context.Context, client vmRunGuestClient, spec vmRunRepoSpec, progress *vmRunProgressRenderer) error { if progress != nil { progress.render("preparing shallow repo") } repoCopyDir, cleanup, err := prepareVMRunRepoCopyFunc(ctx, spec) if err != nil { return err } defer cleanup() if progress != nil { progress.render("copying repo metadata to guest") } var copyLog bytes.Buffer remoteCommand := fmt.Sprintf("rm -rf %s && mkdir -p %s && tar -o -C %s --strip-components=1 -xf -", shellQuote(vmRunGuestDir(spec.RepoName)), shellQuote(vmRunGuestDir(spec.RepoName)), shellQuote(vmRunGuestDir(spec.RepoName))) if err := client.StreamTar(ctx, repoCopyDir, remoteCommand, ©Log); err != nil { return formatVMRunStepError("copy guest git metadata", err, copyLog.String()) } if progress != nil { progress.render("preparing guest checkout") } var scriptLog bytes.Buffer if err := client.RunScript(ctx, vmRunCheckoutScript(spec), &scriptLog); err != nil { return formatVMRunStepError("prepare guest checkout", err, scriptLog.String()) } if progress != nil { progress.render("overlaying host working tree") } var overlayLog bytes.Buffer remoteCommand = fmt.Sprintf("tar -o -C %s --strip-components=1 -xf -", shellQuote(vmRunGuestDir(spec.RepoName))) if err := client.StreamTarEntries(ctx, spec.RepoRoot, spec.OverlayPaths, remoteCommand, &overlayLog); err != nil { return formatVMRunStepError("overlay host working tree", err, overlayLog.String()) } return nil } func prepareVMRunRepoCopy(ctx context.Context, spec vmRunRepoSpec) (string, func(), error) { tempRoot, err := os.MkdirTemp("", "banger-vm-run-*") if err != nil { return "", nil, err } cleanup := func() { _ = os.RemoveAll(tempRoot) } repoCopyDir := filepath.Join(tempRoot, spec.RepoName) cloneArgs := []string{"clone", "--no-checkout", "--depth", fmt.Sprintf("%d", vmRunShallowFetchDepth)} if strings.TrimSpace(spec.CurrentBranch) != "" { cloneArgs = append(cloneArgs, "--single-branch", "--branch", spec.CurrentBranch) } cloneArgs = append(cloneArgs, gitFileURL(spec.RepoRoot), repoCopyDir) if err := runHostCommand(ctx, "git", cloneArgs...); err != nil { cleanup() return "", nil, fmt.Errorf("clone shallow repo copy: %w", err) } checkoutCommit := vmRunCheckoutCommit(spec) if err := runHostCommand(ctx, "git", "-C", repoCopyDir, "cat-file", "-e", checkoutCommit+"^{commit}"); err != nil { if err := runHostCommand(ctx, "git", "-C", repoCopyDir, "fetch", "--depth", fmt.Sprintf("%d", vmRunShallowFetchDepth), gitFileURL(spec.RepoRoot), checkoutCommit); err != nil { cleanup() return "", nil, fmt.Errorf("fetch shallow repo commit %s: %w", checkoutCommit, err) } } if strings.TrimSpace(spec.OriginURL) != "" { if err := runHostCommand(ctx, "git", "-C", repoCopyDir, "remote", "set-url", "origin", spec.OriginURL); err != nil { cleanup() return "", nil, fmt.Errorf("set origin remote: %w", err) } } else { if err := runHostCommand(ctx, "git", "-C", repoCopyDir, "remote", "remove", "origin"); err != nil { cleanup() return "", nil, fmt.Errorf("remove placeholder origin remote: %w", err) } } return repoCopyDir, cleanup, nil } func vmRunCheckoutCommit(spec vmRunRepoSpec) string { if strings.TrimSpace(spec.BranchName) != "" { return spec.BaseCommit } return spec.HeadCommit } func gitFileURL(path string) string { return (&url.URL{Scheme: "file", Path: filepath.ToSlash(path)}).String() } func runHostCommand(ctx context.Context, name string, args ...string) error { _, err := hostCommandOutputFunc(ctx, name, args...) return err } func vmRunCheckoutScript(spec vmRunRepoSpec) string { guestDir := vmRunGuestDir(spec.RepoName) var script strings.Builder script.WriteString("set -euo pipefail\n") fmt.Fprintf(&script, "DIR=%s\n", shellQuote(guestDir)) script.WriteString("git config --global --add safe.directory \"$DIR\"\n") switch { case strings.TrimSpace(spec.BranchName) != "": fmt.Fprintf(&script, "git -C \"$DIR\" checkout -B %s %s\n", shellQuote(spec.BranchName), shellQuote(spec.BaseCommit)) case strings.TrimSpace(spec.CurrentBranch) != "": fmt.Fprintf(&script, "git -C \"$DIR\" checkout -B %s %s\n", shellQuote(spec.CurrentBranch), shellQuote(spec.HeadCommit)) default: fmt.Fprintf(&script, "git -C \"$DIR\" checkout --detach %s\n", shellQuote(spec.HeadCommit)) } script.WriteString("find \"$DIR\" -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} +\n") if strings.TrimSpace(spec.GitUserName) != "" && strings.TrimSpace(spec.GitUserEmail) != "" { fmt.Fprintf(&script, "git -C \"$DIR\" config user.name %s\n", shellQuote(spec.GitUserName)) fmt.Fprintf(&script, "git -C \"$DIR\" config user.email %s\n", shellQuote(spec.GitUserEmail)) } return script.String() } func vmRunGuestDir(repoName string) string { return filepath.ToSlash(filepath.Join("/root", repoName)) } func vmRunToolingHarnessPath(repoName string) string { return filepath.ToSlash(filepath.Join("/tmp", "banger-vm-run-tooling-"+repoName+".sh")) } func vmRunToolingHarnessPromptPath(repoName string) string { return filepath.ToSlash(filepath.Join("/tmp", "banger-vm-run-tooling-"+repoName+".prompt.txt")) } func vmRunToolingHarnessLogPath(repoName string) string { return filepath.ToSlash(filepath.Join("/root/.cache/banger", "vm-run-tooling-"+repoName+".log")) } func startVMRunToolingHarness(ctx context.Context, client vmRunGuestClient, spec vmRunRepoSpec, progress *vmRunProgressRenderer) error { if progress != nil { progress.render("starting tooling harness") } plan := buildVMRunToolingPlanFunc(ctx, spec.RepoRoot) var uploadLog bytes.Buffer if err := client.UploadFile(ctx, vmRunToolingHarnessPromptPath(spec.RepoName), 0o644, []byte(vmRunToolingHarnessPromptData(plan)), &uploadLog); err != nil { return formatVMRunStepError("upload tooling harness prompt", err, uploadLog.String()) } uploadLog.Reset() if err := client.UploadFile(ctx, vmRunToolingHarnessPath(spec.RepoName), 0o755, []byte(vmRunToolingHarnessScript(spec, plan)), &uploadLog); err != nil { return formatVMRunStepError("upload tooling harness", err, uploadLog.String()) } var launchLog bytes.Buffer if err := client.RunScript(ctx, vmRunToolingHarnessLaunchScript(spec), &launchLog); err != nil { return formatVMRunStepError("launch tooling harness", err, launchLog.String()) } if progress != nil { progress.render("tooling harness log: " + vmRunToolingHarnessLogPath(spec.RepoName)) } return nil } func vmRunToolingHarnessPromptData(plan toolingplan.Plan) string { var prompt strings.Builder prompt.WriteString(vmRunToolingHarnessPrompt) lines := make([]string, 0, len(plan.RepoManagedTools)+len(plan.Steps)+len(plan.Skips)) for _, tool := range plan.RepoManagedTools { lines = append(lines, fmt.Sprintf("- Repo already declares %s through mise", tool)) } for _, step := range plan.Steps { lines = append(lines, fmt.Sprintf("- Planned deterministic install: %s@%s from %s", step.Tool, step.Version, step.Source)) } for _, skip := range plan.Skips { lines = append(lines, fmt.Sprintf("- Deterministic skip: %s (%s)", skip.Target, skip.Reason)) } if len(lines) == 0 { lines = append(lines, "- No deterministic prepass actions were planned") } prompt.WriteString("\n\nDeterministic prepass summary:\n") prompt.WriteString(strings.Join(lines, "\n")) prompt.WriteString("\n\nDo not repeat the deterministic prepass work unless it clearly failed. Focus on the remaining gaps.\n") return prompt.String() } func vmRunToolingHarnessScript(spec vmRunRepoSpec, plan toolingplan.Plan) string { var script strings.Builder script.WriteString("set -uo pipefail\n") fmt.Fprintf(&script, "DIR=%s\n", shellQuote(vmRunGuestDir(spec.RepoName))) script.WriteString("export PATH=/usr/local/bin:/root/.local/share/mise/shims:$PATH\n") script.WriteString("if [ -f /etc/profile.d/mise.sh ]; then . /etc/profile.d/mise.sh || true; fi\n") script.WriteString("log() { printf '%s\\n' \"$*\"; }\n") script.WriteString("run_best_effort() {\n") script.WriteString(" \"$@\"\n") script.WriteString(" rc=$?\n") script.WriteString(" if [ \"$rc\" -ne 0 ]; then\n") script.WriteString(" log \"command failed ($rc): $*\"\n") script.WriteString(" fi\n") script.WriteString(" return 0\n") script.WriteString("}\n") script.WriteString("run_bounded_best_effort() {\n") script.WriteString(" timeout_secs=\"$1\"\n") script.WriteString(" shift\n") script.WriteString(" timeout_marker=\"$(mktemp)\"\n") script.WriteString(" rm -f \"$timeout_marker\"\n") script.WriteString(" \"$@\" &\n") script.WriteString(" cmd_pid=$!\n") script.WriteString(" (\n") script.WriteString(" sleep \"$timeout_secs\"\n") script.WriteString(" if kill -0 \"$cmd_pid\" 2>/dev/null; then\n") script.WriteString(" : >\"$timeout_marker\"\n") script.WriteString(" log \"command timed out after ${timeout_secs}s: $*\"\n") script.WriteString(" kill -TERM \"$cmd_pid\" 2>/dev/null || true\n") script.WriteString(" if command -v pkill >/dev/null 2>&1; then pkill -TERM -P \"$cmd_pid\" 2>/dev/null || true; fi\n") script.WriteString(" sleep 2\n") script.WriteString(" kill -KILL \"$cmd_pid\" 2>/dev/null || true\n") script.WriteString(" if command -v pkill >/dev/null 2>&1; then pkill -KILL -P \"$cmd_pid\" 2>/dev/null || true; fi\n") script.WriteString(" fi\n") script.WriteString(" ) &\n") script.WriteString(" watchdog_pid=$!\n") script.WriteString(" wait \"$cmd_pid\"\n") script.WriteString(" rc=$?\n") script.WriteString(" kill \"$watchdog_pid\" 2>/dev/null || true\n") script.WriteString(" wait \"$watchdog_pid\" 2>/dev/null || true\n") script.WriteString(" if [ -f \"$timeout_marker\" ]; then\n") script.WriteString(" rm -f \"$timeout_marker\"\n") script.WriteString(" return 0\n") script.WriteString(" fi\n") script.WriteString(" rm -f \"$timeout_marker\"\n") script.WriteString(" if [ \"$rc\" -ne 0 ]; then\n") script.WriteString(" log \"command failed ($rc): $*\"\n") script.WriteString(" fi\n") script.WriteString(" return 0\n") script.WriteString("}\n") script.WriteString("cd \"$DIR\" || { log \"missing repo directory: $DIR\"; exit 0; }\n") script.WriteString("MISE_BIN=\"$(command -v mise || true)\"\n") script.WriteString("OPENCODE_BIN=\"$(command -v opencode || true)\"\n") script.WriteString("if [ -z \"$MISE_BIN\" ]; then log \"mise not found; skipping tooling harness\"; exit 0; fi\n") script.WriteString("if [ -z \"$OPENCODE_BIN\" ]; then log \"opencode not found; skipping tooling harness\"; exit 0; fi\n") fmt.Fprintf(&script, "PROMPT_FILE=%s\n", shellQuote(vmRunToolingHarnessPromptPath(spec.RepoName))) script.WriteString("if [ ! -f \"$PROMPT_FILE\" ]; then log \"tooling prompt file missing: $PROMPT_FILE\"; exit 0; fi\n") script.WriteString("log \"starting tooling harness in $DIR\"\n") script.WriteString("if [ -f .mise.toml ] || [ -f .tool-versions ]; then\n") script.WriteString(" log \"running mise install from repo declarations\"\n") script.WriteString(" run_best_effort \"$MISE_BIN\" install\n") script.WriteString("fi\n") fmt.Fprintf(&script, "INSTALL_TIMEOUT_SECS=%d\n", vmRunToolingInstallTimeoutSeconds) for _, step := range plan.Steps { stepLabel := fmt.Sprintf("deterministic install: %s@%s (%s)", step.Tool, step.Version, step.Source) fmt.Fprintf(&script, "log %s\n", shellQuote(stepLabel)) fmt.Fprintf(&script, "run_bounded_best_effort \"$INSTALL_TIMEOUT_SECS\" \"$MISE_BIN\" use -g --pin %s\n", shellQuote(step.Tool+"@"+step.Version)) } for _, skip := range plan.Skips { skipLabel := fmt.Sprintf("deterministic skip: %s (%s)", skip.Target, skip.Reason) fmt.Fprintf(&script, "log %s\n", shellQuote(skipLabel)) } if len(plan.Steps) > 0 { script.WriteString("run_best_effort \"$MISE_BIN\" reshim\n") } fmt.Fprintf(&script, "MODEL=%s\n", shellQuote(vmRunToolingHarnessModel)) fmt.Fprintf(&script, "TIMEOUT_SECS=%d\n", vmRunToolingHarnessTimeoutSeconds) script.WriteString("log \"running bounded opencode repo tooling inspection with $MODEL for up to ${TIMEOUT_SECS}s\"\n") script.WriteString("run_bounded_best_effort \"$TIMEOUT_SECS\" bash -lc 'exec \"$1\" run --format json -m \"$2\" \"$(cat \"$3\")\"' _ \"$OPENCODE_BIN\" \"$MODEL\" \"$PROMPT_FILE\"\n") script.WriteString("log \"tooling harness finished\"\n") return script.String() } func vmRunToolingHarnessLaunchScript(spec vmRunRepoSpec) string { var script strings.Builder script.WriteString("set -euo pipefail\n") fmt.Fprintf(&script, "HELPER=%s\n", shellQuote(vmRunToolingHarnessPath(spec.RepoName))) fmt.Fprintf(&script, "LOG=%s\n", shellQuote(vmRunToolingHarnessLogPath(spec.RepoName))) script.WriteString("mkdir -p \"$(dirname \"$LOG\")\"\n") script.WriteString("nohup bash \"$HELPER\" >\"$LOG\" 2>&1