From 14f6982c4dcc8d5f86280f52608e3d8e3e3b21d6 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 8 Oct 2026 15:49:39 +0200 Subject: [PATCH] feat(bonfire): add Passthrough and Long command fields - Command.Passthrough turns off cobra flag parsing and uses arbitrary args, so every argument after the command name, including --help, -h and --, reaches Run through Input.Args unchanged - Command.Long sets cobra's Long text for the help command; without it the description is shown as before - both fields are additive: commands without Passthrough parse flags and reject unknown ones exactly as before - README and docs/console/writing-commands.md describe both fields --- docs/console/writing-commands.md | 1 + modules/bonfire/README.md | 3 +- modules/bonfire/command.go | 11 ++++ modules/bonfire/command_test.go | 103 +++++++++++++++++++++++++++++++ modules/bonfire/root.go | 6 ++ 5 files changed, 123 insertions(+), 1 deletion(-) diff --git a/docs/console/writing-commands.md b/docs/console/writing-commands.md index 2d9a683..f7ab696 100644 --- a/docs/console/writing-commands.md +++ b/docs/console/writing-commands.md @@ -16,6 +16,7 @@ A `bonfire.Command` has a name, a description, its positional arguments and flag - Each `bonfire.Arg` is a positional argument with a name, a description and a `Required` marker. The usage line shows required arguments as `` and optional ones as `[name]`. - Each `bonfire.Flag` is a string flag. Set `bonfire.Flag.Bare` for a switch such as `--dry-run` that stores `true` when given alone, and `bonfire.Flag.Repeatable` for a flag that can be given several times. - `bonfire.Command.Run` receives the context, a `bonfire.Input` and a `bonfire.Output`. +- `bonfire.Command.Long` is optional extended help shown by the help command; without it the description is shown. Set `bonfire.Command.Passthrough` for a command that forwards to another program: flag parsing is turned off, so every argument after the command name, including `--help`, `-h` and `--`, reaches `bonfire.Input.Args` unchanged. Declared flags then appear in the usage text only, and declared arguments are not count-checked. Read arguments with `bonfire.Input.Argument`, scalar and bare flags with `bonfire.Input.Flag`, and repeatable flags with `bonfire.Input.Flags`, which returns the values in the order given: diff --git a/modules/bonfire/README.md b/modules/bonfire/README.md index 70ef075..fce15d3 100644 --- a/modules/bonfire/README.md +++ b/modules/bonfire/README.md @@ -17,6 +17,7 @@ bonfire is the console layer of SummerCMS. Plugins and framework modules describ - Styled status lines: `bonfire.Output.Info`, `bonfire.Output.Success`, `bonfire.Output.Warning` and `bonfire.Output.Error` (the last one writes to the error stream). - Widgets: box-drawn tables (`bonfire.Output.Table`), a spinner around a function (`bonfire.Output.Spinner`) and a progress bar (`bonfire.Output.Progress`); both fall back to plain lines when output is not a terminal. - Prompts: `bonfire.Output.Ask`, `bonfire.Output.Confirm`, `bonfire.Output.Choice` and `bonfire.Output.Secret`, which reads a hidden value on a terminal. Prompts return their defaults when input ends, and `bonfire.Output.Confirm` returns its default without asking when the session is not interactive. +- Passthrough commands (`bonfire.Command.Passthrough`) skip flag parsing, so every argument, including `--help`, reaches `bonfire.Input.Args` unchanged; this suits commands that forward to another program. `bonfire.Command.Long` adds extended help text that the help command shows instead of the description. - 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. @@ -80,7 +81,7 @@ if catalog.Has("blog:import") { | Identifier | Description | |------------|-------------| -| `bonfire.Command` | A console command: name, description, flags, arguments and the `bonfire.Command.Run` function. | +| `bonfire.Command` | A console command: name, description, optional extended help (`bonfire.Command.Long`), flags, arguments, the `bonfire.Command.Passthrough` switch that hands every argument to Run unparsed, and the `bonfire.Command.Run` function. | | `bonfire.Flag` | A string flag; `bonfire.Flag.Bare` allows the flag without a value, `bonfire.Flag.Repeatable` makes it an ordered multi-value flag. | | `bonfire.Arg` | A positional argument with a name, description and required marker. | | `bonfire.Input` | Parsed view handed to `bonfire.Command.Run`: `bonfire.Input.Args`, `bonfire.Input.Argument`, `bonfire.Input.Flag` and `bonfire.Input.Flags`. | diff --git a/modules/bonfire/command.go b/modules/bonfire/command.go index f22e838..5cee867 100644 --- a/modules/bonfire/command.go +++ b/modules/bonfire/command.go @@ -15,6 +15,17 @@ var ErrCommandName = errors.New("bonfire: plugin command name must be namespace: type Command struct { Name string Description string + // Long is optional extended help that cobra shows for the help command + // (help ). When empty, cobra falls back to Description. + Long string + // Passthrough turns off flag parsing for the command: every argument + // after the command name, including --help, -h and --, reaches Run + // unchanged through Input.Args. Declared Flags are registered for the + // usage text only and are never parsed, so Input.Flag returns only + // their defaults. Declared Args shape the usage string but are not + // count-checked, so Input.Argument indexes the raw args. Passthrough + // suits wrapper commands that forward to another program. + Passthrough bool Flags []Flag Args []Arg Run func(ctx context.Context, in Input, out Output) error diff --git a/modules/bonfire/command_test.go b/modules/bonfire/command_test.go index 69e6f1e..97e6536 100644 --- a/modules/bonfire/command_test.go +++ b/modules/bonfire/command_test.go @@ -1,7 +1,11 @@ package bonfire import ( + "bytes" + "context" "io" + "slices" + "strings" "testing" ) @@ -12,3 +16,102 @@ func TestValidBareRuntimeCommandNames(t *testing.T) { } } } + +func capturingCommand(name string, got *[]string) Command { + return Command{ + Name: name, + Description: "Capture args", + Passthrough: true, + Run: func(ctx context.Context, in Input, out Output) error { + *got = append([]string(nil), in.Args()...) + return nil + }, + } +} + +func TestPassthroughCommandReceivesRawArgs(t *testing.T) { + var got []string + var out, errW bytes.Buffer + root, err := NewRootIO("app", []Command{capturingCommand("serve", &got)}, strings.NewReader(""), &out, &errW) + if err != nil { + t.Fatal(err) + } + args := []string{"--addr", ":8123", "-p", "--help", "-h", "--", "x"} + root.SetArgs(append([]string{"serve"}, args...)) + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if !slices.Equal(got, args) { + t.Fatalf("args = %q, want %q", got, args) + } + if out.Len() != 0 || errW.Len() != 0 { + t.Fatalf("passthrough printed output: stdout=%q stderr=%q", out.String(), errW.String()) + } +} + +func TestPassthroughCommandThroughCall(t *testing.T) { + var got []string + var buf bytes.Buffer + args := []string{"--addr", ":8123", "--help"} + if err := Call(context.Background(), []Command{capturingCommand("serve", &got)}, "serve", args, &buf); err != nil { + t.Fatalf("Call: %v", err) + } + if !slices.Equal(got, args) { + t.Fatalf("args = %q, want %q", got, args) + } +} + +func TestPassthroughCommandIgnoresArgCount(t *testing.T) { + var got []string + cmd := capturingCommand("queue:clear", &got) + cmd.Args = []Arg{{Name: "queue", Required: true}} + root, err := NewRoot("app", []Command{cmd}, io.Discard) + if err != nil { + t.Fatal(err) + } + root.SetArgs([]string{"queue:clear"}) + if err := root.Execute(); err != nil { + t.Fatalf("passthrough with zero args: %v", err) + } + if len(got) != 0 { + t.Fatalf("args = %q, want none", got) + } +} + +func TestNonPassthroughCommandRejectsUnknownFlag(t *testing.T) { + root, err := NewRoot("app", []Command{{Name: "migrate", Run: func(context.Context, Input, Output) error { return nil }}}, io.Discard) + if err != nil { + t.Fatal(err) + } + root.SetArgs([]string{"migrate", "--addr", ":8123"}) + err = root.Execute() + if err == nil || !strings.Contains(err.Error(), "unknown flag: --addr") { + t.Fatalf("err = %v, want unknown flag: --addr", err) + } +} + +func TestHelpShowsLongOrDescription(t *testing.T) { + for _, tc := range []struct { + name string + cmd Command + want string + }{ + {"long", Command{Name: "serve", Description: "Short text", Long: "Long text"}, "Long text"}, + {"description", Command{Name: "serve", Description: "Short text"}, "Short text"}, + } { + t.Run(tc.name, func(t *testing.T) { + var buf bytes.Buffer + root, err := NewRoot("app", []Command{tc.cmd}, &buf) + if err != nil { + t.Fatal(err) + } + root.SetArgs([]string{"help", "serve"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if !strings.Contains(buf.String(), tc.want) { + t.Fatalf("help missing %q:\n%s", tc.want, buf.String()) + } + }) + } +} diff --git a/modules/bonfire/root.go b/modules/bonfire/root.go index 46bdffc..81980a8 100644 --- a/modules/bonfire/root.go +++ b/modules/bonfire/root.go @@ -54,6 +54,7 @@ func wrap(c Command, out Output) (*cobra.Command, error) { cmd := &cobra.Command{ Use: commandUsage(c), Short: c.Description, + Long: c.Long, Args: positionalArgs(c.Args), RunE: func(cmd *cobra.Command, args []string) error { if c.Run == nil { @@ -62,6 +63,11 @@ func wrap(c Command, out Output) (*cobra.Command, error) { return c.Run(cmd.Context(), cobraInput{cmd: cmd, spec: c, args: args}, out) }, } + if c.Passthrough { + // Every argument, flags included, reaches Run as given. + cmd.DisableFlagParsing = true + cmd.Args = cobra.ArbitraryArgs + } for _, flag := range c.Flags { if flag.Repeatable { // D-19: an ordered, multi-occurrence string flag (Cobra