feat(diary): boot list, help and completion requests quietly (quick-261028-rqc)

- helpOnly also counts no command word, list, help, cobra's shell
  completion requests and --help=<bool>, read the way pflag does
- TestHelpOnlyAgreesWithCobra pins the classification against bonfire's
  real dispatch, with the passthrough --help case as the exception
- console and logging docs describe which invocations boot quietly
This commit is contained in:
Jakub Zych
2026-10-28 21:47:42 +01:00
parent 6b9becd994
commit 6c606c666b
7 changed files with 231 additions and 24 deletions

View File

@@ -91,8 +91,11 @@ The application binary reads these options before it loads `config/` and starts
```sh
./bin/acme -vv migrate
./bin/acme migrate:status --env staging
./bin/acme -v serve --help
```
An invocation that only lists or describes commands (no command, `list`, `help`, `--help` or `-h`, and shell completion requests) starts the plugins with the console log at warnings, as `-q` would, so the info lines plugins log while they register and boot do not print above the help; `-v` shows them, as in the last line above. `serve`, `queue:work`, `migrate` and every other command boot at the configured levels, and log files keep their levels. It works for both programs and needs no rebuild of the generated `main.go`, only the framework version. A command that passes its arguments through unparsed counts as asking for help when `--help` follows it, but its console log is back at the configured levels before it runs.
### Exit codes
| Code | Meaning |

View File

@@ -142,6 +142,8 @@ var blogLog = diary.For("blog")
Its records carry `component=blog`, follow `log.components.blog` and go through the redacting channels.
`Register` and `Boot` run before every command, so a plugin logs what it registered or collected at Debug (visible with `-vv`) rather than Info. Only the invocations that just ask for help (no command, `list`, `help`, `--help` or `-h`, shell completion) boot with the console at warnings; `migrate`, `queue:work` and one-off commands print every Info record a plugin logs while it boots.
Redaction is a safety net for mistakes, not a way to log secrets on purpose. Log identifiers and outcomes: the record id, the vendor, the status code, how long it took. Never log request arguments, tokens, credentials or request and response bodies, which can carry personal data the patterns do not recognise.
## Request log

View File

@@ -34,7 +34,7 @@ Use cases:
- Redaction: every channel leaf is wrapped by `sunscreen.Wrap`.
- Runtime verbosity: `diary.SetVerbosity` lets info (1) or debug (2) through for every component and adds source locations on styled lines (3); quiet raises only the console channels to warn.
- Console global options: `diary.FromGlobals` turns `-v`/`-vv`/`-vvv`, `-q` and `--ansi`/`--no-ansi` into `diary.Options`, and `diary.ApplyGlobals` applies them to the running setup (the generated main registers it with `bonfire.OnGlobals`).
- Quiet boot for help: `diary.FromGlobals` also records the process arguments (`os.Args[1:]`) in `diary.Options`, and `diary.Bootstrap` and `diary.Setup` start the console channels at warnings, as `-q` would, when those arguments only ask for help (`-h` or `--help`) and no `-v` is given. Daily files and stderr channels keep their levels, warnings and errors still print, and an existing generated main gets it from a framework upgrade alone. A `diary.Options` literal without arguments does no check.
- Quiet boot for help: `diary.FromGlobals` also records the process arguments (`os.Args[1:]`) in `diary.Options`, and `diary.Bootstrap` and `diary.Setup` start the console channels at warnings, as `-q` would, when those arguments only ask the binary to describe itself and no `-v` is given: no command, `list`, `help`, `-h` or `--help` (also after a command name) and shell completion requests. A command that passes its arguments through unparsed counts as asking for help when `--help` follows it; `diary.ApplyGlobals` restores the configured console levels before it runs. Daily files and stderr channels keep their levels, warnings and errors still print, and an existing generated main gets it from a framework upgrade alone. A `diary.Options` literal without arguments does no check.
- The process default logger (`slog.Default`) and the standard `log` package log through the configured channels without a component.
## Usage
@@ -158,4 +158,4 @@ Environment variables use the `SUMMER_LOG__` prefix with a double underscore bet
go test ./modules/diary/...
```
`TestDiaryTracerSmoke` loads a `config/log.yaml` through `compass`, logs one record from a component logger and checks that it reaches the console and the daily file redacted. `TestConfigEnvSmoke` parses `SUMMER_LOG__` overrides given as strings and checks that every invalid value fails with an error naming its key. `TestDailyDegradesSmoke` points the daily channel at a read-only directory and checks that it warns once and the console keeps logging. `TestBootQuietSmoke` runs the generated main's sequence with a plugin that logs while it registers and boots, and checks that `serve --help` keeps its info lines off the console (but not its warning, nor the daily file) while `serve` prints them. The tests write to temporary directories and need no external services.
`TestDiaryTracerSmoke` loads a `config/log.yaml` through `compass`, logs one record from a component logger and checks that it reaches the console and the daily file redacted. `TestConfigEnvSmoke` parses `SUMMER_LOG__` overrides given as strings and checks that every invalid value fails with an error naming its key. `TestDailyDegradesSmoke` points the daily channel at a read-only directory and checks that it warns once and the console keeps logging. `TestBootQuietSmoke` runs the generated main's sequence with a plugin that logs while it registers and boots, and checks that `serve --help` keeps its info lines off the console (but not its warning, nor the daily file) while `serve` prints them. `TestHelpOnly` lists the argument shapes that count as asking for help, and `TestHelpOnlyAgreesWithCobra` runs each through a bonfire root and checks that a command runs exactly when they do not. The tests write to temporary directories and need no external services.

View File

@@ -82,6 +82,12 @@ func TestBootQuietSmoke(t *testing.T) {
{name: "serve --help", args: []string{"serve", "--help"}},
{name: "serve", args: []string{"serve"}, wantInfo: true},
{name: "literal options", args: []string{"serve", "--help"}, literal: true, wantInfo: true},
{name: "no args", args: []string{}},
{name: "list", args: []string{"list"}},
{name: "help serve", args: []string{"help", "serve"}},
{name: "-h", args: []string{"-h"}},
{name: "-v serve --help", args: []string{"-v", "serve", "--help"}, wantInfo: true},
{name: "queue:work", args: []string{"queue:work"}, wantInfo: true},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {

View File

@@ -0,0 +1,10 @@
package diary
// HelpOnly exposes helpOnly to the external tests.
var HelpOnly = helpOnly
// The completion command names helpOnly recognises, for the cobra check.
const (
CompleteRequestCmd = completeRequestCmd
CompleteNoDescRequestCmd = completeNoDescRequestCmd
)

View File

@@ -1,47 +1,100 @@
package diary
import (
"strconv"
"strings"
)
// The hidden commands of cobra's shell completion (cobra.ShellCompRequestCmd
// and cobra.ShellCompNoDescRequestCmd), named here so diary does not link
// cobra; TestHelpOnlyAgreesWithCobra pins them.
const (
completeRequestCmd = "__complete"
completeNoDescRequestCmd = "__completeNoDesc"
)
// helpOnly reports whether args, a binary's command line without the
// program name, only ask for help: before "--" they hold --help or a
// single-dash cluster that reaches h. It is a tolerant pre-scan of the raw
// arguments, made before plugins boot, and reads them the way
// bonfire.PeekGlobals does: it stops at "--", skips the value of --env and
// reads a single-dash cluster letter by letter, stopping at the first
// letter that is not a global shorthand (v, q, n, h). It never fails and
// cannot tell a passthrough command (whose --help reaches its Run) from
// another, so "<passthrough> --help" counts; the root's OnGlobals hook
// (ApplyGlobals) restores the console levels before that command runs.
// program name, only ask the binary to describe itself, so no command runs:
//
// - there is no command word (the first token before "--" that is neither
// a flag nor the value of --env, bonfire's rule under which Execute
// prints the list): a bare invocation, only global options, -h or
// --help;
// - the command word is list, help or one of cobra's shell completion
// requests (__complete, __completeNoDesc);
// - the help flag is set before "--", also after the command word, the
// way cobra honours it: --help, --help=<bool> read by strconv.ParseBool
// the way pflag does (the last occurrence wins, an unparsable value is
// ignored), or a single-dash cluster that reaches h (-h, -vh, -nh).
//
// It is a tolerant pre-scan of the raw arguments, made before plugins boot,
// and reads them the way bonfire.PeekGlobals does: it stops at "--", skips
// the value of --env and reads a single-dash cluster letter by letter,
// stopping at the first letter that is not a global shorthand (v, q, n, h).
// It never fails. An unknown or misspelled command word is not help-only
// (that is unknowable before boot). It cannot tell a passthrough command
// (whose --help reaches its Run) from another, so "<passthrough> --help"
// counts; the root's OnGlobals hook (ApplyGlobals) restores the console
// levels before that command runs.
func helpOnly(args []string) bool {
help := false
word := ""
scan:
for i := 0; i < len(args); i++ {
a := args[i]
switch {
case a == "--":
return false
break scan
case a == "--env":
i++
case a == "--help":
return true
case len(a) >= 2 && a[0] == '-' && a[1] != '-':
if clusterHasHelp(a[1:]) {
return true
help = true
case strings.HasPrefix(a, "--help="):
if v, err := strconv.ParseBool(strings.TrimPrefix(a, "--help=")); err == nil {
help = v
}
case len(a) >= 2 && a[0] == '-' && a[1] != '-':
if v, ok := clusterHelp(a[1:]); ok {
help = v
}
case strings.HasPrefix(a, "-"):
case word == "":
word = a
}
}
return false
switch word {
case "", "list", "help", completeRequestCmd, completeNoDescRequestCmd:
return true
}
return help
}
// clusterHasHelp reports whether a shorthand cluster (the letters after the
// dash) reaches h before a letter that is not a global shorthand.
func clusterHasHelp(cluster string) bool {
// clusterHelp reads a shorthand cluster (the letters after the dash) the
// way bonfire.PeekGlobals does and reports the help value it sets: ok is
// true when the cluster reaches h before a letter that is not a global
// shorthand, and -h=<bool> (-vh=false) sets the value read by
// strconv.ParseBool.
func clusterHelp(cluster string) (help, ok bool) {
for i := 0; i < len(cluster); i++ {
switch cluster[i] {
r := cluster[i]
if i+1 < len(cluster) && cluster[i+1] == '=' {
if r != 'h' {
return help, ok
}
if v, err := strconv.ParseBool(cluster[i+2:]); err == nil {
return v, true
}
return help, ok
}
switch r {
case 'h':
return true
help, ok = true, true
case 'v', 'q', 'n':
default:
return false
return help, ok
}
}
return false
return help, ok
}
// bootQuiet applies the help-only rule of Bootstrap and Setup: with Args

View File

@@ -0,0 +1,133 @@
package diary_test
import (
"context"
"io"
"strings"
"testing"
"github.com/spf13/cobra"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/diary"
)
// helpOnlyCases are the rows shared by TestHelpOnly and
// TestHelpOnlyAgreesWithCobra. usage marks the rows cobra rejects as a
// usage error, which the agreement check leaves out.
var helpOnlyCases = []struct {
args []string
want bool
usage bool
}{
{args: []string{}, want: true},
{args: []string{"--help"}, want: true},
{args: []string{"-h"}, want: true},
{args: []string{"serve", "--help"}, want: true},
{args: []string{"serve", "-h"}, want: true},
{args: []string{"-h", "serve"}, want: true},
{args: []string{"serve", "--help=true"}, want: true},
{args: []string{"serve", "--help=1"}, want: true},
{args: []string{"-nh", "serve"}, want: true},
{args: []string{"-vh", "serve"}, want: true},
{args: []string{"serve", "-vh=true"}, want: true},
{args: []string{"list"}, want: true},
{args: []string{"list", "--format=json"}, want: true},
{args: []string{"help"}, want: true},
{args: []string{"help", "serve"}, want: true},
{args: []string{"--env", "staging"}, want: true},
{args: []string{"--env=staging"}, want: true},
{args: []string{"-v"}, want: true},
{args: []string{"--help=false"}, want: true},
{args: []string{"--env", "help"}, want: true},
{args: []string{"__complete", "serve", ""}, want: true},
{args: []string{"__completeNoDesc", "se"}, want: true},
{args: []string{"serve"}},
{args: []string{"migrate", "-v"}},
{args: []string{"queue:work"}},
{args: []string{"--env", "list", "serve"}},
{args: []string{"serve", "--", "--help"}},
{args: []string{"serve", "--help=false"}},
{args: []string{"serve", "--help", "--help=false"}},
{args: []string{"serve", "-h=false"}},
{args: []string{"serve", "-qv"}},
{args: []string{"serve", "-xh"}, usage: true},
{args: []string{"serve", "--help=yes"}, usage: true},
{args: []string{"blog:import", "https://example.com/help"}},
{args: []string{"blog:import", "help"}},
{args: []string{"-q", "--env=list", "migrate"}},
}
func TestHelpOnly(t *testing.T) {
for _, tc := range helpOnlyCases {
if got := diary.HelpOnly(tc.args); got != tc.want {
t.Errorf("helpOnly(%q) = %v, want %v", tc.args, got, tc.want)
}
}
}
// helpOnlyRoot builds a root whose commands record whether their Run ran.
func helpOnlyRoot(t *testing.T, ran *bool) *cobra.Command {
t.Helper()
run := func(context.Context, bonfire.Input, bonfire.Output) error {
*ran = true
return nil
}
optional := []bonfire.Arg{{Name: "target", Description: "An optional argument"}}
commands := []bonfire.Command{
{Name: "serve", Description: "Start the HTTP server", Args: optional, Run: run},
{Name: "migrate", Description: "Run the migrations", Run: run},
{Name: "queue:work", Description: "Run the job workers", Run: run},
{Name: "blog:import", Description: "Import posts", Args: optional, Run: run},
{Name: "acme:tool", Description: "Run a tool with its own flags", Passthrough: true, Run: run},
}
root, err := bonfire.NewRootIO("acme", commands, strings.NewReader(""), io.Discard, io.Discard,
bonfire.WithVersion(bonfire.Version{App: "v1.0.0", Framework: "v1.0.0"}))
if err != nil {
t.Fatal(err)
}
return root
}
// TestHelpOnlyAgreesWithCobra: for every row cobra accepts, a command runs
// exactly when helpOnly reports false. A passthrough command is the
// documented exception: its --help reaches its Run, while helpOnly, which
// cannot know the command before boot, counts it.
func TestHelpOnlyAgreesWithCobra(t *testing.T) {
for _, tc := range helpOnlyCases {
var ran bool
code := bonfire.Execute(context.Background(), helpOnlyRoot(t, &ran), tc.args)
if tc.usage {
if code != bonfire.ExitUsage {
t.Errorf("Execute(%q) = %d, want the usage error %d", tc.args, code, bonfire.ExitUsage)
}
continue
}
if code != bonfire.ExitOK {
t.Errorf("Execute(%q) = %d, want 0", tc.args, code)
continue
}
if ran != !diary.HelpOnly(tc.args) {
t.Errorf("Execute(%q) ran a command = %v, but helpOnly = %v", tc.args, ran, diary.HelpOnly(tc.args))
}
}
t.Run("passthrough --help", func(t *testing.T) {
args := []string{"acme:tool", "--help"}
var ran bool
if code := bonfire.Execute(context.Background(), helpOnlyRoot(t, &ran), args); code != bonfire.ExitOK {
t.Fatalf("Execute(%q) = %d, want 0", args, code)
}
if !ran || !diary.HelpOnly(args) {
t.Errorf("Execute(%q) ran = %v, helpOnly = %v; want both true (the documented exception)", args, ran, diary.HelpOnly(args))
}
})
if diary.CompleteRequestCmd != cobra.ShellCompRequestCmd {
t.Errorf("completion command %q, cobra uses %q", diary.CompleteRequestCmd, cobra.ShellCompRequestCmd)
}
if diary.CompleteNoDescRequestCmd != cobra.ShellCompNoDescRequestCmd {
t.Errorf("completion command %q, cobra uses %q", diary.CompleteNoDescRequestCmd, cobra.ShellCompNoDescRequestCmd)
}
}