fix(summer): pass the app binary's exit code through and print its error once

- bonfire.WithExitCode (additive) makes an error carry the exit code
  bonfire.Execute returns; a code outside 1..255 gives 1
- runAppCmd, factored out of runAppBinary, returns
  Reported(WithExitCode(err, code)) when the binary exited with a code, so
  summer prints no second error block; a binary killed by a signal keeps
  summer's block and exit 1
- README, console introduction and upgrading notes say so (CR-02)
This commit is contained in:
Jakub Zych
2026-10-22 02:17:09 +02:00
parent d151a252c5
commit 323413c741
8 changed files with 165 additions and 10 deletions

View File

@@ -46,7 +46,7 @@ Use cases:
- Hidden commands (`bonfire.Command.Hidden`) run but stay out of the list unless `list --all`.
- JSON catalog: `list --format=json` prints every command, hidden ones flagged, with arguments, options, defaults and examples as a `bonfire.Listing` (`bonfire.Describe` builds it from a command slice).
- Documentation coverage: `bonfire.CheckDocs` reports commands lacking a description, long help, examples, or argument and option descriptions.
- Error block and exit codes: `bonfire.Execute` runs the root through fang and returns `bonfire.ExitOK`, `bonfire.ExitFailure`, or `bonfire.ExitUsage` for an unknown command or flag and a wrong number of arguments (`bonfire.UsageError`). Errors print on the error stream as a block (`bonfire.RenderError`): the ERROR badge and the message, the explanation and Try lines of `bonfire.WithHint` (`{bin}` becomes `bin/<name>` or `summer`), cobra's "did you mean" suggestions as Try lines, and "Run with -v for details." or, at `-v`, the wrapped error chain; every message passes through `sunscreen.Scrub`. Errors wrapped by `bonfire.Reported` print nothing. `bonfire.Fail` prints errors that happen before the root exists. For an application binary (any root other than `summer`) that fails, other than with a usage error, while the working directory has no `config/` directory and a parent directory holds `summer.yaml`, both add the hint "This command runs from the application root." with `cd <relative path>; bin/<name> <args>` (only the message changes).
- Error block and exit codes: `bonfire.Execute` runs the root through fang and returns `bonfire.ExitOK`, `bonfire.ExitFailure`, or `bonfire.ExitUsage` for an unknown command or flag and a wrong number of arguments (`bonfire.UsageError`). Errors print on the error stream as a block (`bonfire.RenderError`): the ERROR badge and the message, the explanation and Try lines of `bonfire.WithHint` (`{bin}` becomes `bin/<name>` or `summer`), cobra's "did you mean" suggestions as Try lines, and "Run with -v for details." or, at `-v`, the wrapped error chain; every message passes through `sunscreen.Scrub`. Errors wrapped by `bonfire.Reported` print nothing. An error carrying a code through `bonfire.WithExitCode` exits with that code; summer uses it to exit with the code of an app binary it ran. `bonfire.Fail` prints errors that happen before the root exists. For an application binary (any root other than `summer`) that fails, other than with a usage error, while the working directory has no `config/` directory and a parent directory holds `summer.yaml`, both add the hint "This command runs from the application root." with `cd <relative path>; bin/<name> <args>` (only the message changes).
- Name checks at boot: two commands with the same name, or a command named `list` or `help`, make `bonfire.NewRoot` fail with `bonfire.ErrDuplicateCommand`; a command flag that reuses a global option's name or shorthand fails with `bonfire.ErrReservedFlag` instead of panicking.
- Injectable streams (`bonfire.NewRootIO`, `bonfire.NewOutput`) so commands can be tested against buffers.
- In-process calls: `bonfire.Call` runs a named command with arguments against any writer (Laravel `Artisan::call`), and `bonfire.Catalog` holds an application binary's final command list so code outside the Cobra root, such as the conga scheduler, can call any registered command.
@@ -139,7 +139,8 @@ if catalog.Has("blog:import") {
| `bonfire.HintError` | An error with an `Explanation` and a `Try` text that the error block prints. |
| `bonfire.WithHint` | Wraps an error in a `bonfire.HintError`; `{bin}` in the Try text becomes `bin/<name>` or `summer`. |
| `bonfire.ErrReported` | Marks an error whose details the command already printed. |
| `bonfire.Reported` | Wraps an error so it matches `bonfire.ErrReported`: exit code 1, no error block. |
| `bonfire.Reported` | Wraps an error so it matches `bonfire.ErrReported`: exit code 1, or the code of `bonfire.WithExitCode`, and no error block. |
| `bonfire.WithExitCode` | Makes an error carry the exit code `bonfire.Execute` returns for it (1 to 255; other codes give 1). |
| `bonfire.Listing` | The JSON catalog of `list --format=json`: `Application` (`bonfire.ApplicationInfo`), `GlobalOptions` and `Commands`. |
| `bonfire.ApplicationInfo` | Name, version and framework version of a binary in a `bonfire.Listing`. |
| `bonfire.CommandInfo` | One command of a `bonfire.Listing`: name, description, long help, usage, hidden and passthrough switches, `Arguments`, `Options` and `Examples`. |

View File

@@ -93,6 +93,28 @@ func Reported(err error) error {
return &reportedError{err: err}
}
// exitCodeError carries the exit code Execute returns for an error.
type exitCodeError struct {
err error
code int
}
func (e *exitCodeError) Error() string { return e.err.Error() }
func (e *exitCodeError) Unwrap() error { return e.err }
// WithExitCode returns err carrying code as the process exit code: Execute
// returns code instead of ExitFailure or ExitUsage. A code outside 1 to 255
// gives ExitFailure. A nil err stays nil. Wrap it with Reported when the
// error was already printed, as summer does with the exit code of an app
// binary it ran.
func WithExitCode(err error, code int) error {
if err == nil {
return nil
}
return &exitCodeError{err: err, code: code}
}
// RenderError writes err to w as the console error block: the ERROR badge
// and the message, the explanation and the Try lines of a HintError ({bin}
// replaced by summer or bin/<name>, for the root named name), then "Run with

View File

@@ -182,3 +182,39 @@ func TestWrongDirectoryHint(t *testing.T) {
}
}
}
// TestWithExitCode (CR-02): Execute returns the code an error carries
// through WithExitCode, prints no block when it is also Reported, maps a
// code outside 1..255 to ExitFailure, and errors.Is and errors.As still see
// the wrapped error.
func TestWithExitCode(t *testing.T) {
plainEnv(t)
base := errors.New("child failed")
command := func(err error) []Command {
return []Command{{Name: "acme:run", Description: "Run", Run: func(context.Context, Input, Output) error { return err }}}
}
_, errOut, code := runGolden(t, command(Reported(WithExitCode(base, 2))), "acme:run")
if code != 2 || errOut != "" {
t.Errorf("Reported(WithExitCode(err, 2)): exit %d, stderr %q; want 2 and no block", code, errOut)
}
_, errOut, code = runGolden(t, command(WithExitCode(base, 3)), "acme:run")
if code != 3 || !strings.Contains(errOut, "ERROR") || !strings.Contains(errOut, "child failed") {
t.Errorf("WithExitCode(err, 3): exit %d, stderr %q; want 3 and the block", code, errOut)
}
for _, bad := range []int{0, -1, 256} {
if _, _, code := runGolden(t, command(Reported(WithExitCode(base, bad))), "acme:run"); code != ExitFailure {
t.Errorf("WithExitCode(err, %d): exit %d, want %d", bad, code, ExitFailure)
}
}
if WithExitCode(nil, 2) != nil {
t.Error("WithExitCode(nil, 2) is not nil")
}
wrapped := fmt.Errorf("outer: %w", WithExitCode(&HintError{Err: base, Try: "x"}, 4))
var hint *HintError
if !errors.Is(wrapped, base) || !errors.As(wrapped, &hint) || wrapped.Error() != "outer: child failed" {
t.Errorf("WithExitCode hides the wrapped error: %v", wrapped)
}
if code := exitCode(wrapped); code != 4 {
t.Errorf("exitCode through a wrapper = %d, want 4", code)
}
}

View File

@@ -2,6 +2,7 @@ package bonfire
import (
"context"
"errors"
"io"
"os"
"slices"
@@ -23,11 +24,11 @@ const (
)
// Execute runs root with args and returns the process exit code: ExitOK,
// ExitFailure, or ExitUsage for usage errors. With no command word in args
// (only global options, -h or --help) it prints the command list. Errors are
// rendered on the root's error stream by RenderError through fang's error
// handler; fang is used without its version flag, man page and completion
// commands.
// ExitFailure, ExitUsage for usage errors, or the code an error carries
// through WithExitCode. With no command word in args (only global options,
// -h or --help) it prints the command list. Errors are rendered on the
// root's error stream by RenderError through fang's error handler; fang is
// used without its version flag, man page and completion commands.
func Execute(ctx context.Context, root *cobra.Command, args []string) int {
if ctx == nil {
ctx = context.Background()
@@ -73,9 +74,15 @@ func Fail(w io.Writer, name string, err error, g Globals) int {
}
func exitCode(err error) int {
var carrier *exitCodeError
switch {
case err == nil:
return ExitOK
case errors.As(err, &carrier):
if carrier.code < 1 || carrier.code > 255 {
return ExitFailure
}
return carrier.code
case isUsage(err):
return ExitUsage
default: