remove vm session feature

Cuts the daemon-managed guest-session machinery (start/list/show/
logs/stop/kill/attach/send). The feature shipped aimed at agent-
orchestration workflows (programmatic stdin piping into a long-lived
guest process) that aren't driving any concrete user today, and the
~2.3K LOC of daemon surface area — attach bridge, FIFO keepalive,
controller registry, sessionstream framing, SQLite persistence — was
locking in an API we'd have to keep through v0.1.0.

Anything session-flavoured that people actually need today can be
done with `vm ssh + tmux` or `vm run -- cmd`.

Deleted:
- internal/cli/commands_vm_session.go
- internal/daemon/{guest_sessions,session_lifecycle,session_attach,session_stream,session_controller}.go
- internal/daemon/session/ (guest-session helpers package)
- internal/sessionstream/ (framing package)
- internal/daemon/guest_sessions_test.go
- internal/store/guest_session_test.go
- GuestSession* types from internal/{api,model}
- Store UpsertGuestSession/GetGuestSession/ListGuestSessionsByVM/DeleteGuestSession + scanner helpers
- guest.session.* RPC dispatch entries
- 5 CLI session tests, 2 completion tests, 2 printer tests

Extracted:
- ShellQuote + FormatStepError lifted to internal/daemon/workspace/util.go
  (only non-session consumer); workspace package now self-contained
- internal/daemon/guest_ssh.go keeps guestSSHClient + dialGuest +
  waitForGuestSSH — still used by workspace prepare/export
- internal/daemon/fake_firecracker_test.go preserves the test helper
  that used to live in guest_sessions_test.go

Store schema: CREATE TABLE guest_sessions and its column migrations
removed. Existing dev DBs keep an orphan table (harmless, pre-v0.1.0).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thales Maciel 2026-04-20 12:47:58 -03:00
parent c42fcbe012
commit 2b6437d1b4
No known key found for this signature in database
GPG key ID: 33112E6833C34679
34 changed files with 194 additions and 4031 deletions

View file

@ -46,7 +46,6 @@ func TestListCommandsHaveLsAlias(t *testing.T) {
{"vm", "list"},
{"image", "list"},
{"kernel", "list"},
{"vm", "session", "list"},
}
for _, path := range cases {
t.Run(path[len(path)-1], func(t *testing.T) {

View file

@ -1918,34 +1918,9 @@ func (c *testVMRunGuestClient) StreamTarEntries(ctx context.Context, sourceDir s
return nil
}
func TestVMSessionSendCommandExists(t *testing.T) {
root := NewBangerCommand()
vm, _, err := root.Find([]string{"vm"})
if err != nil {
t.Fatalf("find vm: %v", err)
}
session, _, err := vm.Find([]string{"session"})
if err != nil {
t.Fatalf("find session: %v", err)
}
if _, _, err := session.Find([]string{"send"}); err != nil {
t.Fatalf("find session send: %v", err)
}
}
func TestVMSessionSendRejectsWrongArgCount(t *testing.T) {
cmd := NewBangerCommand()
cmd.SetArgs([]string{"vm", "session", "send", "only-one-arg"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "usage: banger vm session send") {
t.Fatalf("Execute() error = %v, want send usage error", err)
}
}
// stubEnsureDaemonForSend isolates XDG dirs and installs a daemon-ping
// fake onto the caller's *deps so `ensureDaemon` short-circuits without
// trying to spawn bangerd. `vm session send` uses this to avoid needing
// a built binary on disk.
// trying to spawn bangerd.
func stubEnsureDaemonForSend(t *testing.T, d *deps) {
t.Helper()
t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "config"))
@ -1956,98 +1931,6 @@ func stubEnsureDaemonForSend(t *testing.T, d *deps) {
}
}
func TestVMSessionSendWithMessageFlag(t *testing.T) {
d := defaultDeps()
stubEnsureDaemonForSend(t, d)
var capturedParams api.GuestSessionSendParams
d.guestSessionSend = func(_ context.Context, _ string, params api.GuestSessionSendParams) (api.GuestSessionSendResult, error) {
capturedParams = params
return api.GuestSessionSendResult{
Session: model.GuestSession{ID: "sess-id", Name: "planner"},
BytesWritten: len(params.Payload),
}, nil
}
cmd := d.newRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs([]string{"vm", "session", "send", "devbox", "planner", "--message", `{"type":"abort"}`})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
wantPayload := []byte(`{"type":"abort"}` + "\n")
if string(capturedParams.Payload) != string(wantPayload) {
t.Fatalf("payload = %q, want %q", capturedParams.Payload, wantPayload)
}
if capturedParams.VMIDOrName != "devbox" {
t.Fatalf("VMIDOrName = %q, want %q", capturedParams.VMIDOrName, "devbox")
}
if capturedParams.SessionIDOrName != "planner" {
t.Fatalf("SessionIDOrName = %q, want %q", capturedParams.SessionIDOrName, "planner")
}
if !strings.Contains(out.String(), "17") {
t.Fatalf("output = %q, want bytes_written in output", out.String())
}
}
func TestVMSessionSendMessageAlreadyHasNewline(t *testing.T) {
d := defaultDeps()
stubEnsureDaemonForSend(t, d)
var capturedPayload []byte
d.guestSessionSend = func(_ context.Context, _ string, params api.GuestSessionSendParams) (api.GuestSessionSendResult, error) {
capturedPayload = params.Payload
return api.GuestSessionSendResult{
Session: model.GuestSession{Name: "s"},
BytesWritten: len(params.Payload),
}, nil
}
cmd := d.newRootCommand()
cmd.SetOut(io.Discard)
cmd.SetArgs([]string{"vm", "session", "send", "devbox", "s", "--message", "{\"type\":\"abort\"}\n"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
// Must not double-append newline.
if capturedPayload[len(capturedPayload)-1] != '\n' {
t.Fatalf("payload missing trailing newline: %q", capturedPayload)
}
if len(capturedPayload) > 0 && capturedPayload[len(capturedPayload)-2] == '\n' {
t.Fatalf("payload has double trailing newline: %q", capturedPayload)
}
}
func TestVMSessionSendFromStdin(t *testing.T) {
d := defaultDeps()
stubEnsureDaemonForSend(t, d)
var capturedPayload []byte
d.guestSessionSend = func(_ context.Context, _ string, params api.GuestSessionSendParams) (api.GuestSessionSendResult, error) {
capturedPayload = params.Payload
return api.GuestSessionSendResult{
Session: model.GuestSession{Name: "planner"},
BytesWritten: len(params.Payload),
}, nil
}
stdinPayload := `{"type":"steer","message":"Focus on src/"}` + "\n"
cmd := d.newRootCommand()
cmd.SetOut(io.Discard)
cmd.SetIn(strings.NewReader(stdinPayload))
cmd.SetArgs([]string{"vm", "session", "send", "devbox", "planner"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
if string(capturedPayload) != stdinPayload {
t.Fatalf("payload = %q, want %q", capturedPayload, stdinPayload)
}
}
func TestVMWorkspaceExportCommandExists(t *testing.T) {
root := NewBangerCommand()
vm, _, err := root.Find([]string{"vm"})

View file

@ -42,7 +42,6 @@ func (d *deps) newVMCommand() *cobra.Command {
d.newVMSetCommand(),
d.newVMSSHCommand(),
d.newVMWorkspaceCommand(),
d.newVMSessionCommand(),
d.newVMLogsCommand(),
d.newVMStatsCommand(),
d.newVMPortsCommand(),

View file

@ -1,370 +0,0 @@
package cli
import (
"context"
"errors"
"fmt"
"io"
"net"
"strings"
"banger/internal/api"
"banger/internal/model"
"banger/internal/sessionstream"
"github.com/spf13/cobra"
)
func (d *deps) 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(
d.newVMSessionStartCommand(),
d.newVMSessionListCommand(),
d.newVMSessionShowCommand(),
d.newVMSessionLogsCommand(),
d.newVMSessionStopCommand(),
d.newVMSessionKillCommand(),
d.newVMSessionAttachCommand(),
d.newVMSessionSendCommand(),
)
return cmd
}
func (d *deps) 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...]"),
ValidArgsFunction: d.completeVMNameOnlyAtPos0,
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 := d.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 := d.guestSessionStart(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 (d *deps) newVMSessionListCommand() *cobra.Command {
return &cobra.Command{
Use: "list <id-or-name>",
Aliases: []string{"ls"},
Short: "List managed guest commands for a VM",
Args: exactArgsUsage(1, "usage: banger vm session list <id-or-name>"),
ValidArgsFunction: d.completeVMNameOnlyAtPos0,
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := d.guestSessionList(cmd.Context(), layout.SocketPath, args[0])
if err != nil {
return err
}
return printGuestSessionTable(cmd.OutOrStdout(), result.Sessions)
},
}
}
func (d *deps) 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>"),
ValidArgsFunction: d.completeSessionNames,
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := d.guestSessionGet(cmd.Context(), layout.SocketPath, api.GuestSessionRefParams{VMIDOrName: args[0], SessionIDOrName: args[1]})
if err != nil {
return err
}
return printJSON(cmd.OutOrStdout(), result.Session)
},
}
}
func (d *deps) 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>"),
ValidArgsFunction: d.completeSessionNames,
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := d.guestSessionLogs(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 (d *deps) 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>"),
ValidArgsFunction: d.completeSessionNames,
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := d.guestSessionStop(cmd.Context(), layout.SocketPath, api.GuestSessionRefParams{VMIDOrName: args[0], SessionIDOrName: args[1]})
if err != nil {
return err
}
return printGuestSessionSummary(cmd.OutOrStdout(), result.Session)
},
}
}
func (d *deps) 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>"),
ValidArgsFunction: d.completeSessionNames,
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := d.guestSessionKill(cmd.Context(), layout.SocketPath, api.GuestSessionRefParams{VMIDOrName: args[0], SessionIDOrName: args[1]})
if err != nil {
return err
}
return printGuestSessionSummary(cmd.OutOrStdout(), result.Session)
},
}
}
func (d *deps) 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>"),
ValidArgsFunction: d.completeSessionNames,
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := d.guestSessionAttachBegin(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 (d *deps) newVMSessionSendCommand() *cobra.Command {
var message string
cmd := &cobra.Command{
Use: "send <id-or-name> <session>",
Short: "Write bytes to a running guest session's stdin pipe",
Long: "Write a payload to the stdin pipe of a running pipe-mode guest session without holding the exclusive attach. Use --message for an inline JSONL string, or pipe bytes via stdin when --message is omitted. A trailing newline is appended to --message values that lack one.",
Args: exactArgsUsage(2, "usage: banger vm session send <id-or-name> <session> [--message '<json>']"),
ValidArgsFunction: d.completeSessionNames,
Example: strings.TrimSpace(`
banger vm session send devbox planner --message '{"type":"abort"}'
banger vm session send devbox planner --message '{"type":"steer","message":"Focus on src/"}'
echo '{"type":"prompt","prompt":"Summarize."}' | banger vm session send devbox planner
`),
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
var payload []byte
if message != "" {
payload = []byte(message)
if len(payload) > 0 && payload[len(payload)-1] != '\n' {
payload = append(payload, '\n')
}
} else {
payload, err = io.ReadAll(cmd.InOrStdin())
if err != nil {
return fmt.Errorf("read stdin: %w", err)
}
}
result, err := d.guestSessionSend(cmd.Context(), layout.SocketPath, api.GuestSessionSendParams{
VMIDOrName: args[0],
SessionIDOrName: args[1],
Payload: payload,
})
if err != nil {
return err
}
_, err = fmt.Fprintf(cmd.OutOrStdout(), "sent %d bytes to session %s\n", result.BytesWritten, result.Session.Name)
return err
},
}
cmd.Flags().StringVar(&message, "message", "", "JSONL message to send; a trailing newline is appended if absent")
return cmd
}
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 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
}
}
}

View file

@ -21,9 +21,9 @@ import (
// - Fail silently. Completion is advisory; any error path returns an
// empty suggestion list rather than propagating to the user.
// defaultCompletionLister + defaultCompletionSessionLister back the
// corresponding *deps fields; tests inject their own fakes via the
// struct instead of mutating package-level vars.
// defaultCompletionLister backs the *deps.completionLister field;
// tests inject their own fake via the struct instead of mutating
// package-level vars.
func defaultCompletionLister(ctx context.Context, socketPath, method string) ([]string, error) {
switch method {
case "vm.list":
@ -66,20 +66,6 @@ func defaultCompletionLister(ctx context.Context, socketPath, method string) ([]
return nil, nil
}
func defaultCompletionSessionLister(ctx context.Context, socketPath, vmIDOrName string) ([]string, error) {
result, err := rpc.Call[api.GuestSessionListResult](ctx, socketPath, "guest.session.list", api.VMRefParams{IDOrName: vmIDOrName})
if err != nil {
return nil, err
}
names := make([]string, 0, len(result.Sessions))
for _, session := range result.Sessions {
if session.Name != "" {
names = append(names, session.Name)
}
}
return names, nil
}
// daemonSocketForCompletion returns the socket path IFF the daemon is
// already running. Returns "", false when no daemon is up — completion
// callers use this as the bail signal.
@ -177,25 +163,3 @@ func (d *deps) completeKernelNames(cmd *cobra.Command, args []string, toComplete
}
return filterPrefix(names, args, toComplete), cobra.ShellCompDirectiveNoFileComp
}
// completeSessionNames handles `... <vm> <session>` commands: pos 0
// completes VMs, pos 1 completes sessions owned by args[0], pos 2+ is
// silent.
func (d *deps) completeSessionNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
switch len(args) {
case 0:
return d.completeVMNames(cmd, args, toComplete)
case 1:
socket, ok := d.daemonSocketForCompletion(cmd.Context())
if !ok {
return nil, cobra.ShellCompDirectiveNoFileComp
}
names, err := d.completionSessionLister(cmd.Context(), socket, args[0])
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return filterPrefix(names, nil, toComplete), cobra.ShellCompDirectiveNoFileComp
default:
return nil, cobra.ShellCompDirectiveNoFileComp
}
}

View file

@ -19,10 +19,7 @@ func stubCompletionSeams(
d *deps,
pingErr error,
names map[string][]string,
listErr error,
sessions map[string][]string,
sessionErr error,
) {
listErr error) {
t.Helper()
d.daemonPing = func(ctx context.Context, socketPath string) (api.PingResult, error) {
@ -37,12 +34,6 @@ func stubCompletionSeams(
}
return names[method], nil
}
d.completionSessionLister = func(ctx context.Context, socketPath, vmIDOrName string) ([]string, error) {
if sessionErr != nil {
return nil, sessionErr
}
return sessions[vmIDOrName], nil
}
}
func TestFilterPrefix(t *testing.T) {
@ -82,7 +73,7 @@ func testCmdWithCtx() *cobra.Command {
func TestCompleteVMNamesHappyPath(t *testing.T) {
d := defaultDeps()
stubCompletionSeams(t, d, nil, map[string][]string{"vm.list": {"alpha", "beta", "gamma"}}, nil, nil, nil)
stubCompletionSeams(t, d, nil, map[string][]string{"vm.list": {"alpha", "beta", "gamma"}}, nil)
got, directive := d.completeVMNames(testCmdWithCtx(), nil, "")
if directive != cobra.ShellCompDirectiveNoFileComp {
@ -95,7 +86,7 @@ func TestCompleteVMNamesHappyPath(t *testing.T) {
func TestCompleteVMNamesDaemonDown(t *testing.T) {
d := defaultDeps()
stubCompletionSeams(t, d, errors.New("connection refused"), nil, nil, nil, nil)
stubCompletionSeams(t, d, errors.New("connection refused"), nil, nil)
got, directive := d.completeVMNames(testCmdWithCtx(), nil, "")
if len(got) != 0 {
@ -108,7 +99,7 @@ func TestCompleteVMNamesDaemonDown(t *testing.T) {
func TestCompleteVMNamesRPCError(t *testing.T) {
d := defaultDeps()
stubCompletionSeams(t, d, nil, nil, errors.New("rpc failed"), nil, nil)
stubCompletionSeams(t, d, nil, nil, errors.New("rpc failed"))
got, _ := d.completeVMNames(testCmdWithCtx(), nil, "")
if len(got) != 0 {
@ -118,7 +109,7 @@ func TestCompleteVMNamesRPCError(t *testing.T) {
func TestCompleteVMNamesExcludesAlreadyEntered(t *testing.T) {
d := defaultDeps()
stubCompletionSeams(t, d, nil, map[string][]string{"vm.list": {"alpha", "beta", "gamma"}}, nil, nil, nil)
stubCompletionSeams(t, d, nil, map[string][]string{"vm.list": {"alpha", "beta", "gamma"}}, nil)
got, _ := d.completeVMNames(testCmdWithCtx(), []string{"alpha"}, "")
want := []string{"beta", "gamma"}
@ -129,7 +120,7 @@ func TestCompleteVMNamesExcludesAlreadyEntered(t *testing.T) {
func TestCompleteVMNamesPrefixFilter(t *testing.T) {
d := defaultDeps()
stubCompletionSeams(t, d, nil, map[string][]string{"vm.list": {"alpha", "beta", "alphabet"}}, nil, nil, nil)
stubCompletionSeams(t, d, nil, map[string][]string{"vm.list": {"alpha", "beta", "alphabet"}}, nil)
got, _ := d.completeVMNames(testCmdWithCtx(), nil, "alp")
want := []string{"alpha", "alphabet"}
@ -140,7 +131,7 @@ func TestCompleteVMNamesPrefixFilter(t *testing.T) {
func TestCompleteVMNameOnlyAtPos0(t *testing.T) {
d := defaultDeps()
stubCompletionSeams(t, d, nil, map[string][]string{"vm.list": {"alpha"}}, nil, nil, nil)
stubCompletionSeams(t, d, nil, map[string][]string{"vm.list": {"alpha"}}, nil)
atPos0, _ := d.completeVMNameOnlyAtPos0(testCmdWithCtx(), nil, "")
if len(atPos0) != 1 || atPos0[0] != "alpha" {
@ -155,7 +146,7 @@ func TestCompleteVMNameOnlyAtPos0(t *testing.T) {
func TestCompleteImageNames(t *testing.T) {
d := defaultDeps()
stubCompletionSeams(t, d, nil, map[string][]string{"image.list": {"debian-bookworm", "alpine"}}, nil, nil, nil)
stubCompletionSeams(t, d, nil, map[string][]string{"image.list": {"debian-bookworm", "alpine"}}, nil)
got, _ := d.completeImageNames(testCmdWithCtx(), nil, "")
if !reflect.DeepEqual(got, []string{"debian-bookworm", "alpine"}) {
@ -165,7 +156,7 @@ func TestCompleteImageNames(t *testing.T) {
func TestCompleteKernelNames(t *testing.T) {
d := defaultDeps()
stubCompletionSeams(t, d, nil, map[string][]string{"kernel.list": {"generic-6.12"}}, nil, nil, nil)
stubCompletionSeams(t, d, nil, map[string][]string{"kernel.list": {"generic-6.12"}}, nil)
got, _ := d.completeKernelNames(testCmdWithCtx(), nil, "")
if len(got) != 1 || got[0] != "generic-6.12" {
@ -175,58 +166,10 @@ func TestCompleteKernelNames(t *testing.T) {
func TestCompleteImageNameOnlyAtPos0SilentAfterFirst(t *testing.T) {
d := defaultDeps()
stubCompletionSeams(t, d, nil, map[string][]string{"image.list": {"alpine"}}, nil, nil, nil)
stubCompletionSeams(t, d, nil, map[string][]string{"image.list": {"alpine"}}, nil)
after, _ := d.completeImageNameOnlyAtPos0(testCmdWithCtx(), []string{"alpine"}, "")
if len(after) != 0 {
t.Errorf("expected silence at pos 1+, got %v", after)
}
}
func TestCompleteSessionNames(t *testing.T) {
d := defaultDeps()
stubCompletionSeams(t, d,
nil,
map[string][]string{"vm.list": {"devbox"}},
nil,
map[string][]string{"devbox": {"planner", "worker"}},
nil,
)
// Position 0 → VMs.
vms, _ := d.completeSessionNames(testCmdWithCtx(), nil, "")
if len(vms) != 1 || vms[0] != "devbox" {
t.Errorf("pos 0: got %v", vms)
}
// Position 1 → sessions scoped to args[0].
sessions, _ := d.completeSessionNames(testCmdWithCtx(), []string{"devbox"}, "")
if !reflect.DeepEqual(sessions, []string{"planner", "worker"}) {
t.Errorf("pos 1: got %v", sessions)
}
// Position 1 with prefix filter.
filtered, _ := d.completeSessionNames(testCmdWithCtx(), []string{"devbox"}, "wor")
if len(filtered) != 1 || filtered[0] != "worker" {
t.Errorf("pos 1 prefix: got %v", filtered)
}
// Position 2+ silent.
past, _ := d.completeSessionNames(testCmdWithCtx(), []string{"devbox", "planner"}, "")
if len(past) != 0 {
t.Errorf("pos 2+: got %v", past)
}
}
func TestCompleteSessionNamesDaemonDown(t *testing.T) {
d := defaultDeps()
stubCompletionSeams(t, d, errors.New("down"), nil, nil, nil, nil)
got, directive := d.completeSessionNames(testCmdWithCtx(), []string{"devbox"}, "")
if len(got) != 0 {
t.Errorf("expected no suggestions when daemon down, got %v", got)
}
if directive != cobra.ShellCompDirectiveNoFileComp {
t.Errorf("directive = %d, want NoFileComp", directive)
}
}

View file

@ -31,36 +31,27 @@ import (
// validators) stay package-level because they hold no references to
// external systems.
type deps struct {
bangerdPath func() (string, error)
daemonExePath func(pid int) string
doctor func(ctx context.Context) (system.Report, error)
sshExec func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error
hostCommandOutput func(ctx context.Context, name string, args ...string) ([]byte, error)
vmHealth func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error)
vmSSH func(ctx context.Context, socketPath, idOrName string) (api.VMSSHResult, error)
vmDelete func(ctx context.Context, socketPath, idOrName string) error
vmList func(ctx context.Context, socketPath string) (api.VMListResult, error)
daemonPing func(ctx context.Context, socketPath string) (api.PingResult, error)
vmCreateBegin func(ctx context.Context, socketPath string, params api.VMCreateParams) (api.VMCreateBeginResult, error)
vmCreateStatus func(ctx context.Context, socketPath, operationID string) (api.VMCreateStatusResult, error)
vmCreateCancel func(ctx context.Context, socketPath, operationID string) error
vmPorts func(ctx context.Context, socketPath, idOrName string) (api.VMPortsResult, error)
vmWorkspacePrepare func(ctx context.Context, socketPath string, params api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error)
vmWorkspaceExport func(ctx context.Context, socketPath string, params api.WorkspaceExportParams) (api.WorkspaceExportResult, error)
guestSessionStart func(ctx context.Context, socketPath string, params api.GuestSessionStartParams) (api.GuestSessionShowResult, error)
guestSessionGet func(ctx context.Context, socketPath string, params api.GuestSessionRefParams) (api.GuestSessionShowResult, error)
guestSessionList func(ctx context.Context, socketPath, idOrName string) (api.GuestSessionListResult, error)
guestSessionStop func(ctx context.Context, socketPath string, params api.GuestSessionRefParams) (api.GuestSessionShowResult, error)
guestSessionKill func(ctx context.Context, socketPath string, params api.GuestSessionRefParams) (api.GuestSessionShowResult, error)
guestSessionLogs func(ctx context.Context, socketPath string, params api.GuestSessionLogsParams) (api.GuestSessionLogsResult, error)
guestSessionAttachBegin func(ctx context.Context, socketPath string, params api.GuestSessionAttachBeginParams) (api.GuestSessionAttachBeginResult, error)
guestSessionSend func(ctx context.Context, socketPath string, params api.GuestSessionSendParams) (api.GuestSessionSendResult, error)
guestWaitForSSH func(ctx context.Context, address, privateKeyPath string, interval time.Duration) error
guestDial func(ctx context.Context, address, privateKeyPath string) (vmRunGuestClient, error)
buildVMRunToolingPlan func(ctx context.Context, repoRoot string) toolingplan.Plan
cwd func() (string, error)
completionLister func(ctx context.Context, socketPath, method string) ([]string, error)
completionSessionLister func(ctx context.Context, socketPath, vmIDOrName string) ([]string, error)
bangerdPath func() (string, error)
daemonExePath func(pid int) string
doctor func(ctx context.Context) (system.Report, error)
sshExec func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error
hostCommandOutput func(ctx context.Context, name string, args ...string) ([]byte, error)
vmHealth func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error)
vmSSH func(ctx context.Context, socketPath, idOrName string) (api.VMSSHResult, error)
vmDelete func(ctx context.Context, socketPath, idOrName string) error
vmList func(ctx context.Context, socketPath string) (api.VMListResult, error)
daemonPing func(ctx context.Context, socketPath string) (api.PingResult, error)
vmCreateBegin func(ctx context.Context, socketPath string, params api.VMCreateParams) (api.VMCreateBeginResult, error)
vmCreateStatus func(ctx context.Context, socketPath, operationID string) (api.VMCreateStatusResult, error)
vmCreateCancel func(ctx context.Context, socketPath, operationID string) error
vmPorts func(ctx context.Context, socketPath, idOrName string) (api.VMPortsResult, error)
vmWorkspacePrepare func(ctx context.Context, socketPath string, params api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error)
vmWorkspaceExport func(ctx context.Context, socketPath string, params api.WorkspaceExportParams) (api.WorkspaceExportResult, error)
guestWaitForSSH func(ctx context.Context, address, privateKeyPath string, interval time.Duration) error
guestDial func(ctx context.Context, address, privateKeyPath string) (vmRunGuestClient, error)
buildVMRunToolingPlan func(ctx context.Context, repoRoot string) toolingplan.Plan
cwd func() (string, error)
completionLister func(ctx context.Context, socketPath, method string) ([]string, error)
}
func defaultDeps() *deps {
@ -125,30 +116,6 @@ func defaultDeps() *deps {
vmWorkspaceExport: func(ctx context.Context, socketPath string, params api.WorkspaceExportParams) (api.WorkspaceExportResult, error) {
return rpc.Call[api.WorkspaceExportResult](ctx, socketPath, "vm.workspace.export", params)
},
guestSessionStart: func(ctx context.Context, socketPath string, params api.GuestSessionStartParams) (api.GuestSessionShowResult, error) {
return rpc.Call[api.GuestSessionShowResult](ctx, socketPath, "guest.session.start", params)
},
guestSessionGet: func(ctx context.Context, socketPath string, params api.GuestSessionRefParams) (api.GuestSessionShowResult, error) {
return rpc.Call[api.GuestSessionShowResult](ctx, socketPath, "guest.session.get", params)
},
guestSessionList: func(ctx context.Context, socketPath, idOrName string) (api.GuestSessionListResult, error) {
return rpc.Call[api.GuestSessionListResult](ctx, socketPath, "guest.session.list", api.VMRefParams{IDOrName: idOrName})
},
guestSessionStop: func(ctx context.Context, socketPath string, params api.GuestSessionRefParams) (api.GuestSessionShowResult, error) {
return rpc.Call[api.GuestSessionShowResult](ctx, socketPath, "guest.session.stop", params)
},
guestSessionKill: func(ctx context.Context, socketPath string, params api.GuestSessionRefParams) (api.GuestSessionShowResult, error) {
return rpc.Call[api.GuestSessionShowResult](ctx, socketPath, "guest.session.kill", params)
},
guestSessionLogs: func(ctx context.Context, socketPath string, params api.GuestSessionLogsParams) (api.GuestSessionLogsResult, error) {
return rpc.Call[api.GuestSessionLogsResult](ctx, socketPath, "guest.session.logs", params)
},
guestSessionAttachBegin: func(ctx context.Context, socketPath string, params api.GuestSessionAttachBeginParams) (api.GuestSessionAttachBeginResult, error) {
return rpc.Call[api.GuestSessionAttachBeginResult](ctx, socketPath, "guest.session.attach.begin", params)
},
guestSessionSend: func(ctx context.Context, socketPath string, params api.GuestSessionSendParams) (api.GuestSessionSendResult, error) {
return rpc.Call[api.GuestSessionSendResult](ctx, socketPath, "guest.session.send", params)
},
guestWaitForSSH: func(ctx context.Context, address, privateKeyPath string, interval time.Duration) error {
knownHosts, _ := bangerKnownHostsPath()
return guest.WaitForSSH(ctx, address, privateKeyPath, knownHosts, interval)
@ -157,9 +124,8 @@ func defaultDeps() *deps {
knownHosts, _ := bangerKnownHostsPath()
return guest.Dial(ctx, address, privateKeyPath, knownHosts)
},
buildVMRunToolingPlan: toolingplan.Build,
cwd: os.Getwd,
completionLister: defaultCompletionLister,
completionSessionLister: defaultCompletionSessionLister,
buildVMRunToolingPlan: toolingplan.Build,
cwd: os.Getwd,
completionLister: defaultCompletionLister,
}
}

View file

@ -4,7 +4,6 @@ import (
"bytes"
"errors"
"fmt"
"reflect"
"strings"
"testing"
@ -52,37 +51,6 @@ func TestDashIfEmpty(t *testing.T) {
}
}
func TestParseKeyValuePairs(t *testing.T) {
t.Run("nil when empty", func(t *testing.T) {
got, err := parseKeyValuePairs(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != nil {
t.Fatalf("got %v, want nil", got)
}
})
t.Run("parses entries", func(t *testing.T) {
got, err := parseKeyValuePairs([]string{"a=1", " b = two", "c=x=y"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := map[string]string{"a": "1", "b": " two", "c": "x=y"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
t.Run("rejects malformed entries", func(t *testing.T) {
for _, bad := range []string{"noequals", "=noKey", " =v"} {
if _, err := parseKeyValuePairs([]string{bad}); err == nil {
t.Errorf("expected error for %q", bad)
}
}
})
}
func TestExitCodeErrorError(t *testing.T) {
e := ExitCodeError{Code: 42}
got := e.Error()
@ -234,38 +202,6 @@ func TestPrintKernelCatalogTable(t *testing.T) {
}
}
func TestPrintGuestSessionTable(t *testing.T) {
var buf bytes.Buffer
sessions := []model.GuestSession{
{ID: "abcdef0123456789", Name: "planner", Status: "running", Command: "pi", CWD: "/root/repo", Attachable: true},
{ID: "short", Name: "once", Status: "exited", Command: "true", CWD: "/tmp", Attachable: false},
}
if err := printGuestSessionTable(&buf, sessions); err != nil {
t.Fatalf("printGuestSessionTable: %v", err)
}
got := buf.String()
for _, want := range []string{"ID", "NAME", "planner", "once", "yes", "no", "pi"} {
if !strings.Contains(got, want) {
t.Errorf("output missing %q:\n%s", want, got)
}
}
}
func TestPrintGuestSessionSummary(t *testing.T) {
var buf bytes.Buffer
session := model.GuestSession{
ID: "id1", Name: "s", Status: "exited", Command: "true", CWD: "/root",
}
if err := printGuestSessionSummary(&buf, session); err != nil {
t.Fatalf("printGuestSessionSummary: %v", err)
}
got := buf.String()
fields := strings.Split(strings.TrimRight(got, "\n"), "\t")
if len(fields) != 5 {
t.Fatalf("expected 5 tab-separated fields, got %d: %q", len(fields), got)
}
}
func TestPrintJSON(t *testing.T) {
var buf bytes.Buffer
if err := printJSON(&buf, map[string]int{"a": 1, "b": 2}); err != nil {
@ -340,10 +276,6 @@ type failWriter struct{}
func (failWriter) Write([]byte) (int, error) { return 0, fmt.Errorf("boom") }
func TestPrintersPropagateWriteErrors(t *testing.T) {
sessions := []model.GuestSession{{ID: "id", Name: "n"}}
if err := printGuestSessionTable(failWriter{}, sessions); err == nil {
t.Error("expected write error from printGuestSessionTable")
}
kernels := []api.KernelEntry{{Name: "k"}}
if err := printKernelListTable(failWriter{}, kernels); err == nil {
t.Error("expected write error from printKernelListTable")

View file

@ -3,7 +3,6 @@ package cli
import (
"encoding/json"
"fmt"
"io"
"os"
"sort"
"strings"
@ -276,30 +275,6 @@ func printKernelCatalogTable(out anyWriter, entries []api.KernelCatalogEntry) er
return w.Flush()
}
// -- guest session printers -----------------------------------------
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()
}
// -- doctor printer -------------------------------------------------
func printDoctorReport(out anyWriter, report system.Report) error {