cli: shell completion via cobra + dynamic resource name lookups
Re-enable cobra's default `completion` subcommand (`banger completion bash|zsh|fish|powershell`). Plus live resource-name suggestions that hit the running daemon via the same RPC the real commands use: vm start/stop/restart/delete/kill/set → completeVMNames (variadic) vm ssh/show/logs/stats/ports/... → completeVMNameOnlyAtPos0 vm session list/start → completeVMNameOnlyAtPos0 vm session show/logs/stop/kill/attach/send → completeSessionNames (vm + session) image show/delete/promote → completeImageNameOnlyAtPos0 kernel show/rm → completeKernelNameOnlyAtPos0 vm run/create --image, image pull/register --kernel-ref → flag-value completion Design notes in internal/cli/completion.go: completers never auto-start the daemon (ping-check, bail with NoFileComp on miss), so tab-completion stays a zero-cost probe. Variadic completers exclude already-entered args to avoid duplicate suggestions. README: install recipes for bash / zsh / fish.
This commit is contained in:
parent
346eaba673
commit
e3eaa0c797
4 changed files with 556 additions and 76 deletions
22
README.md
22
README.md
|
|
@ -32,6 +32,28 @@ Installs `banger` (CLI), `bangerd` (daemon, auto-starts on first
|
||||||
CLI call), and `banger-vsock-agent` (companion, under
|
CLI call), and `banger-vsock-agent` (companion, under
|
||||||
`$PREFIX/lib/banger/`).
|
`$PREFIX/lib/banger/`).
|
||||||
|
|
||||||
|
### Shell completion
|
||||||
|
|
||||||
|
`banger` ships completion scripts for bash, zsh, fish, and
|
||||||
|
powershell. Tab-completion covers subcommands, flags, and live
|
||||||
|
resource names (VM, image, kernel, session) looked up from the
|
||||||
|
daemon. With the daemon down, resource completion silently
|
||||||
|
returns nothing — no file-completion fallback.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# bash (system-wide)
|
||||||
|
banger completion bash | sudo tee /etc/bash_completion.d/banger
|
||||||
|
|
||||||
|
# zsh (user-local; ~/.zfunc must be on fpath)
|
||||||
|
banger completion zsh > ~/.zfunc/_banger
|
||||||
|
|
||||||
|
# fish
|
||||||
|
banger completion fish > ~/.config/fish/completions/banger.fish
|
||||||
|
```
|
||||||
|
|
||||||
|
`banger completion --help` shows the shell-specific loading
|
||||||
|
recipes.
|
||||||
|
|
||||||
## `vm run`
|
## `vm run`
|
||||||
|
|
||||||
One command, four common shapes:
|
One command, four common shapes:
|
||||||
|
|
|
||||||
|
|
@ -181,7 +181,6 @@ func NewBangerCommand() *cobra.Command {
|
||||||
SilenceErrors: true,
|
SilenceErrors: true,
|
||||||
RunE: helpNoArgs,
|
RunE: helpNoArgs,
|
||||||
}
|
}
|
||||||
root.CompletionOptions.DisableDefaultCmd = true
|
|
||||||
root.AddCommand(newDaemonCommand(), newDoctorCommand(), newImageCommand(), newInternalCommand(), newKernelCommand(), newVersionCommand(), newPSCommand(), newVMCommand())
|
root.AddCommand(newDaemonCommand(), newDoctorCommand(), newImageCommand(), newInternalCommand(), newKernelCommand(), newVersionCommand(), newPSCommand(), newVMCommand())
|
||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|
@ -846,15 +845,17 @@ Three modes:
|
||||||
cmd.Flags().StringVar(&branchName, "branch", "", "create and switch to a new guest branch")
|
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(&fromRef, "from", "HEAD", "base ref for --branch")
|
||||||
cmd.Flags().BoolVar(&removeOnExit, "rm", false, "delete the VM after the ssh session / command exits")
|
cmd.Flags().BoolVar(&removeOnExit, "rm", false, "delete the VM after the ssh session / command exits")
|
||||||
|
_ = cmd.RegisterFlagCompletionFunc("image", completeImageNames)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func newVMKillCommand() *cobra.Command {
|
func newVMKillCommand() *cobra.Command {
|
||||||
var signal string
|
var signal string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "kill <id-or-name>...",
|
Use: "kill <id-or-name>...",
|
||||||
Short: "Send a signal to a VM process",
|
Short: "Send a signal to a VM process",
|
||||||
Args: minArgsUsage(1, "usage: banger vm kill [--signal SIGTERM|SIGKILL|...] <id-or-name>..."),
|
Args: minArgsUsage(1, "usage: banger vm kill [--signal SIGTERM|SIGKILL|...] <id-or-name>..."),
|
||||||
|
ValidArgsFunction: completeVMNames,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if err := system.EnsureSudo(cmd.Context()); err != nil {
|
if err := system.EnsureSudo(cmd.Context()); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -935,6 +936,7 @@ func newVMCreateCommand() *cobra.Command {
|
||||||
cmd.Flags().StringVar(&workDiskSize, "disk-size", model.FormatSizeBytes(model.DefaultWorkDiskSize), "work disk size")
|
cmd.Flags().StringVar(&workDiskSize, "disk-size", model.FormatSizeBytes(model.DefaultWorkDiskSize), "work disk size")
|
||||||
cmd.Flags().BoolVar(&natEnabled, "nat", false, "enable NAT")
|
cmd.Flags().BoolVar(&natEnabled, "nat", false, "enable NAT")
|
||||||
cmd.Flags().BoolVar(&noStart, "no-start", false, "create without starting")
|
cmd.Flags().BoolVar(&noStart, "no-start", false, "create without starting")
|
||||||
|
_ = cmd.RegisterFlagCompletionFunc("image", completeImageNames)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1015,9 +1017,10 @@ func selectVMListVMs(vms []model.VMRecord, showAll, latest bool) []model.VMRecor
|
||||||
|
|
||||||
func newVMShowCommand() *cobra.Command {
|
func newVMShowCommand() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "show <id-or-name>",
|
Use: "show <id-or-name>",
|
||||||
Short: "Show VM details",
|
Short: "Show VM details",
|
||||||
Args: exactArgsUsage(1, "usage: banger vm show <id-or-name>"),
|
Args: exactArgsUsage(1, "usage: banger vm show <id-or-name>"),
|
||||||
|
ValidArgsFunction: completeVMNameOnlyAtPos0,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
layout, _, err := ensureDaemon(cmd.Context())
|
layout, _, err := ensureDaemon(cmd.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -1034,9 +1037,10 @@ func newVMShowCommand() *cobra.Command {
|
||||||
|
|
||||||
func newVMActionCommand(use, short, method string) *cobra.Command {
|
func newVMActionCommand(use, short, method string) *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: use + " <id-or-name>...",
|
Use: use + " <id-or-name>...",
|
||||||
Short: short,
|
Short: short,
|
||||||
Args: minArgsUsage(1, fmt.Sprintf("usage: banger vm %s <id-or-name>...", use)),
|
Args: minArgsUsage(1, fmt.Sprintf("usage: banger vm %s <id-or-name>...", use)),
|
||||||
|
ValidArgsFunction: completeVMNames,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if err := system.EnsureSudo(cmd.Context()); err != nil {
|
if err := system.EnsureSudo(cmd.Context()); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -1072,9 +1076,10 @@ func newVMSetCommand() *cobra.Command {
|
||||||
noNat bool
|
noNat bool
|
||||||
)
|
)
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "set <id-or-name>...",
|
Use: "set <id-or-name>...",
|
||||||
Short: "Update stopped VM settings",
|
Short: "Update stopped VM settings",
|
||||||
Args: minArgsUsage(1, "usage: banger vm set [--vcpu N] [--memory MiB] [--disk-size SIZE] [--nat|--no-nat] <id-or-name>..."),
|
Args: minArgsUsage(1, "usage: banger vm set [--vcpu N] [--memory MiB] [--disk-size SIZE] [--nat|--no-nat] <id-or-name>..."),
|
||||||
|
ValidArgsFunction: completeVMNames,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
params, err := vmSetParamsFromFlags(args[0], vcpu, memory, diskSize, nat, noNat)
|
params, err := vmSetParamsFromFlags(args[0], vcpu, memory, diskSize, nat, noNat)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -1115,9 +1120,10 @@ func newVMSetCommand() *cobra.Command {
|
||||||
|
|
||||||
func newVMSSHCommand() *cobra.Command {
|
func newVMSSHCommand() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "ssh <id-or-name> [ssh args...]",
|
Use: "ssh <id-or-name> [ssh args...]",
|
||||||
Short: "SSH into a running VM",
|
Short: "SSH into a running VM",
|
||||||
Args: minArgsUsage(1, "usage: banger vm ssh <id-or-name> [ssh args...]"),
|
Args: minArgsUsage(1, "usage: banger vm ssh <id-or-name> [ssh args...]"),
|
||||||
|
ValidArgsFunction: completeVMNameOnlyAtPos0,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
layout, cfg, err := ensureDaemon(cmd.Context())
|
layout, cfg, err := ensureDaemon(cmd.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -1159,10 +1165,11 @@ func newVMWorkspacePrepareCommand() *cobra.Command {
|
||||||
var mode string
|
var mode string
|
||||||
var readOnly bool
|
var readOnly bool
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "prepare <id-or-name> [path]",
|
Use: "prepare <id-or-name> [path]",
|
||||||
Short: "Copy a local repo into a running VM",
|
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.",
|
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]"),
|
Args: minArgsUsage(1, "usage: banger vm workspace prepare <id-or-name> [path]"),
|
||||||
|
ValidArgsFunction: completeVMNameOnlyAtPos0,
|
||||||
Example: strings.TrimSpace(`
|
Example: strings.TrimSpace(`
|
||||||
banger vm workspace prepare devbox
|
banger vm workspace prepare devbox
|
||||||
banger vm workspace prepare devbox ../repo --guest-path /root/repo --readonly
|
banger vm workspace prepare devbox ../repo --guest-path /root/repo --readonly
|
||||||
|
|
@ -1213,10 +1220,11 @@ func newVMWorkspaceExportCommand() *cobra.Command {
|
||||||
var outputPath string
|
var outputPath string
|
||||||
var baseCommit string
|
var baseCommit string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "export <id-or-name>",
|
Use: "export <id-or-name>",
|
||||||
Short: "Pull changes from a guest workspace back to the host as a patch",
|
Short: "Pull changes from a guest workspace back to the host as a patch",
|
||||||
Long: "Stage all changes inside the guest workspace (git add -A) and emit a binary-safe unified diff. Pass --base-commit with the head_commit from workspace prepare to capture changes even when the worker ran git commit inside the VM. Without --base-commit the diff is against the current guest HEAD, which misses committed changes.",
|
Long: "Stage all changes inside the guest workspace (git add -A) and emit a binary-safe unified diff. Pass --base-commit with the head_commit from workspace prepare to capture changes even when the worker ran git commit inside the VM. Without --base-commit the diff is against the current guest HEAD, which misses committed changes.",
|
||||||
Args: exactArgsUsage(1, "usage: banger vm workspace export <id-or-name>"),
|
Args: exactArgsUsage(1, "usage: banger vm workspace export <id-or-name>"),
|
||||||
|
ValidArgsFunction: completeVMNameOnlyAtPos0,
|
||||||
Example: strings.TrimSpace(`
|
Example: strings.TrimSpace(`
|
||||||
banger vm workspace export devbox | git apply
|
banger vm workspace export devbox | git apply
|
||||||
banger vm workspace export devbox --base-commit abc1234 | git apply
|
banger vm workspace export devbox --base-commit abc1234 | git apply
|
||||||
|
|
@ -1286,10 +1294,11 @@ func newVMSessionStartCommand() *cobra.Command {
|
||||||
var tagPairs []string
|
var tagPairs []string
|
||||||
var requiredCommands []string
|
var requiredCommands []string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "start <id-or-name> <command> [args...]",
|
Use: "start <id-or-name> <command> [args...]",
|
||||||
Short: "Start a managed guest command",
|
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.",
|
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...]"),
|
Args: minArgsUsage(2, "usage: banger vm session start <id-or-name> [flags] -- <command> [args...]"),
|
||||||
|
ValidArgsFunction: completeVMNameOnlyAtPos0,
|
||||||
Example: strings.TrimSpace(`
|
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 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'
|
banger vm session start devbox --name shell --stdin-mode pipe -- bash -lc 'exec bash'
|
||||||
|
|
@ -1341,9 +1350,10 @@ func newVMSessionStartCommand() *cobra.Command {
|
||||||
|
|
||||||
func newVMSessionListCommand() *cobra.Command {
|
func newVMSessionListCommand() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "list <id-or-name>",
|
Use: "list <id-or-name>",
|
||||||
Short: "List managed guest commands for a VM",
|
Short: "List managed guest commands for a VM",
|
||||||
Args: exactArgsUsage(1, "usage: banger vm session list <id-or-name>"),
|
Args: exactArgsUsage(1, "usage: banger vm session list <id-or-name>"),
|
||||||
|
ValidArgsFunction: completeVMNameOnlyAtPos0,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
layout, _, err := ensureDaemon(cmd.Context())
|
layout, _, err := ensureDaemon(cmd.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -1360,9 +1370,10 @@ func newVMSessionListCommand() *cobra.Command {
|
||||||
|
|
||||||
func newVMSessionShowCommand() *cobra.Command {
|
func newVMSessionShowCommand() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "show <id-or-name> <session>",
|
Use: "show <id-or-name> <session>",
|
||||||
Short: "Show managed guest command details",
|
Short: "Show managed guest command details",
|
||||||
Args: exactArgsUsage(2, "usage: banger vm session show <id-or-name> <session>"),
|
Args: exactArgsUsage(2, "usage: banger vm session show <id-or-name> <session>"),
|
||||||
|
ValidArgsFunction: completeSessionNames,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
layout, _, err := ensureDaemon(cmd.Context())
|
layout, _, err := ensureDaemon(cmd.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -1381,9 +1392,10 @@ func newVMSessionLogsCommand() *cobra.Command {
|
||||||
var stream string
|
var stream string
|
||||||
var tailLines int
|
var tailLines int
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "logs <id-or-name> <session>",
|
Use: "logs <id-or-name> <session>",
|
||||||
Short: "Show stdout or stderr for a guest 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>"),
|
Args: exactArgsUsage(2, "usage: banger vm session logs [--stream stdout|stderr] [-n LINES] <id-or-name> <session>"),
|
||||||
|
ValidArgsFunction: completeSessionNames,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
layout, _, err := ensureDaemon(cmd.Context())
|
layout, _, err := ensureDaemon(cmd.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -1404,9 +1416,10 @@ func newVMSessionLogsCommand() *cobra.Command {
|
||||||
|
|
||||||
func newVMSessionStopCommand() *cobra.Command {
|
func newVMSessionStopCommand() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "stop <id-or-name> <session>",
|
Use: "stop <id-or-name> <session>",
|
||||||
Short: "Send SIGTERM to a guest session",
|
Short: "Send SIGTERM to a guest session",
|
||||||
Args: exactArgsUsage(2, "usage: banger vm session stop <id-or-name> <session>"),
|
Args: exactArgsUsage(2, "usage: banger vm session stop <id-or-name> <session>"),
|
||||||
|
ValidArgsFunction: completeSessionNames,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
layout, _, err := ensureDaemon(cmd.Context())
|
layout, _, err := ensureDaemon(cmd.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -1423,9 +1436,10 @@ func newVMSessionStopCommand() *cobra.Command {
|
||||||
|
|
||||||
func newVMSessionKillCommand() *cobra.Command {
|
func newVMSessionKillCommand() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "kill <id-or-name> <session>",
|
Use: "kill <id-or-name> <session>",
|
||||||
Short: "Send SIGKILL to a guest session",
|
Short: "Send SIGKILL to a guest session",
|
||||||
Args: exactArgsUsage(2, "usage: banger vm session kill <id-or-name> <session>"),
|
Args: exactArgsUsage(2, "usage: banger vm session kill <id-or-name> <session>"),
|
||||||
|
ValidArgsFunction: completeSessionNames,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
layout, _, err := ensureDaemon(cmd.Context())
|
layout, _, err := ensureDaemon(cmd.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -1442,10 +1456,11 @@ func newVMSessionKillCommand() *cobra.Command {
|
||||||
|
|
||||||
func newVMSessionAttachCommand() *cobra.Command {
|
func newVMSessionAttachCommand() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "attach <id-or-name> <session>",
|
Use: "attach <id-or-name> <session>",
|
||||||
Short: "Attach local stdio to an attachable guest 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.",
|
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>"),
|
Args: exactArgsUsage(2, "usage: banger vm session attach <id-or-name> <session>"),
|
||||||
|
ValidArgsFunction: completeSessionNames,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
layout, _, err := ensureDaemon(cmd.Context())
|
layout, _, err := ensureDaemon(cmd.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -1467,10 +1482,11 @@ func newVMSessionAttachCommand() *cobra.Command {
|
||||||
func newVMSessionSendCommand() *cobra.Command {
|
func newVMSessionSendCommand() *cobra.Command {
|
||||||
var message string
|
var message string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "send <id-or-name> <session>",
|
Use: "send <id-or-name> <session>",
|
||||||
Short: "Write bytes to a running guest session's stdin pipe",
|
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.",
|
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>']"),
|
Args: exactArgsUsage(2, "usage: banger vm session send <id-or-name> <session> [--message '<json>']"),
|
||||||
|
ValidArgsFunction: completeSessionNames,
|
||||||
Example: strings.TrimSpace(`
|
Example: strings.TrimSpace(`
|
||||||
banger vm session send devbox planner --message '{"type":"abort"}'
|
banger vm session send devbox planner --message '{"type":"abort"}'
|
||||||
banger vm session send devbox planner --message '{"type":"steer","message":"Focus on src/"}'
|
banger vm session send devbox planner --message '{"type":"steer","message":"Focus on src/"}'
|
||||||
|
|
@ -1628,9 +1644,10 @@ func streamGuestSessionAttachInput(conn net.Conn, stdin io.Reader) error {
|
||||||
func newVMLogsCommand() *cobra.Command {
|
func newVMLogsCommand() *cobra.Command {
|
||||||
var follow bool
|
var follow bool
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "logs <id-or-name>",
|
Use: "logs <id-or-name>",
|
||||||
Short: "Show VM logs",
|
Short: "Show VM logs",
|
||||||
Args: exactArgsUsage(1, "usage: banger vm logs [-f] <id-or-name>"),
|
Args: exactArgsUsage(1, "usage: banger vm logs [-f] <id-or-name>"),
|
||||||
|
ValidArgsFunction: completeVMNameOnlyAtPos0,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
layout, _, err := ensureDaemon(cmd.Context())
|
layout, _, err := ensureDaemon(cmd.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -1652,9 +1669,10 @@ func newVMLogsCommand() *cobra.Command {
|
||||||
|
|
||||||
func newVMStatsCommand() *cobra.Command {
|
func newVMStatsCommand() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "stats <id-or-name>",
|
Use: "stats <id-or-name>",
|
||||||
Short: "Show VM stats",
|
Short: "Show VM stats",
|
||||||
Args: exactArgsUsage(1, "usage: banger vm stats <id-or-name>"),
|
Args: exactArgsUsage(1, "usage: banger vm stats <id-or-name>"),
|
||||||
|
ValidArgsFunction: completeVMNameOnlyAtPos0,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
layout, _, err := ensureDaemon(cmd.Context())
|
layout, _, err := ensureDaemon(cmd.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -1671,9 +1689,10 @@ func newVMStatsCommand() *cobra.Command {
|
||||||
|
|
||||||
func newVMPortsCommand() *cobra.Command {
|
func newVMPortsCommand() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "ports <id-or-name>",
|
Use: "ports <id-or-name>",
|
||||||
Short: "Show host-reachable listening guest ports",
|
Short: "Show host-reachable listening guest ports",
|
||||||
Args: exactArgsUsage(1, "usage: banger vm ports <id-or-name>"),
|
Args: exactArgsUsage(1, "usage: banger vm ports <id-or-name>"),
|
||||||
|
ValidArgsFunction: completeVMNameOnlyAtPos0,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
layout, _, err := ensureDaemon(cmd.Context())
|
layout, _, err := ensureDaemon(cmd.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -1740,6 +1759,7 @@ func newImageRegisterCommand() *cobra.Command {
|
||||||
cmd.Flags().StringVar(¶ms.ModulesDir, "modules", "", "modules dir")
|
cmd.Flags().StringVar(¶ms.ModulesDir, "modules", "", "modules dir")
|
||||||
cmd.Flags().StringVar(¶ms.KernelRef, "kernel-ref", "", "name of a cataloged kernel (see 'banger kernel list')")
|
cmd.Flags().StringVar(¶ms.KernelRef, "kernel-ref", "", "name of a cataloged kernel (see 'banger kernel list')")
|
||||||
cmd.Flags().BoolVar(¶ms.Docker, "docker", false, "mark image as docker-prepared")
|
cmd.Flags().BoolVar(¶ms.Docker, "docker", false, "mark image as docker-prepared")
|
||||||
|
_ = cmd.RegisterFlagCompletionFunc("kernel-ref", completeKernelNames)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1813,14 +1833,16 @@ subcommand lands).
|
||||||
cmd.Flags().StringVar(¶ms.ModulesDir, "modules", "", "modules dir")
|
cmd.Flags().StringVar(¶ms.ModulesDir, "modules", "", "modules dir")
|
||||||
cmd.Flags().StringVar(¶ms.KernelRef, "kernel-ref", "", "name of a cataloged kernel (see 'banger kernel list')")
|
cmd.Flags().StringVar(¶ms.KernelRef, "kernel-ref", "", "name of a cataloged kernel (see 'banger kernel list')")
|
||||||
cmd.Flags().StringVar(&sizeRaw, "size", "", "ext4 image size (e.g. 4GiB); defaults to content + 25%, min 1GiB")
|
cmd.Flags().StringVar(&sizeRaw, "size", "", "ext4 image size (e.g. 4GiB); defaults to content + 25%, min 1GiB")
|
||||||
|
_ = cmd.RegisterFlagCompletionFunc("kernel-ref", completeKernelNames)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func newImagePromoteCommand() *cobra.Command {
|
func newImagePromoteCommand() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "promote <id-or-name>",
|
Use: "promote <id-or-name>",
|
||||||
Short: "Promote an unmanaged image to a managed artifact",
|
Short: "Promote an unmanaged image to a managed artifact",
|
||||||
Args: exactArgsUsage(1, "usage: banger image promote <id-or-name>"),
|
Args: exactArgsUsage(1, "usage: banger image promote <id-or-name>"),
|
||||||
|
ValidArgsFunction: completeImageNameOnlyAtPos0,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if err := system.EnsureSudo(cmd.Context()); err != nil {
|
if err := system.EnsureSudo(cmd.Context()); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -1859,9 +1881,10 @@ func newImageListCommand() *cobra.Command {
|
||||||
|
|
||||||
func newImageShowCommand() *cobra.Command {
|
func newImageShowCommand() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "show <id-or-name>",
|
Use: "show <id-or-name>",
|
||||||
Short: "Show image details",
|
Short: "Show image details",
|
||||||
Args: exactArgsUsage(1, "usage: banger image show <id-or-name>"),
|
Args: exactArgsUsage(1, "usage: banger image show <id-or-name>"),
|
||||||
|
ValidArgsFunction: completeImageNameOnlyAtPos0,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
layout, _, err := ensureDaemon(cmd.Context())
|
layout, _, err := ensureDaemon(cmd.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -1878,9 +1901,10 @@ func newImageShowCommand() *cobra.Command {
|
||||||
|
|
||||||
func newImageDeleteCommand() *cobra.Command {
|
func newImageDeleteCommand() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "delete <id-or-name>",
|
Use: "delete <id-or-name>",
|
||||||
Short: "Delete an image",
|
Short: "Delete an image",
|
||||||
Args: exactArgsUsage(1, "usage: banger image delete <id-or-name>"),
|
Args: exactArgsUsage(1, "usage: banger image delete <id-or-name>"),
|
||||||
|
ValidArgsFunction: completeImageNameOnlyAtPos0,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if err := system.EnsureSudo(cmd.Context()); err != nil {
|
if err := system.EnsureSudo(cmd.Context()); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -2006,9 +2030,10 @@ func newKernelListCommand() *cobra.Command {
|
||||||
|
|
||||||
func newKernelShowCommand() *cobra.Command {
|
func newKernelShowCommand() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "show <name>",
|
Use: "show <name>",
|
||||||
Short: "Show kernel catalog entry details",
|
Short: "Show kernel catalog entry details",
|
||||||
Args: exactArgsUsage(1, "usage: banger kernel show <name>"),
|
Args: exactArgsUsage(1, "usage: banger kernel show <name>"),
|
||||||
|
ValidArgsFunction: completeKernelNameOnlyAtPos0,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
layout, _, err := ensureDaemon(cmd.Context())
|
layout, _, err := ensureDaemon(cmd.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -2025,10 +2050,11 @@ func newKernelShowCommand() *cobra.Command {
|
||||||
|
|
||||||
func newKernelRmCommand() *cobra.Command {
|
func newKernelRmCommand() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "rm <name>",
|
Use: "rm <name>",
|
||||||
Aliases: []string{"remove", "delete"},
|
Aliases: []string{"remove", "delete"},
|
||||||
Short: "Remove a kernel catalog entry",
|
Short: "Remove a kernel catalog entry",
|
||||||
Args: exactArgsUsage(1, "usage: banger kernel rm <name>"),
|
Args: exactArgsUsage(1, "usage: banger kernel rm <name>"),
|
||||||
|
ValidArgsFunction: completeKernelNameOnlyAtPos0,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
layout, _, err := ensureDaemon(cmd.Context())
|
layout, _, err := ensureDaemon(cmd.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
202
internal/cli/completion.go
Normal file
202
internal/cli/completion.go
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"banger/internal/api"
|
||||||
|
"banger/internal/paths"
|
||||||
|
"banger/internal/rpc"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Completion helpers. Design notes:
|
||||||
|
//
|
||||||
|
// - Never auto-start the daemon. If it isn't running, return no
|
||||||
|
// suggestions + NoFileComp so the shell doesn't fall back to file
|
||||||
|
// completion (there are no local files that would plausibly match a
|
||||||
|
// VM or image name).
|
||||||
|
// - Filter out names already in args — avoids suggesting the same VM
|
||||||
|
// twice on variadic commands like `vm stop a b <tab>`.
|
||||||
|
// - Fail silently. Completion is advisory; any error path returns an
|
||||||
|
// empty suggestion list rather than propagating to the user.
|
||||||
|
|
||||||
|
// completionListerFunc is the seam used by tests to avoid touching a
|
||||||
|
// real daemon socket.
|
||||||
|
var completionListerFunc = func(ctx context.Context, socketPath, method string) ([]string, error) {
|
||||||
|
switch method {
|
||||||
|
case "vm.list":
|
||||||
|
result, err := rpc.Call[api.VMListResult](ctx, socketPath, method, api.Empty{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(result.VMs))
|
||||||
|
for _, vm := range result.VMs {
|
||||||
|
if vm.Name != "" {
|
||||||
|
names = append(names, vm.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names, nil
|
||||||
|
case "image.list":
|
||||||
|
result, err := rpc.Call[api.ImageListResult](ctx, socketPath, method, api.Empty{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(result.Images))
|
||||||
|
for _, image := range result.Images {
|
||||||
|
if image.Name != "" {
|
||||||
|
names = append(names, image.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names, nil
|
||||||
|
case "kernel.list":
|
||||||
|
result, err := rpc.Call[api.KernelListResult](ctx, socketPath, method, api.Empty{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(result.Entries))
|
||||||
|
for _, entry := range result.Entries {
|
||||||
|
if entry.Name != "" {
|
||||||
|
names = append(names, entry.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// completionSessionListerFunc is the seam for guest-session name lookups
|
||||||
|
// scoped to a VM.
|
||||||
|
var completionSessionListerFunc = func(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.
|
||||||
|
func daemonSocketForCompletion(ctx context.Context) (string, bool) {
|
||||||
|
layout, err := paths.Resolve()
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if _, err := daemonPingFunc(ctx, layout.SocketPath); err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return layout.SocketPath, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterPrefix returns the subset of candidates starting with toComplete
|
||||||
|
// that aren't in exclude. Comparison is case-sensitive because VM/image
|
||||||
|
// names preserve case.
|
||||||
|
func filterPrefix(candidates, exclude []string, toComplete string) []string {
|
||||||
|
excludeSet := make(map[string]struct{}, len(exclude))
|
||||||
|
for _, e := range exclude {
|
||||||
|
excludeSet[e] = struct{}{}
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(candidates))
|
||||||
|
for _, c := range candidates {
|
||||||
|
if _, skip := excludeSet[c]; skip {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if toComplete == "" || hasPrefix(c, toComplete) {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasPrefix(s, prefix string) bool {
|
||||||
|
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
func completeVMNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
|
socket, ok := daemonSocketForCompletion(cmd.Context())
|
||||||
|
if !ok {
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
names, err := completionListerFunc(cmd.Context(), socket, "vm.list")
|
||||||
|
if err != nil {
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
return filterPrefix(names, args, toComplete), cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
|
||||||
|
// completeVMNameOnlyAtPos0 restricts VM-name completion to the first
|
||||||
|
// positional argument. Used by commands like `vm ssh <vm> [ssh args...]`
|
||||||
|
// where args after pos 0 are free-form.
|
||||||
|
func completeVMNameOnlyAtPos0(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
|
if len(args) > 0 {
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
return completeVMNames(cmd, args, toComplete)
|
||||||
|
}
|
||||||
|
|
||||||
|
func completeImageNameOnlyAtPos0(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
|
if len(args) > 0 {
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
return completeImageNames(cmd, args, toComplete)
|
||||||
|
}
|
||||||
|
|
||||||
|
func completeKernelNameOnlyAtPos0(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
|
if len(args) > 0 {
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
return completeKernelNames(cmd, args, toComplete)
|
||||||
|
}
|
||||||
|
|
||||||
|
func completeImageNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
|
socket, ok := daemonSocketForCompletion(cmd.Context())
|
||||||
|
if !ok {
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
names, err := completionListerFunc(cmd.Context(), socket, "image.list")
|
||||||
|
if err != nil {
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
return filterPrefix(names, args, toComplete), cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
|
||||||
|
func completeKernelNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
|
socket, ok := daemonSocketForCompletion(cmd.Context())
|
||||||
|
if !ok {
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
names, err := completionListerFunc(cmd.Context(), socket, "kernel.list")
|
||||||
|
if err != nil {
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
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 completeSessionNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
|
switch len(args) {
|
||||||
|
case 0:
|
||||||
|
return completeVMNames(cmd, args, toComplete)
|
||||||
|
case 1:
|
||||||
|
socket, ok := daemonSocketForCompletion(cmd.Context())
|
||||||
|
if !ok {
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
names, err := completionSessionListerFunc(cmd.Context(), socket, args[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
return filterPrefix(names, nil, toComplete), cobra.ShellCompDirectiveNoFileComp
|
||||||
|
default:
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
}
|
||||||
230
internal/cli/completion_test.go
Normal file
230
internal/cli/completion_test.go
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"banger/internal/api"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stubCompletionSeams installs test doubles for the daemon ping + lister
|
||||||
|
// seams and restores the originals on cleanup. Tests opt into the
|
||||||
|
// sub-functions they actually need.
|
||||||
|
func stubCompletionSeams(
|
||||||
|
t *testing.T,
|
||||||
|
pingErr error,
|
||||||
|
names map[string][]string,
|
||||||
|
listErr error,
|
||||||
|
sessions map[string][]string,
|
||||||
|
sessionErr error,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
origPing := daemonPingFunc
|
||||||
|
origLister := completionListerFunc
|
||||||
|
origSessionLister := completionSessionListerFunc
|
||||||
|
t.Cleanup(func() {
|
||||||
|
daemonPingFunc = origPing
|
||||||
|
completionListerFunc = origLister
|
||||||
|
completionSessionListerFunc = origSessionLister
|
||||||
|
})
|
||||||
|
|
||||||
|
daemonPingFunc = func(ctx context.Context, socketPath string) (api.PingResult, error) {
|
||||||
|
if pingErr != nil {
|
||||||
|
return api.PingResult{}, pingErr
|
||||||
|
}
|
||||||
|
return api.PingResult{}, nil
|
||||||
|
}
|
||||||
|
completionListerFunc = func(ctx context.Context, socketPath, method string) ([]string, error) {
|
||||||
|
if listErr != nil {
|
||||||
|
return nil, listErr
|
||||||
|
}
|
||||||
|
return names[method], nil
|
||||||
|
}
|
||||||
|
completionSessionListerFunc = func(ctx context.Context, socketPath, vmIDOrName string) ([]string, error) {
|
||||||
|
if sessionErr != nil {
|
||||||
|
return nil, sessionErr
|
||||||
|
}
|
||||||
|
return sessions[vmIDOrName], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterPrefix(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
candidates []string
|
||||||
|
exclude []string
|
||||||
|
prefix string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"no filter", []string{"a", "b"}, nil, "", []string{"a", "b"}},
|
||||||
|
{"prefix match", []string{"apple", "banana", "apricot"}, nil, "ap", []string{"apple", "apricot"}},
|
||||||
|
{"exclude already entered", []string{"a", "b", "c"}, []string{"b"}, "", []string{"a", "c"}},
|
||||||
|
{"prefix + exclude", []string{"alpha", "avocado", "banana"}, []string{"alpha"}, "a", []string{"avocado"}},
|
||||||
|
{"exact case sensitive", []string{"VM", "vm"}, nil, "v", []string{"vm"}},
|
||||||
|
{"empty candidates", nil, nil, "any", nil},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := filterPrefix(tc.candidates, tc.exclude, tc.prefix)
|
||||||
|
if !reflect.DeepEqual(got, tc.want) {
|
||||||
|
// Allow nil == empty
|
||||||
|
if len(got) == 0 && len(tc.want) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Errorf("got %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCmdWithCtx() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{Use: "test"}
|
||||||
|
cmd.SetContext(context.Background())
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteVMNamesHappyPath(t *testing.T) {
|
||||||
|
stubCompletionSeams(t, nil, map[string][]string{"vm.list": {"alpha", "beta", "gamma"}}, nil, nil, nil)
|
||||||
|
|
||||||
|
got, directive := completeVMNames(testCmdWithCtx(), nil, "")
|
||||||
|
if directive != cobra.ShellCompDirectiveNoFileComp {
|
||||||
|
t.Errorf("directive = %d, want NoFileComp", directive)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, []string{"alpha", "beta", "gamma"}) {
|
||||||
|
t.Errorf("got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteVMNamesDaemonDown(t *testing.T) {
|
||||||
|
stubCompletionSeams(t, errors.New("connection refused"), nil, nil, nil, nil)
|
||||||
|
|
||||||
|
got, directive := completeVMNames(testCmdWithCtx(), nil, "")
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Errorf("daemon-down should return no suggestions, got %v", got)
|
||||||
|
}
|
||||||
|
if directive != cobra.ShellCompDirectiveNoFileComp {
|
||||||
|
t.Errorf("directive = %d, want NoFileComp", directive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteVMNamesRPCError(t *testing.T) {
|
||||||
|
stubCompletionSeams(t, nil, nil, errors.New("rpc failed"), nil, nil)
|
||||||
|
|
||||||
|
got, _ := completeVMNames(testCmdWithCtx(), nil, "")
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Errorf("rpc error should return no suggestions, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteVMNamesExcludesAlreadyEntered(t *testing.T) {
|
||||||
|
stubCompletionSeams(t, nil, map[string][]string{"vm.list": {"alpha", "beta", "gamma"}}, nil, nil, nil)
|
||||||
|
|
||||||
|
got, _ := completeVMNames(testCmdWithCtx(), []string{"alpha"}, "")
|
||||||
|
want := []string{"beta", "gamma"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("got %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteVMNamesPrefixFilter(t *testing.T) {
|
||||||
|
stubCompletionSeams(t, nil, map[string][]string{"vm.list": {"alpha", "beta", "alphabet"}}, nil, nil, nil)
|
||||||
|
|
||||||
|
got, _ := completeVMNames(testCmdWithCtx(), nil, "alp")
|
||||||
|
want := []string{"alpha", "alphabet"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("got %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteVMNameOnlyAtPos0(t *testing.T) {
|
||||||
|
stubCompletionSeams(t, nil, map[string][]string{"vm.list": {"alpha"}}, nil, nil, nil)
|
||||||
|
|
||||||
|
atPos0, _ := completeVMNameOnlyAtPos0(testCmdWithCtx(), nil, "")
|
||||||
|
if len(atPos0) != 1 || atPos0[0] != "alpha" {
|
||||||
|
t.Errorf("pos 0: got %v", atPos0)
|
||||||
|
}
|
||||||
|
|
||||||
|
atPos1, _ := completeVMNameOnlyAtPos0(testCmdWithCtx(), []string{"alpha"}, "")
|
||||||
|
if len(atPos1) != 0 {
|
||||||
|
t.Errorf("pos 1+ should be silent, got %v", atPos1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteImageNames(t *testing.T) {
|
||||||
|
stubCompletionSeams(t, nil, map[string][]string{"image.list": {"debian-bookworm", "alpine"}}, nil, nil, nil)
|
||||||
|
|
||||||
|
got, _ := completeImageNames(testCmdWithCtx(), nil, "")
|
||||||
|
if !reflect.DeepEqual(got, []string{"debian-bookworm", "alpine"}) {
|
||||||
|
t.Errorf("got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteKernelNames(t *testing.T) {
|
||||||
|
stubCompletionSeams(t, nil, map[string][]string{"kernel.list": {"generic-6.12"}}, nil, nil, nil)
|
||||||
|
|
||||||
|
got, _ := completeKernelNames(testCmdWithCtx(), nil, "")
|
||||||
|
if len(got) != 1 || got[0] != "generic-6.12" {
|
||||||
|
t.Errorf("got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteImageNameOnlyAtPos0SilentAfterFirst(t *testing.T) {
|
||||||
|
stubCompletionSeams(t, nil, map[string][]string{"image.list": {"alpine"}}, nil, nil, nil)
|
||||||
|
|
||||||
|
after, _ := completeImageNameOnlyAtPos0(testCmdWithCtx(), []string{"alpine"}, "")
|
||||||
|
if len(after) != 0 {
|
||||||
|
t.Errorf("expected silence at pos 1+, got %v", after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteSessionNames(t *testing.T) {
|
||||||
|
stubCompletionSeams(
|
||||||
|
t,
|
||||||
|
nil,
|
||||||
|
map[string][]string{"vm.list": {"devbox"}},
|
||||||
|
nil,
|
||||||
|
map[string][]string{"devbox": {"planner", "worker"}},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Position 0 → VMs.
|
||||||
|
vms, _ := 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, _ := completeSessionNames(testCmdWithCtx(), []string{"devbox"}, "")
|
||||||
|
if !reflect.DeepEqual(sessions, []string{"planner", "worker"}) {
|
||||||
|
t.Errorf("pos 1: got %v", sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position 1 with prefix filter.
|
||||||
|
filtered, _ := completeSessionNames(testCmdWithCtx(), []string{"devbox"}, "wor")
|
||||||
|
if len(filtered) != 1 || filtered[0] != "worker" {
|
||||||
|
t.Errorf("pos 1 prefix: got %v", filtered)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position 2+ silent.
|
||||||
|
past, _ := completeSessionNames(testCmdWithCtx(), []string{"devbox", "planner"}, "")
|
||||||
|
if len(past) != 0 {
|
||||||
|
t.Errorf("pos 2+: got %v", past)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteSessionNamesDaemonDown(t *testing.T) {
|
||||||
|
stubCompletionSeams(t, errors.New("down"), nil, nil, nil, nil)
|
||||||
|
|
||||||
|
got, directive := 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue