banger/internal/cli/banger.go
Thales Maciel 108f7a0600
ssh-config: make the ssh <name>.vm shortcut opt-in
Before this change, every daemon.Open() wrote a Host *.vm stanza into
~/.ssh/config in a marker-fenced block. That's a real footgun for users
who manage their SSH config declaratively (chezmoi, dotfiles, NixOS):
banger was mutating host state outside its own directory on every
daemon start, easy to miss and hard to audit.

New contract: the daemon only ever writes its own ssh_config file at
~/.config/banger/ssh_config. ~/.ssh/config is untouched unless the user
opts in. `banger vm ssh <name>` still works out of the box — the
shortcut only matters for plain `ssh sandbox.vm` from any terminal.

The opt-in surface is `banger ssh-config`:

  banger ssh-config              # prints path + include-line +
                                 # install/uninstall hints
  banger ssh-config --install    # adds `Include <bangerConfig>` to
                                 # ~/.ssh/config inside a marker-fenced
                                 # block; idempotent; migrates any
                                 # legacy inline Host *.vm block from
                                 # pre-opt-in builds
  banger ssh-config --uninstall  # removes the new Include block AND
                                 # any legacy inline block

Doctor gains a gentle warn-level note when banger's ssh_config exists
but the user hasn't wired it in — not a fail, since the shortcut is
convenience and `banger vm ssh` covers the essential case.

Tests cover: daemon writes banger file and does NOT touch ~/.ssh/config,
Install adds the block, Install is idempotent, Install migrates the
legacy inline block cleanly (removing it, preserving unrelated
entries, adding the new Include block), Uninstall removes both marker
variants, Uninstall is a no-op when ~/.ssh/config is absent, and
UserSSHIncludeInstalled detects both marker shapes.

README reframes the feature as optional convenience.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:57:26 -03:00

150 lines
3.4 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: "Manage development VMs and images",
SilenceUsage: true,
SilenceErrors: true,
RunE: helpNoArgs,
}
root.AddCommand(
d.newDaemonCommand(),
d.newDoctorCommand(),
d.newImageCommand(),
d.newInternalCommand(),
d.newKernelCommand(),
newSSHConfigCommand(),
newVersionCommand(),
d.newPSCommand(),
d.newVMCommand(),
)
return root
}
func (d *deps) newDoctorCommand() *cobra.Command {
return &cobra.Command{
Use: "doctor",
Short: "Check host and runtime readiness",
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(
&params.RootfsPath,
&params.WorkSeedPath,
&params.KernelPath,
&params.InitrdPath,
&params.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)
}