fix(summer): send a delegate's stderr to the root's error writer through bonfire.ErrWriter (IN-13, quick-261028-rqd)
This commit is contained in:
@@ -38,7 +38,7 @@ Use cases:
|
||||
| `-q` | none | info, success and warning lines hidden; errors kept | printed (`Println`, `Printf`, tables) | defaults, as with `-n` |
|
||||
| `--no-ansi` | none | symbols, no colors | printed | plain line prompts |
|
||||
| `-n` | none | as on the terminal | printed | defaults; `Secret` reads piped stdin or fails with a hint |
|
||||
- Command state for helpers: `bonfire.GlobalsOf`, `bonfire.VerbosityOf`, `bonfire.IsQuiet` and `bonfire.Interactive` read the running command's global options from its `bonfire.Output`.
|
||||
- Command state for helpers: `bonfire.GlobalsOf`, `bonfire.VerbosityOf`, `bonfire.IsQuiet` and `bonfire.Interactive` read the running command's global options from its `bonfire.Output`, and `bonfire.ErrWriter` returns the stream its root's error block goes to (stderr for the real binary, the injected error writer for a root built on buffers).
|
||||
- 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.
|
||||
- Artisan-style command list: a bare binary (or `--help`, or `list`) prints a version header (`bonfire.Version.Header`, read from the build information by `bonfire.BuildVersion`), the usage line, the global options and the commands, top-level names first and then one block per namespace, with aligned descriptions.
|
||||
- Global options on every command: `-v`/`-vv`/`-vvv` (`--verbose`), `-q` (`--quiet`, implies `-n`), `--ansi`/`--no-ansi` (last wins), `-n` (`--no-interaction`) and `--env`. `bonfire.PeekGlobals` reads them from the raw arguments before the root exists, so the generated main can pick the configuration environment and the log verbosity before plugins boot; it reads the `=value` forms the way pflag does (`--verbose=2`, `-v=3`, `--quiet=false`, `--ansi=false`), and a value-taking command flag followed by a global-looking token (`--name -v`) is read as the global before boot, while the command and its error block use cobra's reading; `bonfire.OnGlobals` hooks receive the parsed `bonfire.Globals` before every command runs (roots built by `bonfire.Call` run no hook).
|
||||
@@ -160,6 +160,7 @@ if catalog.Has("blog:import") {
|
||||
| `bonfire.VerbosityOf` | The `-v` level of the command an Output belongs to. |
|
||||
| `bonfire.IsQuiet` | Whether the command runs with `-q`. |
|
||||
| `bonfire.IsTerminal` | Whether the Output writes to a terminal; long-running commands print a summary block for a person there and log records otherwise. |
|
||||
| `bonfire.ErrWriter` | The stream the root's error block goes to for the command an Output belongs to: `os.Stderr` for `bonfire.NewRoot` on `os.Stdout`, the error writer of `bonfire.NewRootIO` and `bonfire.NewOutput`, `os.Stderr` for other Output implementations. summer sends a delegated binary's stderr there. |
|
||||
| `bonfire.Details`, `bonfire.Detail` | Prints a title and aligned label/value lines in the palette (a `Warn` value in the warning color); nothing under `-q`. Used for startup summaries and shutdown reports. |
|
||||
| `bonfire.Interactive` | Whether the Output can ask a person: stdin and stdout are terminals and neither `-n` nor `-q` is set. |
|
||||
| `bonfire.ConfirmProceed` | Asks before a destructive step on an interactive terminal; returns true without asking otherwise. |
|
||||
|
||||
@@ -3,6 +3,8 @@ package bonfire
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
@@ -15,6 +17,7 @@ type consoleState interface {
|
||||
consoleGlobals() Globals
|
||||
consoleInteractive() bool
|
||||
consoleTerminal() bool
|
||||
consoleErrWriter() io.Writer
|
||||
}
|
||||
|
||||
func (c *console) consoleGlobals() Globals { return c.globals }
|
||||
@@ -23,6 +26,13 @@ func (c *console) consoleInteractive() bool { return c.canPrompt() }
|
||||
|
||||
func (c *console) consoleTerminal() bool { return c.tty }
|
||||
|
||||
func (c *console) consoleErrWriter() io.Writer {
|
||||
if c.errStream != nil {
|
||||
return c.errStream
|
||||
}
|
||||
return c.err
|
||||
}
|
||||
|
||||
// GlobalsOf returns the global options of the command out belongs to, or
|
||||
// the zero Globals when out is not a bonfire console.
|
||||
func GlobalsOf(out Output) Globals {
|
||||
@@ -63,6 +73,21 @@ func IsTerminal(out Output) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// ErrWriter returns the stream the root's error block goes to for the
|
||||
// command out belongs to: os.Stderr for a root built by NewRoot on
|
||||
// os.Stdout, the error writer given to NewRootIO or NewOutput (stdout when
|
||||
// NewOutput gets none), and os.Stderr for an Output that is not a bonfire
|
||||
// console. summer sends an app binary's stderr there, so a root built on
|
||||
// buffers captures it while the real summer keeps it on stderr.
|
||||
func ErrWriter(out Output) io.Writer {
|
||||
if s, ok := out.(consoleState); ok {
|
||||
if w := s.consoleErrWriter(); w != nil {
|
||||
return w
|
||||
}
|
||||
}
|
||||
return os.Stderr
|
||||
}
|
||||
|
||||
// Detail is one line of a Details block: a label and its value. Warn
|
||||
// prints the value in the warning color.
|
||||
type Detail struct {
|
||||
|
||||
@@ -44,9 +44,12 @@ type Progress interface {
|
||||
}
|
||||
|
||||
type console struct {
|
||||
in io.Reader
|
||||
out io.Writer
|
||||
err io.Writer
|
||||
in io.Reader
|
||||
out io.Writer
|
||||
err io.Writer
|
||||
// errStream is the stream the root's error block goes to, when the
|
||||
// console belongs to a root (ErrWriter); nil means err.
|
||||
errStream io.Writer
|
||||
lookup func(string) string
|
||||
// tty is true when stdout is a terminal; it picks animated widgets
|
||||
// and boxed tables.
|
||||
|
||||
@@ -4,9 +4,13 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestNonTTYSpinner(t *testing.T) {
|
||||
@@ -387,3 +391,51 @@ func TestHelpUsesSharedAdapter(t *testing.T) {
|
||||
t.Fatalf("help = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// stubOutput is an Output that is not a bonfire console.
|
||||
type stubOutput struct{ Output }
|
||||
|
||||
// TestErrWriter (IN-13): ErrWriter returns the stream the root's error
|
||||
// block goes to: os.Stderr for NewRoot on os.Stdout, the error writer of
|
||||
// NewRootIO and NewOutput (stdout when NewOutput gets none), and os.Stderr
|
||||
// for an Output that is not a bonfire console.
|
||||
func TestErrWriter(t *testing.T) {
|
||||
outputOf := func(t *testing.T, build func([]Command) (*cobra.Command, error)) Output {
|
||||
t.Helper()
|
||||
var got Output
|
||||
cmd := Command{Name: "acme:grab", Description: "Grab the output", Run: func(_ context.Context, _ Input, out Output) error {
|
||||
got = out
|
||||
return nil
|
||||
}}
|
||||
root, err := build([]Command{cmd})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root.SetArgs([]string{"acme:grab"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
out := outputOf(t, func(c []Command) (*cobra.Command, error) { return NewRoot("acme", c, os.Stdout) })
|
||||
if w := ErrWriter(out); w != os.Stderr {
|
||||
t.Errorf("NewRoot on os.Stdout: ErrWriter = %v, want os.Stderr", w)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
out = outputOf(t, func(c []Command) (*cobra.Command, error) {
|
||||
return NewRootIO("acme", c, strings.NewReader(""), &stdout, &stderr)
|
||||
})
|
||||
if w := ErrWriter(out); w != io.Writer(&stderr) {
|
||||
t.Errorf("NewRootIO: ErrWriter = %v, want the error writer", w)
|
||||
}
|
||||
if w := ErrWriter(NewOutput(strings.NewReader(""), &stdout, &stderr)); w != io.Writer(&stderr) {
|
||||
t.Errorf("NewOutput: ErrWriter = %v, want the error writer", w)
|
||||
}
|
||||
if w := ErrWriter(NewOutput(strings.NewReader(""), &stdout, nil)); w != io.Writer(&stdout) {
|
||||
t.Errorf("NewOutput without an error writer: ErrWriter = %v, want stdout", w)
|
||||
}
|
||||
if w := ErrWriter(stubOutput{}); w != os.Stderr {
|
||||
t.Errorf("a non-bonfire Output: ErrWriter = %v, want os.Stderr", w)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,18 +53,26 @@ func OnGlobals(fn func(Globals)) RootOption {
|
||||
// It accepts command values rather than a party registry to avoid an import cycle.
|
||||
// When out is the process stdout, the error block of Execute goes to stderr.
|
||||
func NewRoot(name string, commands []Command, out io.Writer, opts ...RootOption) (*cobra.Command, error) {
|
||||
root, err := NewRootIO(name, commands, os.Stdin, out, out, opts...)
|
||||
root, console, err := newRootIO(name, commands, os.Stdin, out, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil || out == os.Stdout {
|
||||
root.SetErr(os.Stderr)
|
||||
console.errStream = os.Stderr
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
// NewRootIO is NewRoot with injected stdin/stdout/stderr for tests.
|
||||
func NewRootIO(name string, commands []Command, in io.Reader, out, errW io.Writer, opts ...RootOption) (*cobra.Command, error) {
|
||||
root, _, err := newRootIO(name, commands, in, out, errW, opts...)
|
||||
return root, err
|
||||
}
|
||||
|
||||
// newRootIO builds the root of NewRootIO and returns its console too, so
|
||||
// NewRoot can record where it moved the error stream.
|
||||
func newRootIO(name string, commands []Command, in io.Reader, out, errW io.Writer, opts ...RootOption) (*cobra.Command, *console, error) {
|
||||
if out == nil {
|
||||
out = os.Stdout
|
||||
}
|
||||
@@ -84,6 +92,7 @@ func NewRootIO(name string, commands []Command, in io.Reader, out, errW io.Write
|
||||
cfg.version = BuildVersion()
|
||||
}
|
||||
output := newConsole(in, out, errW, os.Getenv, nil, nil)
|
||||
output.errStream = errW
|
||||
root := &cobra.Command{
|
||||
Use: name,
|
||||
SilenceUsage: true,
|
||||
@@ -116,24 +125,24 @@ func NewRootIO(name string, commands []Command, in io.Reader, out, errW io.Write
|
||||
}
|
||||
for _, command := range commands {
|
||||
if seen[command.Name] {
|
||||
return nil, fmt.Errorf("%w: %q", ErrDuplicateCommand, command.Name)
|
||||
return nil, nil, fmt.Errorf("%w: %q", ErrDuplicateCommand, command.Name)
|
||||
}
|
||||
seen[command.Name] = true
|
||||
cmd, err := st.wrap(command, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
root.AddCommand(cmd)
|
||||
}
|
||||
|
||||
listCmd, err := st.wrap(st.listCommand(), false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
root.AddCommand(listCmd)
|
||||
helpCmd, err := st.wrap(st.helpCommand(), false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
root.SetHelpCommand(helpCmd)
|
||||
root.SetHelpFunc(func(*cobra.Command, []string) {
|
||||
@@ -160,7 +169,7 @@ func NewRootIO(name string, commands []Command, in io.Reader, out, errW io.Write
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return root, nil
|
||||
return root, output, nil
|
||||
}
|
||||
|
||||
// rootState is what the list, help and error rendering of one root share.
|
||||
|
||||
Reference in New Issue
Block a user