diff --git a/internal/cli/banger.go b/internal/cli/banger.go index b5fe81d..49aa74f 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -150,7 +150,7 @@ func NewBangerCommand() *cobra.Command { RunE: helpNoArgs, } root.CompletionOptions.DisableDefaultCmd = true - root.AddCommand(newDaemonCommand(), newDoctorCommand(), newImageCommand(), newInternalCommand(), newVersionCommand(), newVMCommand()) + root.AddCommand(newDaemonCommand(), newDoctorCommand(), newImageCommand(), newInternalCommand(), newVersionCommand(), newPSCommand(), newVMCommand()) return root } @@ -626,27 +626,79 @@ func newVMCreateCommand() *cobra.Command { return cmd } +type vmListOptions struct { + showAll bool + latest bool + quiet bool +} + +func newPSCommand() *cobra.Command { + return newVMListLikeCommand("ps", nil, "usage: banger ps") +} + func newVMListCommand() *cobra.Command { - return &cobra.Command{ - Use: "list", - Short: "List VMs", - Args: noArgsUsage("usage: banger vm list"), + return newVMListLikeCommand("list", []string{"ls", "ps"}, "usage: banger vm list") +} + +func newVMListLikeCommand(use string, aliases []string, usage string) *cobra.Command { + var opts vmListOptions + cmd := &cobra.Command{ + Use: use, + Aliases: aliases, + Short: "List VMs", + Args: noArgsUsage(usage), RunE: func(cmd *cobra.Command, args []string) error { - layout, _, err := ensureDaemon(cmd.Context()) - if err != nil { - return err - } - result, err := rpc.Call[api.VMListResult](cmd.Context(), layout.SocketPath, "vm.list", api.Empty{}) - if err != nil { - return err - } - images, err := rpc.Call[api.ImageListResult](cmd.Context(), layout.SocketPath, "image.list", api.Empty{}) - if err != nil { - return err - } - return printVMListTable(cmd.OutOrStdout(), result.VMs, imageNameIndex(images.Images)) + return runVMList(cmd, opts) }, } + cmd.Flags().BoolVarP(&opts.showAll, "all", "a", false, "show all VMs") + cmd.Flags().BoolVarP(&opts.latest, "latest", "l", false, "show only the latest VM") + cmd.Flags().BoolVarP(&opts.quiet, "quiet", "q", false, "only show VM IDs") + return cmd +} + +func runVMList(cmd *cobra.Command, opts vmListOptions) error { + layout, _, err := ensureDaemon(cmd.Context()) + if err != nil { + return err + } + result, err := rpc.Call[api.VMListResult](cmd.Context(), layout.SocketPath, "vm.list", api.Empty{}) + if err != nil { + return err + } + vms := selectVMListVMs(result.VMs, opts.showAll, opts.latest) + if opts.quiet { + return printVMIDList(cmd.OutOrStdout(), vms) + } + images, err := rpc.Call[api.ImageListResult](cmd.Context(), layout.SocketPath, "image.list", api.Empty{}) + if err != nil { + return err + } + return printVMListTable(cmd.OutOrStdout(), vms, imageNameIndex(images.Images)) +} + +func selectVMListVMs(vms []model.VMRecord, showAll, latest bool) []model.VMRecord { + filtered := make([]model.VMRecord, 0, len(vms)) + for _, vm := range vms { + if !showAll && vm.State != model.VMStateRunning { + continue + } + filtered = append(filtered, vm) + } + if !latest || len(filtered) <= 1 { + return filtered + } + latestVM := filtered[0] + for _, vm := range filtered[1:] { + if vm.CreatedAt.After(latestVM.CreatedAt) { + latestVM = vm + continue + } + if vm.CreatedAt.Equal(latestVM.CreatedAt) && vm.UpdatedAt.After(latestVM.UpdatedAt) { + latestVM = vm + } + } + return []model.VMRecord{latestVM} } func newVMShowCommand() *cobra.Command { @@ -2054,6 +2106,15 @@ func printVMSummary(out anyWriter, vm model.VMRecord) error { return err } +func printVMIDList(out anyWriter, vms []model.VMRecord) error { + for _, vm := range vms { + if _, err := fmt.Fprintln(out, vm.ID); err != nil { + return err + } + } + return nil +} + func printVMListTable(out anyWriter, vms []model.VMRecord, imageNames map[string]string) error { w := tabwriter.NewWriter(out, 0, 8, 2, ' ', 0) if _, err := fmt.Fprintln(w, "ID\tNAME\tSTATE\tIMAGE\tIP\tVCPU\tMEM\tDISK\tCREATED"); err != nil { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 83c6b15..d625555 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -27,7 +27,7 @@ func TestNewBangerCommandHasExpectedSubcommands(t *testing.T) { for _, sub := range cmd.Commands() { names = append(names, sub.Name()) } - want := []string{"daemon", "doctor", "image", "internal", "version", "vm"} + want := []string{"daemon", "doctor", "image", "internal", "ps", "version", "vm"} if !reflect.DeepEqual(names, want) { t.Fatalf("subcommands = %v, want %v", names, want) } @@ -155,6 +155,47 @@ func TestInternalPackagesCommandSupportsAlpine(t *testing.T) { } } +func TestPSAndVMListAliasesAndFlagsExist(t *testing.T) { + root := NewBangerCommand() + ps, _, err := root.Find([]string{"ps"}) + if err != nil { + t.Fatalf("find ps: %v", err) + } + for _, flagName := range []string{"all", "latest", "quiet"} { + if ps.Flags().Lookup(flagName) == nil { + t.Fatalf("missing ps flag %q", flagName) + } + } + vm, _, err := root.Find([]string{"vm"}) + if err != nil { + t.Fatalf("find vm: %v", err) + } + list, _, err := vm.Find([]string{"list"}) + if err != nil { + t.Fatalf("find list: %v", err) + } + if _, _, err := vm.Find([]string{"ls"}); err != nil { + t.Fatalf("find ls alias: %v", err) + } + if _, _, err := vm.Find([]string{"ps"}); err != nil { + t.Fatalf("find ps alias: %v", err) + } + for _, flagName := range []string{"all", "latest", "quiet"} { + if list.Flags().Lookup(flagName) == nil { + t.Fatalf("missing vm list flag %q", flagName) + } + } +} + +func TestPSCommandRejectsArgs(t *testing.T) { + cmd := NewBangerCommand() + cmd.SetArgs([]string{"ps", "extra"}) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "usage: banger ps") { + t.Fatalf("Execute() error = %v, want ps usage error", err) + } +} + func TestVMCreateFlagsExist(t *testing.T) { root := NewBangerCommand() vm, _, err := root.Find([]string{"vm"}) @@ -595,6 +636,49 @@ func TestPrintImageListTableShowsRootfsSizes(t *testing.T) { } } +func TestSelectVMListVMsDefaultsToRunning(t *testing.T) { + now := time.Now() + vms := []model.VMRecord{ + {ID: "running-1", State: model.VMStateRunning, CreatedAt: now.Add(-3 * time.Hour)}, + {ID: "stopped-1", State: model.VMStateStopped, CreatedAt: now.Add(-2 * time.Hour)}, + {ID: "running-2", State: model.VMStateRunning, CreatedAt: now.Add(-1 * time.Hour)}, + } + got := selectVMListVMs(vms, false, false) + if len(got) != 2 || got[0].ID != "running-1" || got[1].ID != "running-2" { + t.Fatalf("selectVMListVMs() = %#v, want only running VMs in original order", got) + } +} + +func TestSelectVMListVMsLatestUsesFilteredSet(t *testing.T) { + now := time.Now() + vms := []model.VMRecord{ + {ID: "running-old", State: model.VMStateRunning, CreatedAt: now.Add(-3 * time.Hour)}, + {ID: "stopped-new", State: model.VMStateStopped, CreatedAt: now.Add(-30 * time.Minute)}, + {ID: "running-new", State: model.VMStateRunning, CreatedAt: now.Add(-1 * time.Hour)}, + } + got := selectVMListVMs(vms, false, true) + if len(got) != 1 || got[0].ID != "running-new" { + t.Fatalf("selectVMListVMs(default latest) = %#v, want latest running VM", got) + } + got = selectVMListVMs(vms, true, true) + if len(got) != 1 || got[0].ID != "stopped-new" { + t.Fatalf("selectVMListVMs(all latest) = %#v, want latest VM across all states", got) + } +} + +func TestPrintVMIDListShowsFullIDs(t *testing.T) { + var out bytes.Buffer + err := printVMIDList(&out, []model.VMRecord{{ID: "0123456789abcdef0123456789abcdef"}, {ID: "fedcba9876543210fedcba9876543210"}}) + if err != nil { + t.Fatalf("printVMIDList() error = %v", err) + } + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + want := []string{"0123456789abcdef0123456789abcdef", "fedcba9876543210fedcba9876543210"} + if !reflect.DeepEqual(lines, want) { + t.Fatalf("lines = %v, want %v", lines, want) + } +} + func TestPrintVMListTableShowsImageNames(t *testing.T) { var out bytes.Buffer err := printVMListTable(&out, []model.VMRecord{