From a7d1a49aca767ae528d276daa150dd4732bfc535 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Fri, 17 Apr 2026 15:37:47 -0300 Subject: [PATCH] cli: restrict ExitCodeError unwrap to the CLI's own type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main.go previously unwrapped *any* error implementing `ExitCode() int` into the process exit status, which matched *exec.ExitError too. So whenever a CLI command ran a subprocess (mkfs.ext4, debugfs, ssh to a daemon preflight, etc.) and that subprocess failed, the CLI would silently exit with the subprocess's code — no error message printed. Surfaced while bringing up `banger internal make-bundle`: mkfs.ext4 was failing on an undersized ext4 and the user saw only `EXIT=1`. Fix: export the type as `cli.ExitCodeError` and unwrap against the concrete type in main.go. The `ExitCode()` method is gone — only the explicit wrap at the `vm run` command-mode call site produces this error now. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/banger/main.go | 4 ++-- internal/cli/banger.go | 17 ++++++++--------- internal/cli/cli_test.go | 4 ++-- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/cmd/banger/main.go b/cmd/banger/main.go index bee0caa..0719e11 100644 --- a/cmd/banger/main.go +++ b/cmd/banger/main.go @@ -17,9 +17,9 @@ func main() { cmd := cli.NewBangerCommand() if err := cmd.ExecuteContext(ctx); err != nil { - var exitErr interface{ ExitCode() int } + var exitErr cli.ExitCodeError if errors.As(err, &exitErr) { - os.Exit(exitErr.ExitCode()) + os.Exit(exitErr.Code) } fmt.Fprintf(os.Stderr, "banger: %v\n", err) os.Exit(1) diff --git a/internal/cli/banger.go b/internal/cli/banger.go index 78b38c2..3ec07a7 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -2810,20 +2810,19 @@ func splitVMRunArgs(cmd *cobra.Command, args []string) (pathArgs, commandArgs [] return args[:dash], args[dash:] } -// exitCodeError wraps a remote command's exit status so the CLI's main() -// can propagate it verbatim. Setup errors and other failures stay as -// regular errors. -type exitCodeError struct { +// ExitCodeError wraps a remote command's exit status so the CLI's main() +// can propagate it verbatim. Only errors explicitly wrapped in this +// type get forwarded as process exit codes — plain *exec.ExitError +// values (from unrelated subprocesses like mkfs.ext4) must still +// surface as regular errors so the user sees a message. +type ExitCodeError struct { Code int } -func (e exitCodeError) Error() string { +func (e ExitCodeError) Error() string { return fmt.Sprintf("exit status %d", e.Code) } -// ExitCode exposes the code for callers using errors.As. -func (e exitCodeError) ExitCode() int { return e.Code } - func runVMRun(ctx context.Context, socketPath string, cfg model.DaemonConfig, stdin io.Reader, stdout, stderr io.Writer, params api.VMCreateParams, spec *vmRunRepoSpec, command []string) error { progress := newVMRunProgressRenderer(stderr) vm, err := runVMCreate(ctx, socketPath, stderr, params) @@ -2871,7 +2870,7 @@ func runVMRun(ctx context.Context, socketPath string, cfg model.DaemonConfig, st if err := sshExecFunc(ctx, stdin, stdout, stderr, sshArgs); err != nil { var exitErr *exec.ExitError if errors.As(err, &exitErr) { - return exitCodeError{Code: exitErr.ExitCode()} + return ExitCodeError{Code: exitErr.ExitCode()} } return err } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 9ce8947..ae5b0f3 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -1675,9 +1675,9 @@ func TestRunVMRunCommandModePropagatesExitCode(t *testing.T) { nil, []string{"false"}, ) - var exitErr exitCodeError + var exitErr ExitCodeError if !errors.As(err, &exitErr) || exitErr.Code != 7 { - t.Fatalf("runVMRun error = %v, want exitCodeError{7}", err) + t.Fatalf("runVMRun error = %v, want ExitCodeError{7}", err) } if len(sshArgsSeen) == 0 || sshArgsSeen[len(sshArgsSeen)-1] != "false" { t.Fatalf("sshArgsSeen = %v, want trailing command 'false'", sshArgsSeen)