Add guest sessions and agent VM defaults
Add daemon-backed workspace and guest-session primitives so host orchestrators can prepare /root/repo, launch long-lived guest commands, and attach to pipe-mode sessions over the local stdio mux bridge. Persist richer session metadata and launch diagnostics, preflight guest cwd/command requirements, make pipe-mode attach rehydratable from guest state after daemon restart, and allow submodules when workspace prepare runs in full_copy mode. At the same time, stop vm run from auto-attaching opencode, make it print next-step commands instead, and make glibc guest images more agent-ready by installing node, opencode, claude, and pi while syncing opencode/claude/pi auth files into work disks on VM start. Validation: - GOCACHE=/tmp/banger-gocache go test ./... - make build - banger vm workspace prepare --help - banger vm session --help - banger vm session start --help - banger vm session attach --help
This commit is contained in:
parent
497e6dca3d
commit
37c4c091ec
18 changed files with 3212 additions and 405 deletions
|
|
@ -29,6 +29,7 @@ import (
|
|||
"banger/internal/model"
|
||||
"banger/internal/paths"
|
||||
"banger/internal/rpc"
|
||||
"banger/internal/sessionstream"
|
||||
"banger/internal/system"
|
||||
"banger/internal/toolingplan"
|
||||
"banger/internal/vmdns"
|
||||
|
|
@ -50,15 +51,7 @@ var (
|
|||
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) {
|
||||
hostCommandOutputFunc = func(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err == nil {
|
||||
|
|
@ -93,6 +86,30 @@ var (
|
|||
vmPortsFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMPortsResult, error) {
|
||||
return rpc.Call[api.VMPortsResult](ctx, socketPath, "vm.ports", api.VMRefParams{IDOrName: idOrName})
|
||||
}
|
||||
vmWorkspacePrepareFunc = func(ctx context.Context, socketPath string, params api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error) {
|
||||
return rpc.Call[api.VMWorkspacePrepareResult](ctx, socketPath, "vm.workspace.prepare", params)
|
||||
}
|
||||
guestSessionStartFunc = func(ctx context.Context, socketPath string, params api.GuestSessionStartParams) (api.GuestSessionShowResult, error) {
|
||||
return rpc.Call[api.GuestSessionShowResult](ctx, socketPath, "guest.session.start", params)
|
||||
}
|
||||
guestSessionGetFunc = func(ctx context.Context, socketPath string, params api.GuestSessionRefParams) (api.GuestSessionShowResult, error) {
|
||||
return rpc.Call[api.GuestSessionShowResult](ctx, socketPath, "guest.session.get", params)
|
||||
}
|
||||
guestSessionListFunc = func(ctx context.Context, socketPath, idOrName string) (api.GuestSessionListResult, error) {
|
||||
return rpc.Call[api.GuestSessionListResult](ctx, socketPath, "guest.session.list", api.VMRefParams{IDOrName: idOrName})
|
||||
}
|
||||
guestSessionStopFunc = func(ctx context.Context, socketPath string, params api.GuestSessionRefParams) (api.GuestSessionShowResult, error) {
|
||||
return rpc.Call[api.GuestSessionShowResult](ctx, socketPath, "guest.session.stop", params)
|
||||
}
|
||||
guestSessionKillFunc = func(ctx context.Context, socketPath string, params api.GuestSessionRefParams) (api.GuestSessionShowResult, error) {
|
||||
return rpc.Call[api.GuestSessionShowResult](ctx, socketPath, "guest.session.kill", params)
|
||||
}
|
||||
guestSessionLogsFunc = func(ctx context.Context, socketPath string, params api.GuestSessionLogsParams) (api.GuestSessionLogsResult, error) {
|
||||
return rpc.Call[api.GuestSessionLogsResult](ctx, socketPath, "guest.session.logs", params)
|
||||
}
|
||||
guestSessionAttachBeginFunc = func(ctx context.Context, socketPath string, params api.GuestSessionAttachBeginParams) (api.GuestSessionAttachBeginResult, error) {
|
||||
return rpc.Call[api.GuestSessionAttachBeginResult](ctx, socketPath, "guest.session.attach.begin", params)
|
||||
}
|
||||
guestWaitForSSHFunc = func(ctx context.Context, address, privateKeyPath string, interval time.Duration) error {
|
||||
return guest.WaitForSSH(ctx, address, privateKeyPath, interval)
|
||||
}
|
||||
|
|
@ -119,6 +136,7 @@ type vmRunRepoSpec struct {
|
|||
HeadCommit string
|
||||
CurrentBranch string
|
||||
BranchName string
|
||||
FromRef string
|
||||
BaseCommit string
|
||||
OriginURL string
|
||||
GitUserName string
|
||||
|
|
@ -128,22 +146,8 @@ type vmRunRepoSpec struct {
|
|||
|
||||
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",
|
||||
|
|
@ -464,6 +468,8 @@ func newVMCommand() *cobra.Command {
|
|||
newVMSetCommand(),
|
||||
newVMSSHCommand(),
|
||||
newVMACPCommand(),
|
||||
newVMWorkspaceCommand(),
|
||||
newVMSessionCommand(),
|
||||
newVMLogsCommand(),
|
||||
newVMStatsCommand(),
|
||||
newVMPortsCommand(),
|
||||
|
|
@ -485,8 +491,13 @@ func newVMRunCommand() *cobra.Command {
|
|||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "run [path]",
|
||||
Short: "Create a repo-backed VM session and attach opencode",
|
||||
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]"),
|
||||
Example: strings.TrimSpace(`
|
||||
banger vm run
|
||||
banger vm run ../repo --name agent-box --branch feature/demo
|
||||
`),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if cmd.Flags().Changed("branch") && strings.TrimSpace(branchName) == "" {
|
||||
return errors.New("--branch requires a branch name")
|
||||
|
|
@ -835,7 +846,7 @@ func newVMACPCommand() *cobra.Command {
|
|||
var cwd string
|
||||
cmd := &cobra.Command{
|
||||
Use: "acp <id-or-name>",
|
||||
Short: "Bridge ACP to a running VM over SSH",
|
||||
Short: "Bridge local stdio to guest opencode acp over SSH",
|
||||
Args: exactArgsUsage(1, "usage: banger vm acp [--cwd PATH] <id-or-name>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, cfg, err := ensureDaemon(cmd.Context())
|
||||
|
|
@ -852,6 +863,393 @@ func newVMACPCommand() *cobra.Command {
|
|||
return cmd
|
||||
}
|
||||
|
||||
func newVMWorkspaceCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "workspace",
|
||||
Short: "Manage repository workspaces inside a running VM",
|
||||
RunE: helpNoArgs,
|
||||
}
|
||||
cmd.AddCommand(newVMWorkspacePrepareCommand())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newVMWorkspacePrepareCommand() *cobra.Command {
|
||||
var guestPath string
|
||||
var branchName string
|
||||
var fromRef string
|
||||
var mode string
|
||||
var readOnly bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "prepare <id-or-name> [path]",
|
||||
Short: "Copy a local repo into a running VM",
|
||||
Long: "Prepare a repository workspace from a local git checkout into a running VM. The default guest path is /root/repo and the default mode is shallow_overlay. Repositories with git submodules must use --mode full_copy.",
|
||||
Args: minArgsUsage(1, "usage: banger vm workspace prepare <id-or-name> [path]"),
|
||||
Example: strings.TrimSpace(`
|
||||
banger vm workspace prepare devbox
|
||||
banger vm workspace prepare devbox ../repo --guest-path /root/repo --readonly
|
||||
banger vm workspace prepare devbox ../repo --mode full_copy
|
||||
`),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, _, err := ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sourcePath := ""
|
||||
if len(args) > 1 {
|
||||
sourcePath = args[1]
|
||||
}
|
||||
resolvedPath, err := resolveVMRunSourcePath(sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
prepareFrom := ""
|
||||
if strings.TrimSpace(branchName) != "" {
|
||||
prepareFrom = fromRef
|
||||
}
|
||||
result, err := vmWorkspacePrepareFunc(cmd.Context(), layout.SocketPath, api.VMWorkspacePrepareParams{
|
||||
IDOrName: args[0],
|
||||
SourcePath: resolvedPath,
|
||||
GuestPath: guestPath,
|
||||
Branch: branchName,
|
||||
From: prepareFrom,
|
||||
Mode: mode,
|
||||
ReadOnly: readOnly,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printJSON(cmd.OutOrStdout(), result.Workspace)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&guestPath, "guest-path", "/root/repo", "guest workspace path")
|
||||
cmd.Flags().StringVar(&branchName, "branch", "", "create and switch to a new guest branch")
|
||||
cmd.Flags().StringVar(&fromRef, "from", "HEAD", "base ref for --branch")
|
||||
cmd.Flags().StringVar(&mode, "mode", string(model.WorkspacePrepareModeShallowOverlay), "workspace mode: shallow_overlay, full_copy, metadata_only")
|
||||
cmd.Flags().BoolVar(&readOnly, "readonly", false, "make the prepared workspace read-only")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newVMSessionCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "session",
|
||||
Short: "Manage long-lived guest commands inside a VM",
|
||||
Long: "Start, inspect, stop, and attach to daemon-managed guest commands. Pipe-mode sessions expose live stdio for interactive protocols. Attach is exclusive and currently uses a same-host local bridge.",
|
||||
RunE: helpNoArgs,
|
||||
}
|
||||
cmd.AddCommand(
|
||||
newVMSessionStartCommand(),
|
||||
newVMSessionListCommand(),
|
||||
newVMSessionShowCommand(),
|
||||
newVMSessionLogsCommand(),
|
||||
newVMSessionStopCommand(),
|
||||
newVMSessionKillCommand(),
|
||||
newVMSessionAttachCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newVMSessionStartCommand() *cobra.Command {
|
||||
var name string
|
||||
var cwd string
|
||||
var stdinMode string
|
||||
var envPairs []string
|
||||
var tagPairs []string
|
||||
var requiredCommands []string
|
||||
cmd := &cobra.Command{
|
||||
Use: "start <id-or-name> <command> [args...]",
|
||||
Short: "Start a managed guest command",
|
||||
Long: "Start a daemon-managed guest command. The daemon verifies that the guest working directory exists and that the requested command is present in guest PATH before launch. Use --stdin-mode pipe when you need live attach.",
|
||||
Args: minArgsUsage(2, "usage: banger vm session start <id-or-name> [flags] -- <command> [args...]"),
|
||||
Example: strings.TrimSpace(`
|
||||
banger vm session start devbox --name planner --cwd /root/repo --stdin-mode pipe --require-command git -- pi --mode rpc --no-session
|
||||
banger vm session start devbox --name shell --stdin-mode pipe -- bash -lc 'exec bash'
|
||||
`),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, _, err := ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := parseKeyValuePairs(envPairs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tags, err := parseKeyValuePairs(tagPairs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := guestSessionStartFunc(cmd.Context(), layout.SocketPath, api.GuestSessionStartParams{
|
||||
VMIDOrName: args[0],
|
||||
Name: name,
|
||||
Command: args[1],
|
||||
Args: append([]string(nil), args[2:]...),
|
||||
CWD: cwd,
|
||||
Env: env,
|
||||
StdinMode: stdinMode,
|
||||
Tags: tags,
|
||||
RequiredCommands: append([]string(nil), requiredCommands...),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := printGuestSessionSummary(cmd.OutOrStdout(), result.Session); err != nil {
|
||||
return err
|
||||
}
|
||||
if result.Session.Status == model.GuestSessionStatusFailed && strings.TrimSpace(result.Session.LaunchMessage) != "" {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "warning: session failed at %s: %s\n", result.Session.LaunchStage, result.Session.LaunchMessage)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&name, "name", "", "session name")
|
||||
cmd.Flags().StringVar(&cwd, "cwd", "", "guest working directory; must already exist")
|
||||
cmd.Flags().StringVar(&stdinMode, "stdin-mode", string(model.GuestSessionStdinClosed), "stdin mode: closed or pipe (pipe enables attach)")
|
||||
cmd.Flags().StringArrayVar(&envPairs, "env", nil, "environment entry in KEY=VALUE form")
|
||||
cmd.Flags().StringArrayVar(&tagPairs, "tag", nil, "session tag in KEY=VALUE form")
|
||||
cmd.Flags().StringArrayVar(&requiredCommands, "require-command", nil, "extra guest command that must exist in PATH before launch; repeatable")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newVMSessionListCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list <id-or-name>",
|
||||
Short: "List managed guest commands for a VM",
|
||||
Args: exactArgsUsage(1, "usage: banger vm session list <id-or-name>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, _, err := ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := guestSessionListFunc(cmd.Context(), layout.SocketPath, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printGuestSessionTable(cmd.OutOrStdout(), result.Sessions)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newVMSessionShowCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "show <id-or-name> <session>",
|
||||
Short: "Show managed guest command details",
|
||||
Args: exactArgsUsage(2, "usage: banger vm session show <id-or-name> <session>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, _, err := ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := guestSessionGetFunc(cmd.Context(), layout.SocketPath, api.GuestSessionRefParams{VMIDOrName: args[0], SessionIDOrName: args[1]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printJSON(cmd.OutOrStdout(), result.Session)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newVMSessionLogsCommand() *cobra.Command {
|
||||
var stream string
|
||||
var tailLines int
|
||||
cmd := &cobra.Command{
|
||||
Use: "logs <id-or-name> <session>",
|
||||
Short: "Show stdout or stderr for a guest session",
|
||||
Args: exactArgsUsage(2, "usage: banger vm session logs [--stream stdout|stderr] [-n LINES] <id-or-name> <session>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, _, err := ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := guestSessionLogsFunc(cmd.Context(), layout.SocketPath, api.GuestSessionLogsParams{VMIDOrName: args[0], SessionIDOrName: args[1], Stream: stream, TailLines: tailLines})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprint(cmd.OutOrStdout(), result.Content)
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&stream, "stream", "stdout", "log stream to read")
|
||||
cmd.Flags().IntVarP(&tailLines, "lines", "n", 200, "number of lines to tail")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newVMSessionStopCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "stop <id-or-name> <session>",
|
||||
Short: "Send SIGTERM to a guest session",
|
||||
Args: exactArgsUsage(2, "usage: banger vm session stop <id-or-name> <session>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, _, err := ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := guestSessionStopFunc(cmd.Context(), layout.SocketPath, api.GuestSessionRefParams{VMIDOrName: args[0], SessionIDOrName: args[1]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printGuestSessionSummary(cmd.OutOrStdout(), result.Session)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newVMSessionKillCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "kill <id-or-name> <session>",
|
||||
Short: "Send SIGKILL to a guest session",
|
||||
Args: exactArgsUsage(2, "usage: banger vm session kill <id-or-name> <session>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, _, err := ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := guestSessionKillFunc(cmd.Context(), layout.SocketPath, api.GuestSessionRefParams{VMIDOrName: args[0], SessionIDOrName: args[1]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printGuestSessionSummary(cmd.OutOrStdout(), result.Session)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newVMSessionAttachCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "attach <id-or-name> <session>",
|
||||
Short: "Attach local stdio to an attachable guest session",
|
||||
Long: "Attach local stdio to a pipe-mode session through a daemon-created local Unix socket bridge. Only one active attach is allowed at a time, and the client must run on the same host as the daemon.",
|
||||
Args: exactArgsUsage(2, "usage: banger vm session attach <id-or-name> <session>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, _, err := ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := guestSessionAttachBeginFunc(cmd.Context(), layout.SocketPath, api.GuestSessionAttachBeginParams{VMIDOrName: args[0], SessionIDOrName: args[1]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
socketPath := strings.TrimSpace(result.SocketPath)
|
||||
if socketPath == "" && result.TransportKind == "unix_socket" {
|
||||
socketPath = strings.TrimSpace(result.TransportTarget)
|
||||
}
|
||||
return runGuestSessionAttach(cmd.Context(), cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), socketPath)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func parseKeyValuePairs(values []string) (map[string]string, error) {
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
result := make(map[string]string, len(values))
|
||||
for _, value := range values {
|
||||
key, raw, ok := strings.Cut(value, "=")
|
||||
if !ok || strings.TrimSpace(key) == "" {
|
||||
return nil, fmt.Errorf("invalid key=value entry %q", value)
|
||||
}
|
||||
result[strings.TrimSpace(key)] = raw
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func printGuestSessionSummary(out anyWriter, session model.GuestSession) error {
|
||||
_, err := fmt.Fprintf(out, "%s\t%s\t%s\t%s\t%s\n", session.ID, session.Name, session.Status, session.Command, session.CWD)
|
||||
return err
|
||||
}
|
||||
|
||||
func printGuestSessionTable(out io.Writer, sessions []model.GuestSession) error {
|
||||
tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
|
||||
if _, err := fmt.Fprintln(tw, "ID\tNAME\tSTATUS\tATTACH\tCOMMAND\tCWD"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, session := range sessions {
|
||||
attach := "no"
|
||||
if session.Attachable {
|
||||
attach = "yes"
|
||||
}
|
||||
if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", shortID(session.ID), session.Name, session.Status, attach, session.Command, session.CWD); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
func runGuestSessionAttach(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, socketPath string) error {
|
||||
conn, err := (&net.Dialer{}).DialContext(ctx, "unix", socketPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
writeErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
writeErrCh <- streamGuestSessionAttachInput(conn, stdin)
|
||||
}()
|
||||
for {
|
||||
channel, payload, err := sessionstream.ReadFrame(conn)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
switch channel {
|
||||
case sessionstream.ChannelStdout:
|
||||
if _, err := stdout.Write(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
case sessionstream.ChannelStderr:
|
||||
if _, err := stderr.Write(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
case sessionstream.ChannelControl:
|
||||
message, err := sessionstream.ReadControl(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch message.Type {
|
||||
case "exit":
|
||||
if message.ExitCode != nil && *message.ExitCode != 0 {
|
||||
return fmt.Errorf("guest session exited with code %d", *message.ExitCode)
|
||||
}
|
||||
return nil
|
||||
case "error":
|
||||
if strings.TrimSpace(message.Error) == "" {
|
||||
return errors.New("guest session attach failed")
|
||||
}
|
||||
return errors.New(message.Error)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case err := <-writeErrCh:
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func streamGuestSessionAttachInput(conn net.Conn, stdin io.Reader) error {
|
||||
if stdin == nil {
|
||||
return sessionstream.WriteControl(conn, sessionstream.ControlMessage{Type: "eof"})
|
||||
}
|
||||
buffer := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := stdin.Read(buffer)
|
||||
if n > 0 {
|
||||
if writeErr := sessionstream.WriteFrame(conn, sessionstream.ChannelStdin, buffer[:n]); writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return sessionstream.WriteControl(conn, sessionstream.ControlMessage{Type: "eof"})
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newVMLogsCommand() *cobra.Command {
|
||||
var follow bool
|
||||
cmd := &cobra.Command{
|
||||
|
|
@ -1532,7 +1930,6 @@ func validateSSHPrereqs(cfg model.DaemonConfig) error {
|
|||
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`)
|
||||
}
|
||||
|
|
@ -1570,12 +1967,14 @@ func inspectVMRunRepo(ctx context.Context, rawPath, branchName, fromRef string)
|
|||
}
|
||||
|
||||
baseCommit := headCommit
|
||||
resolvedFromRef := ""
|
||||
branchName = strings.TrimSpace(branchName)
|
||||
if branchName != "" {
|
||||
fromRef = strings.TrimSpace(fromRef)
|
||||
if fromRef == "" {
|
||||
return vmRunRepoSpec{}, errors.New("--from cannot be empty")
|
||||
}
|
||||
resolvedFromRef = fromRef
|
||||
baseCommit, err = gitTrimmedOutput(ctx, repoRoot, "rev-parse", fromRef+"^{commit}")
|
||||
if err != nil {
|
||||
return vmRunRepoSpec{}, fmt.Errorf("resolve --from %q: %w", fromRef, err)
|
||||
|
|
@ -1607,6 +2006,7 @@ func inspectVMRunRepo(ctx context.Context, rawPath, branchName, fromRef string)
|
|||
HeadCommit: headCommit,
|
||||
CurrentBranch: currentBranch,
|
||||
BranchName: branchName,
|
||||
FromRef: resolvedFromRef,
|
||||
BaseCommit: baseCommit,
|
||||
OriginURL: originURL,
|
||||
GitUserName: gitUserName,
|
||||
|
|
@ -1733,6 +2133,17 @@ 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 {
|
||||
|
|
@ -1743,16 +2154,13 @@ func runVMRun(ctx context.Context, socketPath string, cfg model.DaemonConfig, st
|
|||
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))
|
||||
printVMRunWarning(stderr, fmt.Sprintf("guest tooling bootstrap start failed: %v", err))
|
||||
}
|
||||
if err := runVMRunAttach(ctx, socketPath, vmRef, cfg, stdin, stdout, stderr, vm.Runtime.GuestIP, vmRunGuestDir(), progress); err != nil {
|
||||
return fmt.Errorf("vm %q is running but opencode attach failed: %w", vmRef, err)
|
||||
if progress != nil {
|
||||
progress.render("printing next steps")
|
||||
}
|
||||
return nil
|
||||
return printVMRunNextSteps(stdout, vm)
|
||||
}
|
||||
|
||||
func importVMRunRepoToGuest(ctx context.Context, client vmRunGuestClient, spec vmRunRepoSpec, progress *vmRunProgressRenderer) error {
|
||||
|
|
@ -1876,59 +2284,29 @@ 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")
|
||||
progress.render("starting guest tooling bootstrap")
|
||||
}
|
||||
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())
|
||||
return formatVMRunStepError("upload guest tooling bootstrap", 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())
|
||||
return formatVMRunStepError("launch guest tooling bootstrap", err, launchLog.String())
|
||||
}
|
||||
if progress != nil {
|
||||
progress.render("tooling harness log: " + vmRunToolingHarnessLogPath(spec.RepoName))
|
||||
progress.render("guest tooling 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")
|
||||
|
|
@ -1980,12 +2358,11 @@ func vmRunToolingHarnessScript(spec vmRunRepoSpec, plan toolingplan.Plan) string
|
|||
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 [ -z \"$MISE_BIN\" ]; then log \"mise not found; skipping guest tooling bootstrap\"; exit 0; fi\n")
|
||||
script.WriteString("log \"starting guest tooling bootstrap in $DIR\"\n")
|
||||
if len(plan.RepoManagedTools) > 0 {
|
||||
fmt.Fprintf(&script, "log %s\n", shellQuote("repo-managed mise tools: "+strings.Join(plan.RepoManagedTools, ", ")))
|
||||
}
|
||||
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")
|
||||
|
|
@ -2003,11 +2380,7 @@ func vmRunToolingHarnessScript(spec vmRunRepoSpec, plan toolingplan.Plan) string
|
|||
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")
|
||||
script.WriteString("log \"guest tooling bootstrap finished\"\n")
|
||||
return script.String()
|
||||
}
|
||||
|
||||
|
|
@ -2022,46 +2395,31 @@ func vmRunToolingHarnessLaunchScript(spec vmRunRepoSpec) string {
|
|||
return script.String()
|
||||
}
|
||||
|
||||
func runVMRunAttach(ctx context.Context, socketPath, vmRef string, cfg model.DaemonConfig, stdin io.Reader, stdout, stderr io.Writer, guestIP, guestDir string, progress *vmRunProgressRenderer) error {
|
||||
guestIP = strings.TrimSpace(guestIP)
|
||||
if guestIP == "" {
|
||||
return errors.New("vm has no guest IP")
|
||||
func printVMRunNextSteps(out io.Writer, vm model.VMRecord) error {
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
supportsAttach, err := hostOpencodeAttachSupportedFunc(ctx)
|
||||
if err != nil {
|
||||
printVMRunWarning(stderr, fmt.Sprintf("could not detect host opencode attach support: %v", err))
|
||||
vmRef := strings.TrimSpace(vm.Name)
|
||||
if vmRef == "" {
|
||||
vmRef = shortID(vm.ID)
|
||||
}
|
||||
if supportsAttach {
|
||||
if progress != nil {
|
||||
progress.render("attaching opencode")
|
||||
}
|
||||
return opencodeExecFunc(ctx, stdin, stdout, stderr, []string{
|
||||
"attach",
|
||||
"--dir", guestDir,
|
||||
"http://" + net.JoinHostPort(guestIP, "4096"),
|
||||
})
|
||||
hostRef := strings.TrimSpace(vm.Runtime.DNSName)
|
||||
if hostRef == "" {
|
||||
hostRef = strings.TrimSpace(vm.Runtime.GuestIP)
|
||||
}
|
||||
if progress != nil {
|
||||
progress.render("host opencode has no attach support; starting guest opencode over ssh")
|
||||
}
|
||||
sshArgs, err := sshCommandArgs(cfg, guestIP, []string{"bash", "-lc", fmt.Sprintf("cd %s && exec opencode .", shellQuote(guestDir))})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runSSHSession(ctx, socketPath, vmRef, stdin, stdout, stderr, sshArgs)
|
||||
}
|
||||
|
||||
func hostOpencodeAttachSupported(ctx context.Context) (bool, error) {
|
||||
output, err := hostCommandOutputFunc(ctx, "opencode", "attach", "--help")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return opencodeAttachHelpOutputSupported(output), nil
|
||||
}
|
||||
|
||||
func opencodeAttachHelpOutputSupported(output []byte) bool {
|
||||
text := strings.ToLower(string(output))
|
||||
return strings.Contains(text, "opencode attach")
|
||||
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 {
|
||||
|
|
@ -2098,6 +2456,7 @@ func (r *vmRunProgressRenderer) render(detail string) {
|
|||
}
|
||||
|
||||
func formatVMRunProgress(detail string) string {
|
||||
|
||||
detail = strings.TrimSpace(detail)
|
||||
if detail == "" {
|
||||
return ""
|
||||
|
|
|
|||
|
|
@ -1232,7 +1232,7 @@ func TestInspectVMRunRepoRejectsSubmodules(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
|
||||
func TestRunVMRunCreatesImportsAndPrintsNextSteps(t *testing.T) {
|
||||
repoRoot := t.TempDir()
|
||||
repoCopyDir := filepath.Join(t.TempDir(), "repo-copy")
|
||||
|
||||
|
|
@ -1243,8 +1243,7 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
|
|||
origGuestDial := guestDialFunc
|
||||
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
|
||||
origBuildVMRunToolingPlan := buildVMRunToolingPlanFunc
|
||||
origOpencodeExec := opencodeExecFunc
|
||||
origHostOpencodeAttachSupported := hostOpencodeAttachSupportedFunc
|
||||
origVMWorkspacePrepare := vmWorkspacePrepareFunc
|
||||
t.Cleanup(func() {
|
||||
vmCreateBeginFunc = origBegin
|
||||
vmCreateStatusFunc = origStatus
|
||||
|
|
@ -1253,8 +1252,7 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
|
|||
guestDialFunc = origGuestDial
|
||||
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
|
||||
buildVMRunToolingPlanFunc = origBuildVMRunToolingPlan
|
||||
opencodeExecFunc = origOpencodeExec
|
||||
hostOpencodeAttachSupportedFunc = origHostOpencodeAttachSupported
|
||||
vmWorkspacePrepareFunc = origVMWorkspacePrepare
|
||||
})
|
||||
|
||||
vm := model.VMRecord{
|
||||
|
|
@ -1310,22 +1308,21 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
|
|||
}
|
||||
return repoCopyDir, func() {}, nil
|
||||
}
|
||||
hostOpencodeAttachSupportedFunc = func(context.Context) (bool, error) {
|
||||
return true, nil
|
||||
var workspaceParams api.VMWorkspacePrepareParams
|
||||
vmWorkspacePrepareFunc = func(ctx context.Context, socketPath string, params api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error) {
|
||||
workspaceParams = params
|
||||
return api.VMWorkspacePrepareResult{Workspace: model.WorkspacePrepareResult{VMID: vm.ID, GuestPath: "/root/repo"}}, nil
|
||||
}
|
||||
buildVMRunToolingPlanFunc = func(context.Context, string) toolingplan.Plan {
|
||||
return toolingplan.Plan{
|
||||
Steps: []toolingplan.InstallStep{{Tool: "go", Version: "1.25.0", Source: "go.mod"}},
|
||||
Skips: []toolingplan.SkipNote{{Target: "python", Reason: "no .python-version"}},
|
||||
RepoManagedTools: []string{"go"},
|
||||
Steps: []toolingplan.InstallStep{{Tool: "go", Version: "1.25.0", Source: "go.mod"}},
|
||||
Skips: []toolingplan.SkipNote{{Target: "python", Reason: "no .python-version"}},
|
||||
}
|
||||
}
|
||||
var attachArgs []string
|
||||
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
|
||||
attachArgs = append([]string(nil), args...)
|
||||
return nil
|
||||
}
|
||||
|
||||
spec := vmRunRepoSpec{
|
||||
SourcePath: repoRoot,
|
||||
RepoRoot: repoRoot,
|
||||
RepoName: "repo",
|
||||
HeadCommit: "deadbeef",
|
||||
|
|
@ -1336,13 +1333,15 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
|
|||
GitUserEmail: "repo@example.com",
|
||||
OverlayPaths: []string{"tracked.txt", "nested/keep.txt"},
|
||||
}
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
err := runVMRun(
|
||||
context.Background(),
|
||||
"/tmp/bangerd.sock",
|
||||
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
|
||||
strings.NewReader(""),
|
||||
&bytes.Buffer{},
|
||||
&bytes.Buffer{},
|
||||
&stdout,
|
||||
&stderr,
|
||||
api.VMCreateParams{Name: "devbox"},
|
||||
spec,
|
||||
)
|
||||
|
|
@ -1365,29 +1364,20 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
|
|||
if dialKeyPath != waitKeyPath {
|
||||
t.Fatalf("dialKeyPath = %q, want %q", dialKeyPath, waitKeyPath)
|
||||
}
|
||||
if fakeClient.tarSourceDir != repoCopyDir {
|
||||
t.Fatalf("tarSourceDir = %q, want %q", fakeClient.tarSourceDir, repoCopyDir)
|
||||
if workspaceParams.IDOrName != "devbox" {
|
||||
t.Fatalf("workspaceParams.IDOrName = %q, want devbox", workspaceParams.IDOrName)
|
||||
}
|
||||
if fakeClient.tarCommand != "rm -rf '/root/repo' && mkdir -p '/root/repo' && tar -o -C '/root/repo' --strip-components=1 -xf -" {
|
||||
t.Fatalf("tarCommand = %q", fakeClient.tarCommand)
|
||||
if workspaceParams.SourcePath != repoRoot {
|
||||
t.Fatalf("workspaceParams.SourcePath = %q, want %q", workspaceParams.SourcePath, repoRoot)
|
||||
}
|
||||
if len(fakeClient.uploads) != 2 {
|
||||
t.Fatalf("uploads = %d, want 2", len(fakeClient.uploads))
|
||||
if workspaceParams.GuestPath != "/root/repo" {
|
||||
t.Fatalf("workspaceParams.GuestPath = %q, want /root/repo", workspaceParams.GuestPath)
|
||||
}
|
||||
if fakeClient.uploads[0].path != vmRunToolingHarnessPromptPath("repo") {
|
||||
t.Fatalf("prompt upload path = %q, want %q", fakeClient.uploads[0].path, vmRunToolingHarnessPromptPath("repo"))
|
||||
if workspaceParams.Mode != string(model.WorkspacePrepareModeShallowOverlay) {
|
||||
t.Fatalf("workspaceParams.Mode = %q", workspaceParams.Mode)
|
||||
}
|
||||
if fakeClient.uploads[0].mode != 0o644 {
|
||||
t.Fatalf("prompt upload mode = %v, want 0644", fakeClient.uploads[0].mode)
|
||||
}
|
||||
if !strings.Contains(string(fakeClient.uploads[0].data), `Do not edit repository files.`) {
|
||||
t.Fatalf("prompt upload data = %q, want prompt body", string(fakeClient.uploads[0].data))
|
||||
}
|
||||
if !strings.Contains(string(fakeClient.uploads[0].data), `Planned deterministic install: go@1.25.0 from go.mod`) {
|
||||
t.Fatalf("prompt upload data = %q, want deterministic install summary", string(fakeClient.uploads[0].data))
|
||||
}
|
||||
if !strings.Contains(string(fakeClient.uploads[0].data), `Deterministic skip: python (no .python-version)`) {
|
||||
t.Fatalf("prompt upload data = %q, want deterministic skip summary", string(fakeClient.uploads[0].data))
|
||||
if len(fakeClient.uploads) != 1 {
|
||||
t.Fatalf("uploads = %d, want 1", len(fakeClient.uploads))
|
||||
}
|
||||
if fakeClient.uploadPath != vmRunToolingHarnessPath("repo") {
|
||||
t.Fatalf("uploadPath = %q, want %q", fakeClient.uploadPath, vmRunToolingHarnessPath("repo"))
|
||||
|
|
@ -1395,23 +1385,17 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
|
|||
if fakeClient.uploadMode != 0o755 {
|
||||
t.Fatalf("uploadMode = %v, want 0755", fakeClient.uploadMode)
|
||||
}
|
||||
if !strings.Contains(string(fakeClient.uploadData), `repo-managed mise tools: go`) {
|
||||
t.Fatalf("uploadData = %q, want repo-managed tool log", string(fakeClient.uploadData))
|
||||
}
|
||||
if !strings.Contains(string(fakeClient.uploadData), `run_best_effort "$MISE_BIN" install`) {
|
||||
t.Fatalf("uploadData = %q, want mise install best-effort step", string(fakeClient.uploadData))
|
||||
}
|
||||
if !strings.Contains(string(fakeClient.uploadData), fmt.Sprintf(`INSTALL_TIMEOUT_SECS=%d`, vmRunToolingInstallTimeoutSeconds)) {
|
||||
t.Fatalf("uploadData = %q, want deterministic install timeout", string(fakeClient.uploadData))
|
||||
}
|
||||
if !strings.Contains(string(fakeClient.uploadData), `deterministic install: go@1.25.0 (go.mod)`) {
|
||||
t.Fatalf("uploadData = %q, want deterministic install log", string(fakeClient.uploadData))
|
||||
t.Fatalf("uploadData = %q, want repo mise install step", string(fakeClient.uploadData))
|
||||
}
|
||||
if !strings.Contains(string(fakeClient.uploadData), `run_bounded_best_effort "$INSTALL_TIMEOUT_SECS" "$MISE_BIN" use -g --pin 'go@1.25.0'`) {
|
||||
t.Fatalf("uploadData = %q, want deterministic go install step", string(fakeClient.uploadData))
|
||||
}
|
||||
if !strings.Contains(string(fakeClient.uploadData), `deterministic skip: python (no .python-version)`) {
|
||||
t.Fatalf("uploadData = %q, want deterministic skip log", string(fakeClient.uploadData))
|
||||
}
|
||||
if !strings.Contains(string(fakeClient.uploadData), `run_best_effort "$MISE_BIN" reshim`) {
|
||||
t.Fatalf("uploadData = %q, want deterministic reshim step", string(fakeClient.uploadData))
|
||||
if strings.Contains(string(fakeClient.uploadData), `opencode run`) {
|
||||
t.Fatalf("uploadData = %q, want no opencode harness run", string(fakeClient.uploadData))
|
||||
}
|
||||
if !strings.Contains(fakeClient.launchScript, `nohup bash "$HELPER" >"$LOG" 2>&1 </dev/null &`) {
|
||||
t.Fatalf("launchScript = %q, want nohup launcher", fakeClient.launchScript)
|
||||
|
|
@ -1419,33 +1403,21 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
|
|||
if !strings.Contains(fakeClient.launchScript, vmRunToolingHarnessLogPath("repo")) {
|
||||
t.Fatalf("launchScript = %q, want tooling harness log path", fakeClient.launchScript)
|
||||
}
|
||||
if !strings.Contains(fakeClient.script, `git -C "$DIR" checkout -B 'feature' 'cafebabe'`) {
|
||||
t.Fatalf("script = %q, want guest branch checkout", fakeClient.script)
|
||||
}
|
||||
if !strings.Contains(fakeClient.script, `find "$DIR" -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} +`) {
|
||||
t.Fatalf("script = %q, want guest worktree reset", fakeClient.script)
|
||||
}
|
||||
if !strings.Contains(fakeClient.script, `git config --global --add safe.directory "$DIR"`) {
|
||||
t.Fatalf("script = %q, want guest safe.directory config", fakeClient.script)
|
||||
}
|
||||
if !strings.Contains(fakeClient.script, `git -C "$DIR" config user.name 'Repo User'`) {
|
||||
t.Fatalf("script = %q, want guest repo user.name config", fakeClient.script)
|
||||
}
|
||||
if !strings.Contains(fakeClient.script, `git -C "$DIR" config user.email 'repo@example.com'`) {
|
||||
t.Fatalf("script = %q, want guest repo user.email config", fakeClient.script)
|
||||
}
|
||||
if fakeClient.streamSourceDir != repoRoot {
|
||||
t.Fatalf("streamSourceDir = %q, want %q", fakeClient.streamSourceDir, repoRoot)
|
||||
}
|
||||
if !reflect.DeepEqual(fakeClient.streamEntries, spec.OverlayPaths) {
|
||||
t.Fatalf("streamEntries = %v, want %v", fakeClient.streamEntries, spec.OverlayPaths)
|
||||
}
|
||||
if fakeClient.streamCommand != "tar -o -C '/root/repo' --strip-components=1 -xf -" {
|
||||
t.Fatalf("streamCommand = %q", fakeClient.streamCommand)
|
||||
}
|
||||
wantAttach := []string{"attach", "--dir", "/root/repo", "http://172.16.0.2:4096"}
|
||||
if !reflect.DeepEqual(attachArgs, wantAttach) {
|
||||
t.Fatalf("attachArgs = %v, want %v", attachArgs, wantAttach)
|
||||
output := stdout.String()
|
||||
for _, want := range []string{
|
||||
"VM ready.",
|
||||
"Name: devbox",
|
||||
"Host: devbox.vm",
|
||||
"Repo: /root/repo",
|
||||
"banger vm ssh devbox",
|
||||
"opencode attach http://devbox.vm:4096 --dir /root/repo",
|
||||
"banger vm acp devbox",
|
||||
`banger vm ssh devbox -- "cd /root/repo && claude"`,
|
||||
`banger vm ssh devbox -- "cd /root/repo && pi"`,
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("stdout = %q, want %q", output, want)
|
||||
}
|
||||
}
|
||||
if !fakeClient.closed {
|
||||
t.Fatal("guest client should be closed")
|
||||
|
|
@ -1459,8 +1431,7 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
|
|||
origWaitForSSH := guestWaitForSSHFunc
|
||||
origGuestDial := guestDialFunc
|
||||
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
|
||||
origOpencodeExec := opencodeExecFunc
|
||||
origHostOpencodeAttachSupported := hostOpencodeAttachSupportedFunc
|
||||
origVMWorkspacePrepare := vmWorkspacePrepareFunc
|
||||
t.Cleanup(func() {
|
||||
vmCreateBeginFunc = origBegin
|
||||
vmCreateStatusFunc = origStatus
|
||||
|
|
@ -1468,8 +1439,7 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
|
|||
guestWaitForSSHFunc = origWaitForSSH
|
||||
guestDialFunc = origGuestDial
|
||||
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
|
||||
opencodeExecFunc = origOpencodeExec
|
||||
hostOpencodeAttachSupportedFunc = origHostOpencodeAttachSupported
|
||||
vmWorkspacePrepareFunc = origVMWorkspacePrepare
|
||||
})
|
||||
|
||||
vm := model.VMRecord{
|
||||
|
|
@ -1509,20 +1479,18 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
|
|||
prepareVMRunRepoCopyFunc = func(ctx context.Context, spec vmRunRepoSpec) (string, func(), error) {
|
||||
return t.TempDir(), func() {}, nil
|
||||
}
|
||||
hostOpencodeAttachSupportedFunc = func(context.Context) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
|
||||
return nil
|
||||
vmWorkspacePrepareFunc = func(ctx context.Context, socketPath string, params api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error) {
|
||||
return api.VMWorkspacePrepareResult{Workspace: model.WorkspacePrepareResult{VMID: vm.ID, GuestPath: "/root/repo"}}, nil
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
err := runVMRun(
|
||||
context.Background(),
|
||||
"/tmp/bangerd.sock",
|
||||
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
|
||||
strings.NewReader(""),
|
||||
&bytes.Buffer{},
|
||||
&stdout,
|
||||
&stderr,
|
||||
api.VMCreateParams{Name: "devbox"},
|
||||
vmRunRepoSpec{RepoRoot: t.TempDir(), RepoName: "repo", HeadCommit: "deadbeef"},
|
||||
|
|
@ -1533,19 +1501,19 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
|
|||
|
||||
output := stderr.String()
|
||||
for _, want := range []string{
|
||||
"[vm run] preparing guest workspace",
|
||||
"[vm run] waiting for guest ssh",
|
||||
"[vm run] preparing shallow repo",
|
||||
"[vm run] copying repo metadata to guest",
|
||||
"[vm run] preparing guest checkout",
|
||||
"[vm run] overlaying host working tree",
|
||||
"[vm run] starting tooling harness",
|
||||
"[vm run] tooling harness log: /root/.cache/banger/vm-run-tooling-repo.log",
|
||||
"[vm run] attaching opencode",
|
||||
"[vm run] starting guest tooling bootstrap",
|
||||
"[vm run] guest tooling log: /root/.cache/banger/vm-run-tooling-repo.log",
|
||||
"[vm run] printing next steps",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("stderr = %q, want %q", output, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(output, "[vm run] attaching opencode") {
|
||||
t.Fatalf("stderr = %q, want no auto-attach progress", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) {
|
||||
|
|
@ -1555,8 +1523,7 @@ func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) {
|
|||
origWaitForSSH := guestWaitForSSHFunc
|
||||
origGuestDial := guestDialFunc
|
||||
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
|
||||
origOpencodeExec := opencodeExecFunc
|
||||
origHostOpencodeAttachSupported := hostOpencodeAttachSupportedFunc
|
||||
origVMWorkspacePrepare := vmWorkspacePrepareFunc
|
||||
t.Cleanup(func() {
|
||||
vmCreateBeginFunc = origBegin
|
||||
vmCreateStatusFunc = origStatus
|
||||
|
|
@ -1564,8 +1531,7 @@ func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) {
|
|||
guestWaitForSSHFunc = origWaitForSSH
|
||||
guestDialFunc = origGuestDial
|
||||
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
|
||||
opencodeExecFunc = origOpencodeExec
|
||||
hostOpencodeAttachSupportedFunc = origHostOpencodeAttachSupported
|
||||
vmWorkspacePrepareFunc = origVMWorkspacePrepare
|
||||
})
|
||||
|
||||
vm := model.VMRecord{
|
||||
|
|
@ -1597,22 +1563,18 @@ func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) {
|
|||
prepareVMRunRepoCopyFunc = func(ctx context.Context, spec vmRunRepoSpec) (string, func(), error) {
|
||||
return t.TempDir(), func() {}, nil
|
||||
}
|
||||
hostOpencodeAttachSupportedFunc = func(context.Context) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
attachCalled := false
|
||||
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
|
||||
attachCalled = true
|
||||
return nil
|
||||
vmWorkspacePrepareFunc = func(ctx context.Context, socketPath string, params api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error) {
|
||||
return api.VMWorkspacePrepareResult{Workspace: model.WorkspacePrepareResult{VMID: vm.ID, GuestPath: "/root/repo"}}, nil
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
err := runVMRun(
|
||||
context.Background(),
|
||||
"/tmp/bangerd.sock",
|
||||
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
|
||||
strings.NewReader(""),
|
||||
&bytes.Buffer{},
|
||||
&stdout,
|
||||
&stderr,
|
||||
api.VMCreateParams{Name: "devbox"},
|
||||
vmRunRepoSpec{RepoRoot: t.TempDir(), RepoName: "repo", HeadCommit: "deadbeef"},
|
||||
|
|
@ -1620,147 +1582,38 @@ func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("runVMRun: %v", err)
|
||||
}
|
||||
if !attachCalled {
|
||||
t.Fatal("opencode attach should still run when tooling harness launch fails")
|
||||
if !strings.Contains(stderr.String(), "[vm run] warning: guest tooling bootstrap start failed: launch guest tooling bootstrap") {
|
||||
t.Fatalf("stderr = %q, want tooling bootstrap warning", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "[vm run] warning: tooling harness start failed: launch tooling harness: launch failed") {
|
||||
t.Fatalf("stderr = %q, want tooling harness warning", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunVMRunFallsBackToGuestOpencodeWhenHostAttachUnsupported(t *testing.T) {
|
||||
repoRoot := t.TempDir()
|
||||
|
||||
origBegin := vmCreateBeginFunc
|
||||
origStatus := vmCreateStatusFunc
|
||||
origCancel := vmCreateCancelFunc
|
||||
origWaitForSSH := guestWaitForSSHFunc
|
||||
origGuestDial := guestDialFunc
|
||||
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
|
||||
origOpencodeExec := opencodeExecFunc
|
||||
origHostOpencodeAttachSupported := hostOpencodeAttachSupportedFunc
|
||||
origSSHExec := sshExecFunc
|
||||
t.Cleanup(func() {
|
||||
vmCreateBeginFunc = origBegin
|
||||
vmCreateStatusFunc = origStatus
|
||||
vmCreateCancelFunc = origCancel
|
||||
guestWaitForSSHFunc = origWaitForSSH
|
||||
guestDialFunc = origGuestDial
|
||||
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
|
||||
opencodeExecFunc = origOpencodeExec
|
||||
hostOpencodeAttachSupportedFunc = origHostOpencodeAttachSupported
|
||||
sshExecFunc = origSSHExec
|
||||
})
|
||||
|
||||
vm := model.VMRecord{
|
||||
ID: "vm-id",
|
||||
Name: "devbox",
|
||||
Runtime: model.VMRuntime{
|
||||
State: model.VMStateRunning,
|
||||
GuestIP: "172.16.0.2",
|
||||
},
|
||||
}
|
||||
vmCreateBeginFunc = func(context.Context, string, api.VMCreateParams) (api.VMCreateBeginResult, error) {
|
||||
return api.VMCreateBeginResult{Operation: api.VMCreateOperation{ID: "op-1", Stage: "ready", Detail: "vm is ready", Done: true, Success: true, VM: &vm}}, nil
|
||||
}
|
||||
vmCreateStatusFunc = func(context.Context, string, string) (api.VMCreateStatusResult, error) {
|
||||
t.Fatal("vmCreateStatusFunc should not be called")
|
||||
return api.VMCreateStatusResult{}, nil
|
||||
}
|
||||
vmCreateCancelFunc = func(context.Context, string, string) error {
|
||||
t.Fatal("vmCreateCancelFunc should not be called")
|
||||
return nil
|
||||
}
|
||||
guestWaitForSSHFunc = func(ctx context.Context, address, privateKeyPath string, interval time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
guestDialFunc = func(ctx context.Context, address, privateKeyPath string) (vmRunGuestClient, error) {
|
||||
return &testVMRunGuestClient{}, nil
|
||||
}
|
||||
prepareVMRunRepoCopyFunc = func(ctx context.Context, spec vmRunRepoSpec) (string, func(), error) {
|
||||
return t.TempDir(), func() {}, nil
|
||||
}
|
||||
hostOpencodeAttachSupportedFunc = func(context.Context) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
|
||||
t.Fatalf("opencodeExecFunc should not be called when host attach is unsupported: %v", args)
|
||||
return nil
|
||||
}
|
||||
var sshArgs []string
|
||||
sshExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
|
||||
sshArgs = append([]string(nil), args...)
|
||||
return nil
|
||||
}
|
||||
|
||||
var stderr bytes.Buffer
|
||||
err := runVMRun(
|
||||
context.Background(),
|
||||
"/tmp/bangerd.sock",
|
||||
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
|
||||
strings.NewReader(""),
|
||||
&bytes.Buffer{},
|
||||
&stderr,
|
||||
api.VMCreateParams{Name: "devbox"},
|
||||
vmRunRepoSpec{RepoRoot: repoRoot, RepoName: "repo", HeadCommit: "deadbeef"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("runVMRun: %v", err)
|
||||
}
|
||||
if len(sshArgs) < 3 {
|
||||
t.Fatalf("sshArgs = %v, want fallback SSH invocation", sshArgs)
|
||||
}
|
||||
if sshArgs[len(sshArgs)-3] != "bash" || sshArgs[len(sshArgs)-2] != "-lc" {
|
||||
t.Fatalf("sshArgs = %v, want bash -lc fallback command", sshArgs)
|
||||
}
|
||||
if sshArgs[len(sshArgs)-1] != "cd '/root/repo' && exec opencode ." {
|
||||
t.Fatalf("ssh fallback command = %q, want guest opencode launch", sshArgs[len(sshArgs)-1])
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "[vm run] host opencode has no attach support; starting guest opencode over ssh") {
|
||||
t.Fatalf("stderr = %q, want SSH fallback progress", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpencodeAttachHelpOutputSupported(t *testing.T) {
|
||||
if !opencodeAttachHelpOutputSupported([]byte("opencode attach [url]\n\nAttach a terminal")) {
|
||||
t.Fatal("expected attach help output to be recognized")
|
||||
}
|
||||
if opencodeAttachHelpOutputSupported([]byte("opencode [project]\n\nCommands:\n opencode run [message..]")) {
|
||||
t.Fatal("unexpected attach support for top-level help output")
|
||||
if !strings.Contains(stdout.String(), "VM ready.") {
|
||||
t.Fatalf("stdout = %q, want next steps summary", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVMRunToolingHarnessScriptUsesMiseOnly(t *testing.T) {
|
||||
script := vmRunToolingHarnessScript(vmRunRepoSpec{RepoName: "repo"}, toolingplan.Plan{
|
||||
Steps: []toolingplan.InstallStep{{Tool: "go", Version: "1.25.0", Source: "go.mod"}},
|
||||
Skips: []toolingplan.SkipNote{{Target: "python", Reason: "no .python-version"}},
|
||||
RepoManagedTools: []string{"node"},
|
||||
Steps: []toolingplan.InstallStep{{Tool: "go", Version: "1.25.0", Source: "go.mod"}},
|
||||
Skips: []toolingplan.SkipNote{{Target: "python", Reason: "no .python-version"}},
|
||||
})
|
||||
|
||||
for _, want := range []string{
|
||||
`if [ -f .mise.toml ] || [ -f .tool-versions ]; then`,
|
||||
"PROMPT_FILE=" + shellQuote(vmRunToolingHarnessPromptPath("repo")),
|
||||
fmt.Sprintf("INSTALL_TIMEOUT_SECS=%d", vmRunToolingInstallTimeoutSeconds),
|
||||
"MODEL=" + shellQuote(vmRunToolingHarnessModel),
|
||||
fmt.Sprintf("TIMEOUT_SECS=%d", vmRunToolingHarnessTimeoutSeconds),
|
||||
`repo-managed mise tools: node`,
|
||||
`run_best_effort "$MISE_BIN" install`,
|
||||
`deterministic install: go@1.25.0 (go.mod)`,
|
||||
`run_bounded_best_effort "$INSTALL_TIMEOUT_SECS" "$MISE_BIN" use -g --pin 'go@1.25.0'`,
|
||||
`deterministic skip: python (no .python-version)`,
|
||||
`run_best_effort "$MISE_BIN" reshim`,
|
||||
`run_bounded_best_effort "$TIMEOUT_SECS" bash -lc 'exec "$1" run --format json -m "$2" "$(cat "$3")"' _ "$OPENCODE_BIN" "$MODEL" "$PROMPT_FILE"`,
|
||||
`command timed out after ${timeout_secs}s: $*`,
|
||||
`tooling prompt file missing: $PROMPT_FILE`,
|
||||
} {
|
||||
if !strings.Contains(script, want) {
|
||||
t.Fatalf("script = %q, want %q", script, want)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"git add", "cat > .mise.toml", "cat > .tool-versions"} {
|
||||
for _, unwanted := range []string{`opencode run`, `PROMPT_FILE=`, `--format json`, `mimo-v2-pro-free`} {
|
||||
if strings.Contains(script, unwanted) {
|
||||
t.Fatalf("script = %q, want no %q", script, unwanted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareVMRunRepoCopyCreatesShallowMetadataCopy(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not installed")
|
||||
|
|
@ -2065,14 +1918,16 @@ func (c *testVMRunGuestClient) StreamTar(ctx context.Context, sourceDir, remoteC
|
|||
|
||||
func (c *testVMRunGuestClient) RunScript(ctx context.Context, script string, logWriter io.Writer) error {
|
||||
c.runScriptCalls++
|
||||
switch c.runScriptCalls {
|
||||
case 1:
|
||||
if c.runScriptCalls == 1 {
|
||||
c.script = script
|
||||
return c.checkoutErr
|
||||
default:
|
||||
c.launchScript = script
|
||||
if c.checkoutErr != nil {
|
||||
return c.checkoutErr
|
||||
}
|
||||
return c.launchErr
|
||||
}
|
||||
c.launchScript = script
|
||||
return c.launchErr
|
||||
}
|
||||
|
||||
func (c *testVMRunGuestClient) StreamTarEntries(ctx context.Context, sourceDir string, entries []string, remoteCommand string, logWriter io.Writer) error {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue