Frontier models tend to discover a CLI by running --help, scanning the Long description, and inferring the dominant workflow from the examples. Today's banger help reads like a man page index — every verb has a one-line Short and nothing else. This rewrites the groups (banger, vm, vm workspace, image, kernel, system, ssh-config) so each landing page answers "what is this for, what's the 80% command, what comes next" in three to ten lines, with runnable examples. Also disambiguates the near-twin lifecycle commands so a model reading the subcommand index can tell stop/kill/delete apart at a glance: start Start a stopped VM stop Stop a running VM gracefully restart Stop then start a VM kill Force-kill a VM (use when 'vm stop' hangs) delete Stop a VM and remove its disks (irreversible) vm create / vm ssh / vm logs / vm show pick up Long descriptions and examples for the same reason. No behaviour changes; help text only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
188 lines
4.9 KiB
Go
188 lines
4.9 KiB
Go
package cli
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"banger/internal/api"
|
|
"banger/internal/buildinfo"
|
|
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// NewBangerCommand builds the top-level cobra tree with production
|
|
// defaults wired into the dependency struct. Tests reach into the
|
|
// package directly — see newRootCommand + defaultDeps.
|
|
func NewBangerCommand() *cobra.Command {
|
|
return defaultDeps().newRootCommand()
|
|
}
|
|
|
|
func (d *deps) newRootCommand() *cobra.Command {
|
|
root := &cobra.Command{
|
|
Use: "banger",
|
|
Short: "Run development sandboxes as Firecracker microVMs",
|
|
Long: strings.TrimSpace(`
|
|
banger runs disposable development sandboxes as Firecracker microVMs.
|
|
Each sandbox boots in a few seconds, gets its own root filesystem and
|
|
network, and exits on demand.
|
|
|
|
The most common workflow is one command:
|
|
|
|
banger vm run bare sandbox, drops into ssh
|
|
banger vm run ./repo ships a repo into /root/repo, drops into ssh
|
|
banger vm run ./repo -- make test ships a repo, runs the command, exits with its status
|
|
|
|
For a longer-lived VM, use 'banger vm create' to provision and
|
|
'banger vm ssh <name>' to attach. 'banger ps' lists running VMs;
|
|
'banger vm list --all' shows stopped ones too.
|
|
|
|
First-time setup, in order:
|
|
sudo banger system install install the systemd services
|
|
banger doctor confirm the host is ready
|
|
banger image pull debian-bookworm fetch a default image
|
|
|
|
Run 'banger <command> --help' for any subcommand. Run 'banger doctor'
|
|
to diagnose host readiness problems.
|
|
`),
|
|
SilenceUsage: true,
|
|
SilenceErrors: true,
|
|
RunE: helpNoArgs,
|
|
}
|
|
root.AddCommand(
|
|
d.newDaemonCommand(),
|
|
d.newDoctorCommand(),
|
|
d.newImageCommand(),
|
|
d.newInternalCommand(),
|
|
d.newKernelCommand(),
|
|
newSSHConfigCommand(),
|
|
d.newSystemCommand(),
|
|
newVersionCommand(),
|
|
d.newPSCommand(),
|
|
d.newVMCommand(),
|
|
)
|
|
return root
|
|
}
|
|
|
|
func (d *deps) newDoctorCommand() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "doctor",
|
|
Short: "Check host and runtime readiness",
|
|
Long: strings.TrimSpace(`
|
|
Check that the host has everything banger needs to boot guests:
|
|
required tools (mkfs.ext4, debugfs, dmsetup, ip, iptables, ...), KVM
|
|
access, daemon reachability, and per-feature preflight (NAT, DNS
|
|
routing, work-disk seeding).
|
|
|
|
Run 'banger doctor':
|
|
- after 'banger system install' to confirm the install took
|
|
- after upgrading the host kernel or banger itself
|
|
- when 'banger vm run' fails with an unclear error
|
|
|
|
Exit code is non-zero if any check fails. Warnings are reported but
|
|
do not fail the run.
|
|
`),
|
|
Args: noArgsUsage("usage: banger doctor"),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
report, err := d.doctor(cmd.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := printDoctorReport(cmd.OutOrStdout(), report); err != nil {
|
|
return err
|
|
}
|
|
if report.HasFailures() {
|
|
return errors.New("doctor found failing checks")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func newVersionCommand() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "version",
|
|
Short: "Show banger build information",
|
|
Args: noArgsUsage("usage: banger version"),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
_, err := fmt.Fprint(cmd.OutOrStdout(), formatBuildInfoBlock(buildinfo.Current()))
|
|
return err
|
|
},
|
|
}
|
|
}
|
|
|
|
func helpNoArgs(cmd *cobra.Command, args []string) error {
|
|
if len(args) != 0 {
|
|
return fmt.Errorf("unknown arguments: %s", strings.Join(args, " "))
|
|
}
|
|
return cmd.Help()
|
|
}
|
|
|
|
func noArgsUsage(usage string) cobra.PositionalArgs {
|
|
return func(cmd *cobra.Command, args []string) error {
|
|
if len(args) != 0 {
|
|
return errors.New(usage)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func exactArgsUsage(n int, usage string) cobra.PositionalArgs {
|
|
return func(cmd *cobra.Command, args []string) error {
|
|
if len(args) != n {
|
|
return errors.New(usage)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func minArgsUsage(n int, usage string) cobra.PositionalArgs {
|
|
return func(cmd *cobra.Command, args []string) error {
|
|
if len(args) < n {
|
|
return errors.New(usage)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func maxArgsUsage(n int, usage string) cobra.PositionalArgs {
|
|
return func(cmd *cobra.Command, args []string) error {
|
|
if len(args) > n {
|
|
return errors.New(usage)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func shellQuote(value string) string {
|
|
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
|
|
}
|
|
|
|
func absolutizeImageRegisterPaths(params *api.ImageRegisterParams) error {
|
|
return absolutizePaths(
|
|
¶ms.RootfsPath,
|
|
¶ms.WorkSeedPath,
|
|
¶ms.KernelPath,
|
|
¶ms.InitrdPath,
|
|
¶ms.ModulesDir,
|
|
)
|
|
}
|
|
|
|
func absolutizePaths(values ...*string) error {
|
|
var err error
|
|
for _, value := range values {
|
|
if *value == "" || filepath.IsAbs(*value) {
|
|
continue
|
|
}
|
|
*value, err = filepath.Abs(*value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func formatBuildInfoBlock(info buildinfo.Info) string {
|
|
return fmt.Sprintf("version: %s\ncommit: %s\nbuilt_at: %s\n", info.Version, info.Commit, info.BuiltAt)
|
|
}
|