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:
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
57
modules/bonfire/wrongdir_smoke_test.go
Normal file
57
modules/bonfire/wrongdir_smoke_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user