feat(bonfire): global flags, Artisan list and exit codes

- Global options -v/-vv/-vvv, -q, --ansi/--no-ansi, -n and --env on every
  command; PeekGlobals reads them before config loads, OnGlobals hooks
  re-apply them after parsing (diary.ApplyGlobals in the generated main)
- Bare binary, --help and list print the Artisan list with the version
  header from BuildVersion and namespace blocks
- Execute runs the root through fang (no version, man page or completions)
  and returns 0, 1 or 2; Fail renders errors before the root exists
- NewRoot rejects duplicate command names (ErrDuplicateCommand) and flags
  that reuse a global option (ErrReservedFlag)
- Generated main and summer use PeekGlobals, compass.Open with --env,
  diary.FromGlobals and bonfire.Execute; docs checker knows the globals
This commit is contained in:
Jakub Zych
2026-10-21 12:13:23 +02:00
parent b7c07591cb
commit 8d4d7ce7a7
25 changed files with 1158 additions and 154 deletions

View File

@@ -96,7 +96,7 @@ func generateMain(m Manifest) ([]byte, error) {
b.WriteString("// Code generated by summer build. DO NOT EDIT.\n\n")
b.WriteString("package main\n\n")
b.WriteString("import (\n")
b.WriteString("\t\"fmt\"\n")
b.WriteString("\t\"context\"\n")
b.WriteString("\t\"io\"\n")
b.WriteString("\t\"os\"\n\n")
b.WriteString("\t\"git.golem15.com/golem15/summercms/modules/backpack\"\n")
@@ -111,25 +111,19 @@ func generateMain(m Manifest) ([]byte, error) {
b.WriteString("\t\"git.golem15.com/golem15/summercms/modules/surf\"\n")
b.WriteString(")\n\n")
b.WriteString("func main() {\n")
b.WriteString("\tif err := run(os.Args[1:], os.Stdout); err != nil {\n")
b.WriteString("\t\tfmt.Fprintln(os.Stderr, err)\n")
b.WriteString("\t\tos.Exit(1)\n")
b.WriteString("\t}\n")
b.WriteString("\tos.Exit(run(os.Args[1:], os.Stdout))\n")
b.WriteString("}\n\n")
b.WriteString("func run(args []string, out io.Writer) error {\n")
b.WriteString("\tdiary.Bootstrap(os.Stderr, diary.Options{})\n")
b.WriteString("\tcfg, err := compass.Load(\"config\")\n")
b.WriteString("\tif err != nil {\n")
b.WriteString("\t\treturn err\n")
b.WriteString("\t}\n")
b.WriteString("\tif err := diary.Setup(cfg, diary.Options{}); err != nil {\n")
b.WriteString("\t\treturn err\n")
b.WriteString("\t}\n")
binary := strconv.Quote(m.Binary)
fail := "\t\treturn bonfire.Fail(os.Stderr, " + binary + ", err, g)\n"
b.WriteString("func run(args []string, out io.Writer) int {\n")
b.WriteString("\tg := bonfire.PeekGlobals(args)\n")
b.WriteString("\tdiary.Bootstrap(os.Stderr, diary.FromGlobals(g))\n")
b.WriteString("\tcfg, err := compass.Open(compass.Options{Dir: \"config\", Env: g.Env})\n")
b.WriteString("\tif err != nil {\n" + fail + "\t}\n")
b.WriteString("\tif err := diary.Setup(cfg, diary.FromGlobals(g)); err != nil {\n" + fail + "\t}\n")
b.WriteString("\tapp := backpack.New(cfg)\n")
b.WriteString("\tplugins, err := party.Activate(app, PluginIDs)\n")
b.WriteString("\tif err != nil {\n")
b.WriteString("\t\treturn err\n")
b.WriteString("\t}\n")
b.WriteString("\tif err != nil {\n" + fail + "\t}\n")
b.WriteString("\tcommands := lagoon.RuntimeCommands(app, plugins)\n")
b.WriteString("\tcommands = append(commands, conga.RuntimeCommands(app, plugins)...)\n")
b.WriteString("\tcommands = append(commands, surf.ServeCommand(app, plugins))\n")
@@ -140,15 +134,10 @@ func generateMain(m Manifest) ([]byte, error) {
b.WriteString("\t\t\tcommands = append(commands, hasCommands.Commands()...)\n")
b.WriteString("\t\t}\n")
b.WriteString("\t}\n")
b.WriteString("\tif err := app.Publish(bonfire.NewCatalog(commands)); err != nil {\n")
b.WriteString("\t\treturn err\n")
b.WriteString("\t}\n")
fmt.Fprintf(&b, "\troot, err := bonfire.NewRoot(%s, commands, out)\n", strconv.Quote(m.Binary))
b.WriteString("\tif err != nil {\n")
b.WriteString("\t\treturn err\n")
b.WriteString("\t}\n")
b.WriteString("\troot.SetArgs(args)\n")
b.WriteString("\treturn root.Execute()\n")
b.WriteString("\tif err := app.Publish(bonfire.NewCatalog(commands)); err != nil {\n" + fail + "\t}\n")
fmt.Fprintf(&b, "\troot, err := bonfire.NewRoot(%s, commands, out, bonfire.WithVersion(bonfire.BuildVersion()), bonfire.OnGlobals(diary.ApplyGlobals))\n", binary)
b.WriteString("\tif err != nil {\n" + fail + "\t}\n")
b.WriteString("\treturn bonfire.Execute(context.Background(), root, args)\n")
b.WriteString("}\n")
return formatSource([]byte(b.String()))
}

View File

@@ -142,22 +142,26 @@ func TestGenerateMainRegistersCongaRuntimeCommands(t *testing.T) {
}
}
// TestGenerateMainInstallsDiary: the first statement of run installs the
// diary console logger, before config loads or any plugin can log, and
// TestGenerateMainInstallsDiary: run first reads the console global options
// (bonfire.PeekGlobals), then installs the diary console logger with them,
// before config loads (in the --env environment) or any plugin can log;
// diary.Setup applies config/log.yaml right after compass loads it and before
// the application container exists.
// the application container exists, and the root re-applies the parsed
// options to diary through bonfire.OnGlobals.
func TestGenerateMainInstallsDiary(t *testing.T) {
mainSrc, err := generateMain(Manifest{Module: "example.com/app", Binary: "hello"})
if err != nil {
t.Fatal(err)
}
bootstrap := []byte("diary.Bootstrap(os.Stderr, diary.Options{})")
setup := []byte("diary.Setup(cfg, diary.Options{})")
if n := bytes.Count(mainSrc, bootstrap); n != 1 {
t.Fatalf("Bootstrap count = %d, want 1\n%s", n, mainSrc)
}
if n := bytes.Count(mainSrc, setup); n != 1 {
t.Fatalf("Setup count = %d, want 1\n%s", n, mainSrc)
peek := []byte("g := bonfire.PeekGlobals(args)")
bootstrap := []byte("diary.Bootstrap(os.Stderr, diary.FromGlobals(g))")
open := []byte(`compass.Open(compass.Options{Dir: "config", Env: g.Env})`)
setup := []byte("diary.Setup(cfg, diary.FromGlobals(g))")
hook := []byte("bonfire.OnGlobals(diary.ApplyGlobals)")
for _, want := range [][]byte{peek, bootstrap, open, setup, hook, []byte("os.Exit(run(os.Args[1:], os.Stdout))"), []byte("return bonfire.Execute(context.Background(), root, args)")} {
if n := bytes.Count(mainSrc, want); n != 1 {
t.Fatalf("%s count = %d, want 1\n%s", want, n, mainSrc)
}
}
if !bytes.Contains(mainSrc, []byte(`"git.golem15.com/golem15/summercms/modules/diary"`)) {
t.Fatalf("generated main does not import diary:\n%s", mainSrc)
@@ -165,17 +169,18 @@ func TestGenerateMainInstallsDiary(t *testing.T) {
if bytes.Contains(mainSrc, []byte(`"git.golem15.com/golem15/summercms/modules/sunscreen"`)) {
t.Fatalf("generated main still imports sunscreen:\n%s", mainSrc)
}
runSig := []byte("func run(args []string, out io.Writer) error {")
runSig := []byte("func run(args []string, out io.Writer) int {")
runStart := bytes.Index(mainSrc, runSig)
pk := bytes.Index(mainSrc, peek)
boot := bytes.Index(mainSrc, bootstrap)
load := bytes.Index(mainSrc, []byte("compass.Load("))
load := bytes.Index(mainSrc, open)
set := bytes.Index(mainSrc, setup)
newApp := bytes.Index(mainSrc, []byte("backpack.New("))
if runStart < 0 || load < 0 || newApp < 0 || !(runStart < boot && boot < load && load < set && set < newApp) {
t.Fatalf("want run { Bootstrap; compass.Load; Setup; backpack.New }:\n%s", mainSrc)
if runStart < 0 || newApp < 0 || !(runStart < pk && pk < boot && boot < load && load < set && set < newApp) {
t.Fatalf("want run { PeekGlobals; Bootstrap; compass.Open; Setup; backpack.New }:\n%s", mainSrc)
}
if between := bytes.TrimSpace(mainSrc[runStart+len(runSig) : boot]); len(between) != 0 {
t.Fatalf("statements before Bootstrap: %q", between)
if between := bytes.TrimSpace(mainSrc[runStart+len(runSig) : pk]); len(between) != 0 {
t.Fatalf("statements before PeekGlobals: %q", between)
}
}

View File

@@ -3,7 +3,7 @@
package main
import (
"fmt"
"context"
"io"
"os"
@@ -20,25 +20,23 @@ import (
)
func main() {
if err := run(os.Args[1:], os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
os.Exit(run(os.Args[1:], os.Stdout))
}
func run(args []string, out io.Writer) error {
diary.Bootstrap(os.Stderr, diary.Options{})
cfg, err := compass.Load("config")
func run(args []string, out io.Writer) int {
g := bonfire.PeekGlobals(args)
diary.Bootstrap(os.Stderr, diary.FromGlobals(g))
cfg, err := compass.Open(compass.Options{Dir: "config", Env: g.Env})
if err != nil {
return err
return bonfire.Fail(os.Stderr, "demo", err, g)
}
if err := diary.Setup(cfg, diary.Options{}); err != nil {
return err
if err := diary.Setup(cfg, diary.FromGlobals(g)); err != nil {
return bonfire.Fail(os.Stderr, "demo", err, g)
}
app := backpack.New(cfg)
plugins, err := party.Activate(app, PluginIDs)
if err != nil {
return err
return bonfire.Fail(os.Stderr, "demo", err, g)
}
commands := lagoon.RuntimeCommands(app, plugins)
commands = append(commands, conga.RuntimeCommands(app, plugins)...)
@@ -51,12 +49,11 @@ func run(args []string, out io.Writer) error {
}
}
if err := app.Publish(bonfire.NewCatalog(commands)); err != nil {
return err
return bonfire.Fail(os.Stderr, "demo", err, g)
}
root, err := bonfire.NewRoot("demo", commands, out)
root, err := bonfire.NewRoot("demo", commands, out, bonfire.WithVersion(bonfire.BuildVersion()), bonfire.OnGlobals(diary.ApplyGlobals))
if err != nil {
return err
return bonfire.Fail(os.Stderr, "demo", err, g)
}
root.SetArgs(args)
return root.Execute()
return bonfire.Execute(context.Background(), root, args)
}

View File

@@ -101,10 +101,12 @@ func (s *site) checkCommands(docs []parsedDoc) ([]Problem, error) {
// command. tool is true for summer and `go run ./cmd/summer`, false for
// ./bin/{app} and bin/{app}. ok is false when the line is not one of
// those programs or when flags leave no command word. Flag handling
// matches cobra's stripFlags for a root whose only flag is the bool
// --help / -h: "--" ends the search, --help and -h take no value, any
// other --name or two-character -x consumes the next token, and a token
// that already contains "=" is skipped alone.
// matches cobra's stripFlags for the bonfire root and its global options:
// "--" ends the search; --help, -h, -v, -vv, -vvv, --verbose, -q, --quiet,
// -n, --no-interaction, --ansi and --no-ansi take no value; --env takes
// the next token (or --env=X); any other --name or two-character -x
// consumes the next token, and a token that already contains "=" is
// skipped alone.
func commandWord(cmd string) (name string, tool bool, ok bool) {
fields := strings.Fields(strings.TrimSpace(cmd))
if len(fields) > 0 && fields[0] == "$" {
@@ -134,7 +136,7 @@ func commandWord(cmd string) (name string, tool bool, ok bool) {
return "", tool, false
case strings.HasPrefix(s, "-") && strings.Contains(s, "="):
fields = fields[1:]
case s == "--help" || s == "-h":
case valuelessGlobal(s):
fields = fields[1:]
case strings.HasPrefix(s, "--") || (strings.HasPrefix(s, "-") && len(s) == 2):
if len(fields) < 3 {
@@ -150,6 +152,18 @@ func commandWord(cmd string) (name string, tool bool, ok bool) {
return "", tool, false
}
// valuelessGlobal reports the root flags that take no value: --help and -h
// and bonfire's bool global options. "--env" takes a value and is handled
// with the other value flags.
func valuelessGlobal(s string) bool {
switch s {
case "--help", "-h", "-v", "-vv", "-vvv", "--verbose", "-q", "--quiet",
"-n", "--no-interaction", "--ansi", "--no-ansi":
return true
}
return false
}
// isAssignment reports a leading VAR=value token: a name of letters,
// digits and underscores that does not start with a digit.
func isAssignment(tok string) bool {

View File

@@ -593,6 +593,11 @@ func TestCommandWord(t *testing.T) {
{"summer", "", tool, false},
{"FOO=1", "", false, false},
{"summer --version", "", tool, false},
{"bin/acme -v serve", "serve", app, true},
{"bin/acme -vvv --env=dev migrate", "migrate", app, true},
{"bin/acme --env staging migrate", "migrate", app, true},
{"summer -q --no-ansi -n list", "list", tool, true},
{"bin/acme --verbose --quiet --no-interaction --ansi help migrate", "help", app, true},
}
for _, tc := range cases {
name, isTool, ok := commandWord(tc.in)