feat: Artisan checklist for application commands, hints and coverage gate

- migrate, migrate:rollback and deferred:purge gain --pretend and --force;
  they and queue:clear confirm only in production on an interactive
  terminal, so deploys over SSH proceed as before
- result lines: Applied N migrations, Rolled back <id> of <plugin>,
  Cleared N jobs from <queue>
- --json for migrate:status, key:generate, route:list, admin:token:create,
  admin:token:list, admin:token:scopes and websockets:health; -q keeps the
  key and the token
- hints for an empty DSN, an unreachable database, an invalid app.key, a
  missing uploads bucket and an application binary run below its root
- flare and websockets:health return bonfire.Reported after printing
- long help and examples for every application command, greeter:hello and
  blog:publish; cmd/summer TestCommandsDocumented fails on any gap
This commit is contained in:
Jakub Zych
2026-10-21 13:16:21 +02:00
parent 168d65cd1b
commit 9d06a78f62
29 changed files with 831 additions and 77 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.
- 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).
- 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.
@@ -191,7 +191,7 @@ Without these variables, color is enabled only when stdout is a terminal. `--ans
- SummerCMS modules: `sunscreen` (scrubs error messages).
- Third-party: `github.com/spf13/cobra`, `github.com/spf13/pflag`, `golang.org/x/term`, `charm.land/lipgloss/v2` (with `lipgloss/v2/table`), `charm.land/bubbletea/v2`, `charm.land/bubbles/v2` (spinner, progress, key), `charm.land/huh/v2`, `charm.land/fang/v2` and `github.com/charmbracelet/colorprofile` (decided in `.planning/notes/2026-10-21-console-charm-stack.md`).
- Standard library: `bufio`, `context`, `encoding/json`, `errors`, `fmt`, `io`, `os`, `runtime/debug`, `slices`, `strconv`, `strings`, `sync`, `time`, `unicode/utf8`.
- Standard library: `bufio`, `context`, `encoding/json`, `errors`, `fmt`, `io`, `os`, `path/filepath`, `runtime/debug`, `slices`, `strconv`, `strings`, `sync`, `time`, `unicode/utf8`.
## Testing

View File

@@ -3,6 +3,8 @@ package bonfire
import (
"errors"
"io"
"os"
"path/filepath"
"strings"
"git.golem15.com/golem15/summercms/modules/sunscreen"
@@ -173,6 +175,63 @@ func RenderError(w io.Writer, name string, err error, g Globals) {
_ = writeStyled(w, g, b.String())
}
// wrongDirectoryExplanation is the hint added when an application binary
// fails outside its application root.
const wrongDirectoryExplanation = "This command runs from the application root."
// wrongDirectory adds a hint to err when an application binary (any root
// name other than summer) failed while the working directory dir ("" means
// os.Getwd) has no config directory and a parent directory holds
// summer.yaml: compass then found no configuration, which is why the command
// failed (D-19). The Try line is "cd <relative path>; bin/<name> <args>".
// Usage errors, Reported errors and summer are left alone; nothing else
// about the failure changes.
func wrongDirectory(name string, err error, args []string, dir string) error {
if err == nil || name == "summer" || errors.Is(err, ErrReported) || isUsage(err) {
return err
}
if dir == "" {
wd, werr := os.Getwd()
if werr != nil {
return err
}
dir = wd
}
if info, serr := os.Stat(filepath.Join(dir, "config")); serr == nil && info.IsDir() {
return err
}
root := ""
for d := filepath.Dir(dir); ; d = filepath.Dir(d) {
if _, serr := os.Stat(filepath.Join(d, "summer.yaml")); serr == nil {
root = d
break
}
if filepath.Dir(d) == d {
break
}
}
if root == "" {
return err
}
rel, rerr := filepath.Rel(dir, root)
if rerr != nil {
return err
}
try := "cd " + shellQuote(rel) + "; {bin}"
for _, a := range args {
try += " " + shellQuote(a)
}
return &HintError{Err: err, Explanation: wrongDirectoryExplanation, Try: try}
}
// shellQuote single-quotes s when a shell would split or expand it.
func shellQuote(s string) string {
if s != "" && !strings.ContainsAny(s, " \t\n'\"\\$`*?[]{}()<>|&;#~!") {
return s
}
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
// parseUnknownCommand reads the command name and cobra's "Did you mean
// this?" suggestions from an unknown command error.
func parseUnknownCommand(msg string) (string, []string) {

View File

@@ -3,6 +3,7 @@ package bonfire
import (
"context"
"io"
"os"
"slices"
"strings"
@@ -52,7 +53,7 @@ func Execute(ctx context.Context, root *cobra.Command, args []string) int {
// fang hands over a colorprofile writer built from the
// environment only; RenderError unwraps it and applies
// --ansi and --no-ansi itself.
RenderError(w, name, err, g)
RenderError(w, name, wrongDirectory(name, err, args, ""), g)
}),
)
return exitCode(err)
@@ -60,12 +61,14 @@ func Execute(ctx context.Context, root *cobra.Command, args []string) int {
// Fail renders err like Execute does and returns ExitFailure (ExitOK for a
// nil error). The generated main uses it for errors that happen before the
// command root exists, such as a configuration that does not load.
// command root exists, such as a configuration that does not load. Like
// Execute, it adds the wrong-directory hint for an application binary run
// outside its application root.
func Fail(w io.Writer, name string, err error, g Globals) int {
if err == nil {
return ExitOK
}
RenderError(w, name, err, g)
RenderError(w, name, wrongDirectory(name, err, os.Args[1:], ""), g)
return ExitFailure
}

View File

@@ -0,0 +1,57 @@
package bonfire
import (
"bytes"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
// TestWrongDirectoryHintSmoke: an application binary failing below its
// application root (no config/ here, summer.yaml in a parent) gets the
// "cd <rel>; bin/<name> <args>" hint; summer, a directory with config/ and
// a usage error do not.
func TestWrongDirectoryHintSmoke(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "summer.yaml"), []byte("binary: acme\n"), 0o644); err != nil {
t.Fatal(err)
}
sub := filepath.Join(root, "plugins", "acme")
if err := os.MkdirAll(sub, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(root, "config"), 0o755); err != nil {
t.Fatal(err)
}
fail := errors.New("lagoon: database.dsn is empty")
got := wrongDirectory("acme", fail, []string{"migrate", "--plugin", "acme blog"}, sub)
var hint *HintError
if !errors.As(got, &hint) || hint.Explanation != wrongDirectoryExplanation ||
hint.Try != "cd ../..; {bin} migrate --plugin 'acme blog'" || !errors.Is(got, fail) {
t.Fatalf("hint = %#v", got)
}
var buf bytes.Buffer
RenderError(&buf, "acme", got, Globals{Color: ColorNever})
if !strings.Contains(buf.String(), "cd ../..; bin/acme migrate --plugin 'acme blog'") {
t.Fatalf("rendered block lacks the Try line:\n%s", buf.String())
}
for name, tc := range map[string]struct {
name string
err error
dir string
}{
"summer": {"summer", fail, sub},
"app root": {"acme", fail, root},
"usage error": {"acme", Usage(fail), sub},
"reported": {"acme", Reported(fail), sub},
"no app above": {"acme", fail, t.TempDir()},
} {
if got := wrongDirectory(tc.name, tc.err, []string{"migrate"}, tc.dir); errors.As(got, &hint) && hint.Explanation == wrongDirectoryExplanation {
t.Errorf("%s: unexpected hint %#v", name, got)
}
}
}