feat(01-03): add deterministic rich CLI output

- Inject bonfire.Output from stdin/stdout/stderr with stdlib widgets and x/term
- Degrade spinner, progress, table and prompts without a TTY or color
- Reject plugin command names that are not namespace:verb
This commit is contained in:
Jakub Zych
2026-09-16 13:54:44 +02:00
parent 9b1a25dc94
commit a6004e490f
16 changed files with 884 additions and 78 deletions

View File

@@ -2,12 +2,15 @@ package bonfire
import (
"context"
"fmt"
"io"
"errors"
"strings"
"github.com/spf13/cobra"
)
// ErrCommandName is returned when a plugin command is missing namespace:verb.
var ErrCommandName = errors.New("bonfire: plugin command name must be namespace:verb")
// Command is a console command value collected from plugins or the tool.
type Command struct {
Name string
@@ -39,13 +42,6 @@ type Input interface {
Flag(name string) (string, bool)
}
// Output is an injected writer used by commands instead of package-level stdout.
type Output interface {
io.Writer
Println(a ...any)
Printf(format string, a ...any)
}
type cobraInput struct {
cmd *cobra.Command
spec Command
@@ -84,27 +80,39 @@ func (in cobraInput) Flag(name string) (string, bool) {
return val, true
}
type writerOutput struct {
w io.Writer
func validCommandName(name string) bool {
switch name {
case "build", "dev":
return true
}
ns, verb, ok := strings.Cut(name, ":")
return ok && ns != "" && verb != "" && !strings.Contains(verb, " ")
}
func (o writerOutput) Write(p []byte) (int, error) {
if o.w == nil {
return 0, io.ErrClosedPipe
func commandUsage(c Command) string {
parts := []string{c.Name}
for _, a := range c.Args {
if a.Required {
parts = append(parts, "<"+a.Name+">")
} else {
parts = append(parts, "["+a.Name+"]")
}
}
return o.w.Write(p)
return strings.Join(parts, " ")
}
func (o writerOutput) Println(a ...any) {
if o.w == nil {
return
func positionalArgs(args []Arg) cobra.PositionalArgs {
required := 0
for _, a := range args {
if a.Required {
required++
}
}
fmt.Fprintln(o.w, a...)
}
func (o writerOutput) Printf(format string, a ...any) {
if o.w == nil {
return
if required == 0 {
return cobra.ArbitraryArgs
}
fmt.Fprintf(o.w, format, a...)
if required == len(args) {
return cobra.ExactArgs(required)
}
return cobra.MinimumNArgs(required)
}

170
bonfire/output.go Normal file
View File

@@ -0,0 +1,170 @@
package bonfire
import (
"fmt"
"io"
"os"
"golang.org/x/term"
)
const (
ansiReset = "\x1b[0m"
ansiRed = "\x1b[31m"
ansiGreen = "\x1b[32m"
ansiYellow = "\x1b[33m"
ansiCyan = "\x1b[36m"
ansiDim = "\x1b[90m"
symCheck = "✓"
symCross = "✗"
symWarn = "⚠"
symInfo = "ℹ"
symArrow = "→"
symPrompt = "?"
)
// Output is the injected console used by commands. Tests supply streams.
type Output interface {
io.Writer
Println(a ...any)
Printf(format string, a ...any)
Info(message string)
Success(message string)
Error(message string)
Warning(message string)
Table(headers []string, rows [][]string)
Spinner(message string, fn func() error) error
Progress(total int, fn func(Progress) error) error
Ask(question, def string) (string, error)
Confirm(question string, def bool) (bool, error)
Choice(question string, choices []string, def int) (string, error)
Secret(question string) (string, error)
}
// Progress is a single progress bar advanced from a command.
type Progress interface {
Advance(step int)
}
type console struct {
in io.Reader
out io.Writer
err io.Writer
lookup func(string) string
tty bool
interactive bool
color bool
}
// NewOutput builds Output from injected streams and the process color policy.
func NewOutput(in io.Reader, out, errW io.Writer) Output {
return newConsole(in, out, errW, os.Getenv, nil, nil)
}
func newConsole(in io.Reader, out, errW io.Writer, lookup func(string) string, tty, interactive *bool) *console {
if in == nil {
in = os.Stdin
}
if out == nil {
out = os.Stdout
}
if errW == nil {
errW = out
}
if lookup == nil {
lookup = os.Getenv
}
c := &console{in: in, out: out, err: errW, lookup: lookup}
if tty != nil {
c.tty = *tty
} else {
c.tty = isTerminal(out)
}
if interactive != nil {
c.interactive = *interactive
} else {
c.interactive = isTerminal(in) && c.tty
}
c.color = supportsColor(lookup, c.tty)
return c
}
func supportsColor(lookup func(string) string, tty bool) bool {
if lookup == nil {
lookup = os.Getenv
}
if lookup("NO_COLOR") != "" || lookup("TERM") == "dumb" {
return false
}
if lookup("FORCE_COLOR") != "" {
return true
}
return tty
}
func isTerminal(rw any) bool {
type fd interface{ Fd() uintptr }
f, ok := rw.(fd)
if !ok {
return false
}
return term.IsTerminal(int(f.Fd()))
}
func (c *console) Write(p []byte) (int, error) {
if c.out == nil {
return 0, io.ErrClosedPipe
}
return c.out.Write(p)
}
func (c *console) Println(a ...any) {
if c.out == nil {
return
}
fmt.Fprintln(c.out, a...)
}
func (c *console) Printf(format string, a ...any) {
if c.out == nil {
return
}
fmt.Fprintf(c.out, format, a...)
}
func (c *console) Info(message string) {
c.Println(c.style(ansiCyan, symInfo) + " " + message)
}
func (c *console) Success(message string) {
c.Println(c.style(ansiGreen, symCheck) + " " + message)
}
func (c *console) Error(message string) {
w := c.err
if w == nil {
w = c.out
}
if w == nil {
return
}
fmt.Fprintln(w, c.style(ansiRed, symCross)+" "+message)
}
func (c *console) Warning(message string) {
c.Println(c.style(ansiYellow, symWarn) + " " + message)
}
func (c *console) style(code, s string) string {
if !c.color {
return s
}
return code + s + ansiReset
}
func (c *console) dim(s string) string {
return c.style(ansiDim, s)
}
func boolPtr(v bool) *bool { return &v }

157
bonfire/output_test.go Normal file
View File

@@ -0,0 +1,157 @@
package bonfire
import (
"bytes"
"errors"
"strings"
"testing"
)
func TestNonTTYSpinner(t *testing.T) {
var buf bytes.Buffer
out := NewOutput(strings.NewReader(""), &buf, &buf)
if err := out.Spinner("building", func() error { return nil }); err != nil {
t.Fatal(err)
}
if got := buf.String(); got != "[...] building\n" {
t.Fatalf("spinner = %q", got)
}
}
func TestNonTTYProgress(t *testing.T) {
var buf bytes.Buffer
out := NewOutput(strings.NewReader(""), &buf, &buf)
if err := out.Progress(10, func(p Progress) error {
for i := 0; i < 10; i++ {
p.Advance(1)
}
return nil
}); err != nil {
t.Fatal(err)
}
got := buf.String()
for _, want := range []string{"[1/10] 10%", "[5/10] 50%", "[10/10] 100%"} {
if !strings.Contains(got, want) {
t.Fatalf("missing %q in %q", want, got)
}
}
if strings.Contains(got, "\x1b[") {
t.Fatalf("non-tty progress has ansi: %q", got)
}
}
func TestNonTTYTable(t *testing.T) {
var buf bytes.Buffer
out := NewOutput(strings.NewReader(""), &buf, &buf)
out.Table([]string{"plugin", "status"}, [][]string{{"golem15.blog", "active"}})
got := buf.String()
if got != "plugin\tstatus\ngolem15.blog\tactive\n" {
t.Fatalf("table = %q", got)
}
}
func TestColorPolicy(t *testing.T) {
t.Run("NO_COLOR", func(t *testing.T) {
var buf bytes.Buffer
out := newConsole(strings.NewReader(""), &buf, &buf, func(k string) string {
if k == "NO_COLOR" {
return "1"
}
if k == "FORCE_COLOR" {
return "1"
}
return ""
}, boolPtr(true), boolPtr(true))
out.Success("ok")
if strings.Contains(buf.String(), "\x1b[") {
t.Fatalf("NO_COLOR leaked ansi: %q", buf.String())
}
})
t.Run("TERM=dumb", func(t *testing.T) {
var buf bytes.Buffer
out := newConsole(strings.NewReader(""), &buf, &buf, func(k string) string {
if k == "TERM" {
return "dumb"
}
if k == "FORCE_COLOR" {
return "1"
}
return ""
}, boolPtr(true), boolPtr(true))
out.Info("hi")
if strings.Contains(buf.String(), "\x1b[") {
t.Fatalf("TERM=dumb leaked ansi: %q", buf.String())
}
})
t.Run("FORCE_COLOR", func(t *testing.T) {
var buf bytes.Buffer
out := newConsole(strings.NewReader(""), &buf, &buf, func(k string) string {
if k == "FORCE_COLOR" {
return "1"
}
return ""
}, boolPtr(false), boolPtr(false))
out.Success("ok")
if !strings.Contains(buf.String(), "\x1b[") {
t.Fatalf("FORCE_COLOR did not enable ansi: %q", buf.String())
}
})
t.Run("default non-tty", func(t *testing.T) {
var buf bytes.Buffer
out := NewOutput(strings.NewReader(""), &buf, &buf)
out.Success("ok")
if strings.Contains(buf.String(), "\x1b[") {
t.Fatalf("non-tty default has ansi: %q", buf.String())
}
})
}
func TestPluginCommandRequiresColon(t *testing.T) {
var buf bytes.Buffer
_, err := NewRoot("app", []Command{{Name: "hello", Description: "bad"}}, &buf)
if err == nil || !errors.Is(err, ErrCommandName) {
t.Fatalf("want ErrCommandName, got %v", err)
}
if !strings.Contains(err.Error(), "hello") {
t.Fatalf("error should name the command: %v", err)
}
}
func TestKernelAndNamespacedCommands(t *testing.T) {
var buf bytes.Buffer
root, err := NewRoot("summer", []Command{
{Name: "build", Description: "Build the app"},
{Name: "dev", Description: "Watch and rebuild"},
{Name: "make:plugin", Description: "Scaffold a plugin from a vendor.plugin id"},
{Name: "greeter:hello", Description: "Say hello"},
}, &buf)
if err != nil {
t.Fatal(err)
}
root.SetArgs([]string{"make:plugin", "--help"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if !strings.Contains(buf.String(), "vendor.plugin") && !strings.Contains(buf.String(), "make:plugin") {
t.Fatalf("help = %q", buf.String())
}
}
func TestHelpUsesSharedAdapter(t *testing.T) {
var buf bytes.Buffer
root, err := NewRoot("hello", []Command{{
Name: "greeter:hello",
Description: "Print the configured application name",
}}, &buf)
if err != nil {
t.Fatal(err)
}
root.SetArgs([]string{"greeter:hello", "--help"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
got := buf.String()
if !strings.Contains(got, "greeter:hello") || !strings.Contains(got, "Print the configured application name") {
t.Fatalf("help = %q", got)
}
}

133
bonfire/prompts.go Normal file
View File

@@ -0,0 +1,133 @@
package bonfire
import (
"bufio"
"fmt"
"io"
"os"
"strconv"
"strings"
"golang.org/x/term"
)
func (c *console) Ask(question, def string) (string, error) {
c.writeQuestion(question, def)
line, err := c.readLine()
if err != nil {
if err == io.EOF {
return def, nil
}
return "", err
}
if line == "" {
return def, nil
}
return line, nil
}
func (c *console) Confirm(question string, def bool) (bool, error) {
if !c.interactive {
return def, nil
}
hint := "y/N"
if def {
hint = "Y/n"
}
line, err := c.Ask(question+" ["+hint+"]", "")
if err != nil {
return false, err
}
if line == "" {
return def, nil
}
switch strings.ToLower(line) {
case "y", "yes":
return true, nil
case "n", "no":
return false, nil
default:
return def, nil
}
}
func (c *console) Choice(question string, choices []string, def int) (string, error) {
if len(choices) == 0 {
return "", fmt.Errorf("bonfire: choice has no options")
}
if def < 0 || def >= len(choices) {
def = 0
}
c.Println(c.style(ansiCyan, symPrompt) + " " + question)
for i, choice := range choices {
mark := " "
if i == def {
mark = "*"
}
c.Printf(" %s %d) %s\n", mark, i+1, choice)
}
line, err := c.readLine()
if err != nil {
if err == io.EOF {
return choices[def], nil
}
return "", err
}
if line == "" {
return choices[def], nil
}
n, convErr := strconv.Atoi(line)
if convErr != nil || n < 1 || n > len(choices) {
return choices[def], nil
}
return choices[n-1], nil
}
// Secret reads a hidden password on a TTY via term.ReadPassword.
// When stdin is not a terminal it reads a plain line and never logs the value.
func (c *console) Secret(question string) (string, error) {
c.writeQuestion(question, "")
if f, ok := c.in.(*os.File); ok && term.IsTerminal(int(f.Fd())) {
b, err := term.ReadPassword(int(f.Fd()))
c.Println()
if err != nil {
return "", err
}
return string(b), nil
}
line, err := c.readLine()
if err != nil {
if err == io.EOF {
return "", nil
}
return "", err
}
return line, nil
}
func (c *console) writeQuestion(question, def string) {
suffix := ""
if def != "" {
suffix = " " + c.dim("("+def+")")
}
c.Printf("%s %s%s %s ", c.style(ansiCyan, symPrompt), question, suffix, symArrow)
}
func (c *console) readLine() (string, error) {
if c.in == nil {
return "", io.EOF
}
reader, ok := c.in.(*bufio.Reader)
if !ok {
reader = bufio.NewReader(c.in)
}
line, err := reader.ReadString('\n')
line = strings.TrimRight(line, "\r\n")
if err != nil && err != io.EOF {
return "", err
}
if err == io.EOF && line == "" {
return "", io.EOF
}
return strings.TrimSpace(line), nil
}

92
bonfire/prompts_test.go Normal file
View File

@@ -0,0 +1,92 @@
package bonfire
import (
"bytes"
"io"
"strings"
"testing"
)
func TestConfirmUsesDefaultWhenNonTTY(t *testing.T) {
var buf bytes.Buffer
out := NewOutput(strings.NewReader("n\n"), &buf, &buf)
got, err := out.Confirm("continue?", true)
if err != nil {
t.Fatal(err)
}
if !got {
t.Fatal("non-tty confirm should use default true without reading stdin")
}
if buf.Len() != 0 {
t.Fatalf("confirm wrote %q", buf.String())
}
}
func TestAskReadsStdinLine(t *testing.T) {
var buf bytes.Buffer
out := NewOutput(strings.NewReader("alice\n"), &buf, &buf)
got, err := out.Ask("name", "bob")
if err != nil {
t.Fatal(err)
}
if got != "alice" {
t.Fatalf("ask = %q", got)
}
}
func TestAskEOFUsesDefault(t *testing.T) {
var buf bytes.Buffer
out := NewOutput(strings.NewReader(""), &buf, &buf)
got, err := out.Ask("name", "bob")
if err != nil {
t.Fatal(err)
}
if got != "bob" {
t.Fatalf("ask eof = %q", got)
}
}
func TestChoiceReadsStdinLine(t *testing.T) {
var buf bytes.Buffer
out := NewOutput(strings.NewReader("2\n"), &buf, &buf)
got, err := out.Choice("pick", []string{"a", "b", "c"}, 0)
if err != nil {
t.Fatal(err)
}
if got != "b" {
t.Fatalf("choice = %q", got)
}
}
func TestSecretReadsPlainStdinLine(t *testing.T) {
var buf bytes.Buffer
out := NewOutput(strings.NewReader("s3cret\n"), &buf, &buf)
got, err := out.Secret("token")
if err != nil {
t.Fatal(err)
}
if got != "s3cret" {
t.Fatalf("secret = %q", got)
}
if strings.Contains(buf.String(), "s3cret") {
t.Fatal("secret value was written to output")
}
}
func TestClosedStdinDoesNotHang(t *testing.T) {
var buf bytes.Buffer
out := NewOutput(io.NopCloser(strings.NewReader("")), &buf, &buf)
if _, err := out.Ask("name", "def"); err != nil {
t.Fatal(err)
}
ok, err := out.Confirm("ok?", false)
if err != nil {
t.Fatal(err)
}
if ok {
t.Fatal("confirm default false")
}
if _, err := out.Secret("pw"); err != nil {
t.Fatal(err)
}
}

View File

@@ -1,6 +1,7 @@
package bonfire
import (
"fmt"
"io"
"os"
@@ -9,10 +10,22 @@ import (
// NewRoot builds the shared cobra root used by the summer tool and app binaries.
// It accepts command values rather than a party registry to avoid an import cycle.
func NewRoot(name string, commands []Command, out io.Writer) *cobra.Command {
func NewRoot(name string, commands []Command, out io.Writer) (*cobra.Command, error) {
return NewRootIO(name, commands, os.Stdin, out, out)
}
// NewRootIO is NewRoot with injected stdin/stdout/stderr for tests.
func NewRootIO(name string, commands []Command, in io.Reader, out, errW io.Writer) (*cobra.Command, error) {
if out == nil {
out = os.Stdout
}
if errW == nil {
errW = out
}
if in == nil {
in = os.Stdin
}
output := NewOutput(in, out, errW)
root := &cobra.Command{
Use: name,
SilenceUsage: true,
@@ -21,30 +34,32 @@ func NewRoot(name string, commands []Command, out io.Writer) *cobra.Command {
DisableDefaultCmd: true,
},
}
root.SetIn(in)
root.SetOut(out)
root.SetErr(out)
root.SetErr(errW)
for _, command := range commands {
root.AddCommand(wrap(command, out))
cmd, err := wrap(command, output)
if err != nil {
return nil, err
}
root.AddCommand(cmd)
}
return root
return root, nil
}
func wrap(c Command, out io.Writer) *cobra.Command {
func wrap(c Command, out Output) (*cobra.Command, error) {
if !validCommandName(c.Name) {
return nil, fmt.Errorf("%w: %q", ErrCommandName, c.Name)
}
cmd := &cobra.Command{
Use: c.Name,
Use: commandUsage(c),
Short: c.Description,
Args: cobra.ArbitraryArgs,
Args: positionalArgs(c.Args),
RunE: func(cmd *cobra.Command, args []string) error {
if c.Run == nil {
return nil
}
writer := out
if cmd != nil {
if w := cmd.OutOrStdout(); w != nil {
writer = w
}
}
return c.Run(cmd.Context(), cobraInput{cmd: cmd, spec: c, args: args}, writerOutput{w: writer})
return c.Run(cmd.Context(), cobraInput{cmd: cmd, spec: c, args: args}, out)
},
}
for _, flag := range c.Flags {
@@ -54,5 +69,5 @@ func wrap(c Command, out io.Writer) *cobra.Command {
}
cmd.Flags().String(flag.Name, flag.Default, flag.Description)
}
return cmd
return cmd, nil
}

210
bonfire/widgets.go Normal file
View File

@@ -0,0 +1,210 @@
package bonfire
import (
"fmt"
"strings"
"sync"
"time"
"unicode/utf8"
)
var spinnerFrames = []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'}
const (
boxTopLeft = "┌"
boxTopRight = "┐"
boxBottomLeft = "└"
boxBottomRight = "┘"
boxHorizontal = "─"
boxVertical = "│"
boxTeeRight = "├"
boxTeeLeft = "┤"
boxTeeDown = "┬"
boxTeeUp = "┴"
boxCross = "┼"
)
func (c *console) Spinner(message string, fn func() error) error {
if fn == nil {
fn = func() error { return nil }
}
if !c.tty {
c.Printf("[...] %s\n", message)
return fn()
}
done := make(chan struct{})
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
i := 0
ticker := time.NewTicker(80 * time.Millisecond)
defer ticker.Stop()
c.Printf("\r%s %s", c.style(ansiCyan, string(spinnerFrames[0])), message)
for {
select {
case <-done:
return
case <-ticker.C:
i++
frame := spinnerFrames[i%len(spinnerFrames)]
c.Printf("\r%s %s", c.style(ansiCyan, string(frame)), message)
}
}
}()
err := fn()
close(done)
wg.Wait()
if err != nil {
c.Printf("\r%s %s\n", c.style(ansiRed, symCross), message)
return err
}
c.Printf("\r%s %s\n", c.style(ansiGreen, symCheck), message)
return nil
}
func (c *console) Progress(total int, fn func(Progress) error) error {
if total < 1 {
total = 1
}
bar := &progressBar{c: c, total: total, lastPct: -1}
if fn == nil {
return nil
}
err := fn(bar)
if c.tty && bar.current >= bar.total {
c.Println()
}
return err
}
type progressBar struct {
c *console
total int
current int
lastPct int
}
func (p *progressBar) Advance(step int) {
if step < 1 {
step = 1
}
p.current += step
if p.current > p.total {
p.current = p.total
}
pct := 0
if p.total > 0 {
pct = p.current * 100 / p.total
}
if p.c.tty {
p.renderTTY(pct)
return
}
if p.lastPct >= 0 && pct/10 == p.lastPct/10 && pct != 100 {
return
}
if pct == 0 && p.lastPct < 0 {
p.lastPct = 0
return
}
if pct%10 == 0 || pct == 100 {
p.c.Printf("[%d/%d] %d%%\n", p.current, p.total, pct)
p.lastPct = pct
}
}
func (p *progressBar) renderTTY(pct int) {
width := 40
filled := 0
if p.total > 0 {
filled = p.current * width / p.total
}
if filled > width {
filled = width
}
bar := strings.Repeat("█", filled) + strings.Repeat(" ", width-filled)
color := progressColor(pct)
p.c.Printf("\r %s %5.1f%% %d/%d", p.c.style(color, bar), float64(pct), p.current, p.total)
}
func progressColor(pct int) string {
switch {
case pct >= 100:
return "\x1b[38;5;34m"
case pct >= 50:
return "\x1b[38;5;36m"
default:
return "\x1b[38;5;37m"
}
}
func (c *console) Table(headers []string, rows [][]string) {
if !c.tty {
c.Println(strings.Join(headers, "\t"))
for _, row := range rows {
c.Println(strings.Join(padRow(row, len(headers)), "\t"))
}
return
}
cols := len(headers)
all := make([][]string, 0, 1+len(rows))
all = append(all, headers)
for _, row := range rows {
all = append(all, padRow(row, cols))
}
widths := make([]int, cols)
for _, row := range all {
for i, cell := range row {
if n := utf8.RuneCountInString(cell); n > widths[i] {
widths[i] = n
}
}
}
c.Println(boxLine(widths, boxTopLeft, boxTeeDown, boxTopRight))
c.Println(boxRow(headers, widths, true, c))
c.Println(boxLine(widths, boxTeeRight, boxCross, boxTeeLeft))
for _, row := range rows {
c.Println(boxRow(padRow(row, cols), widths, false, c))
}
c.Println(boxLine(widths, boxBottomLeft, boxTeeUp, boxBottomRight))
}
func padRow(row []string, n int) []string {
out := make([]string, n)
for i := 0; i < n; i++ {
if i < len(row) {
out[i] = row[i]
}
}
return out
}
func boxLine(widths []int, left, mid, right string) string {
var b strings.Builder
b.WriteString(left)
for i, w := range widths {
if i > 0 {
b.WriteString(mid)
}
b.WriteString(strings.Repeat(boxHorizontal, w+2))
}
b.WriteString(right)
return b.String()
}
func boxRow(cells []string, widths []int, header bool, c *console) string {
var b strings.Builder
b.WriteString(boxVertical)
for i, cell := range cells {
pad := widths[i] - utf8.RuneCountInString(cell)
text := cell
if header {
text = c.style("\x1b[1m", cell)
}
fmt.Fprintf(&b, " %s%s %s", text, strings.Repeat(" ", pad), boxVertical)
}
return b.String()
}

View File

@@ -10,7 +10,11 @@ import (
)
func main() {
root := bonfire.NewRoot("summer", toolCommands(), os.Stdout)
root, err := bonfire.NewRoot("summer", toolCommands(), os.Stdout)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
root.SetArgs(os.Args[1:])
if err := root.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)

View File

@@ -26,7 +26,10 @@ func TestToolCommandNames(t *testing.T) {
}
var buf bytes.Buffer
root := bonfire.NewRoot("summer", toolCommands(), &buf)
root, err := bonfire.NewRoot("summer", toolCommands(), &buf)
if err != nil {
t.Fatal(err)
}
root.SetArgs([]string{"make:plugin", "--help"})
if err := root.Execute(); err != nil {
t.Fatal(err)

View File

@@ -18,17 +18,13 @@ import (
func TestGreeterHelloPrintsLayeredConfig(t *testing.T) {
got := runHello(t, nil)
want := "name=hello-app posts_per_page=10 debug=false extra=hello-from-optional events=ok collected=greeter handled=true"
if got != want {
t.Fatalf("output = %q, want %q", got, want)
}
assertGreeting(t, got, want)
}
func TestGreeterHelloSeesDevelopmentOverlay(t *testing.T) {
got := runHello(t, map[string]string{"SUMMER_ENV": "development"})
want := "name=hello-dev posts_per_page=10 debug=true extra=hello-from-optional events=ok collected=greeter handled=true"
if got != want {
t.Fatalf("output = %q, want %q", got, want)
}
assertGreeting(t, got, want)
}
func TestGreeterHelloSeesEnvVarOverrideOfPluginDefault(t *testing.T) {
@@ -36,9 +32,7 @@ func TestGreeterHelloSeesEnvVarOverrideOfPluginDefault(t *testing.T) {
"SUMMER_GOLEM15__HELLO__POSTS_PER_PAGE": "25",
})
want := "name=hello-app posts_per_page=25 debug=false extra=hello-from-optional events=ok collected=greeter handled=true"
if got != want {
t.Fatalf("output = %q, want %q", got, want)
}
assertGreeting(t, got, want)
}
func runHello(t *testing.T, env map[string]string) string {
@@ -56,9 +50,7 @@ func runHello(t *testing.T, env map[string]string) string {
func TestGreeterRunsWithoutOptionalPlugin(t *testing.T) {
got := runHelloIDs(t, []string{"golem15.hello", "golem15.greeter"}, nil)
want := "name=hello-app posts_per_page=10 debug=false extra= events=ok collected=greeter handled=true"
if got != want {
t.Fatalf("output = %q, want %q", got, want)
}
assertGreeting(t, got, want)
}
func runHelloIDs(t *testing.T, ids []string, env map[string]string) string {
@@ -82,7 +74,10 @@ func runHelloIDs(t *testing.T, ids []string, env map[string]string) string {
}
}
var buf bytes.Buffer
root := bonfire.NewRoot("hello", commands, &buf)
root, err := bonfire.NewRoot("hello", commands, &buf)
if err != nil {
t.Fatalf("NewRoot: %v", err)
}
root.SetArgs([]string{"greeter:hello"})
if err := root.Execute(); err != nil {
t.Fatalf("execute: %v", err)
@@ -104,9 +99,27 @@ func TestBuiltBinaryGreeterHello(t *testing.T) {
if err != nil {
t.Fatalf("bin/hello greeter:hello: %v\n%s", err, out)
}
if got := strings.TrimSpace(string(out)); got != "name=hello-app posts_per_page=10 debug=false extra=hello-from-optional events=ok collected=greeter handled=true" {
t.Fatalf("output = %q, want layered production config with optional extra and events", got)
got := strings.TrimSpace(string(out))
want := "name=hello-app posts_per_page=10 debug=false extra=hello-from-optional events=ok collected=greeter handled=true"
assertGreeting(t, got, want)
}
func assertGreeting(t *testing.T, got, want string) {
t.Helper()
if !strings.Contains(got, "plugin\tstatus") || !strings.Contains(got, "golem15.greeter\tactive") {
t.Fatalf("missing non-tty table:\n%s", got)
}
if lastLine(got) != want {
t.Fatalf("greeting = %q, want %q\nfull:\n%s", lastLine(got), want, got)
}
}
func lastLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.LastIndex(s, "\n"); i >= 0 {
return s[i+1:]
}
return s
}
func runSummerBuild(t *testing.T) string {

View File

@@ -37,7 +37,10 @@ func run(args []string, out io.Writer) error {
commands = append(commands, hasCommands.Commands()...)
}
}
root := bonfire.NewRoot("hello", commands, out)
root, err := bonfire.NewRoot("hello", commands, out)
if err != nil {
return err
}
root.SetArgs(args)
return root.Execute()
}

View File

@@ -82,6 +82,10 @@ func (p *Plugin) Commands() []bonfire.Command {
}
handled = ok
}
out.Table([]string{"plugin", "status"}, [][]string{{"golem15.greeter", "active"}})
if _, err := out.Confirm("show greeting?", true); err != nil {
return err
}
out.Printf("name=%s posts_per_page=%d debug=%t extra=%s events=%s collected=%s handled=%t\n", name, posts, debug, p.extra, events, collected, handled)
return nil
},

View File

@@ -3,8 +3,6 @@ package greeter
import (
"bytes"
"context"
"fmt"
"io"
"os"
"strings"
"testing"
@@ -15,17 +13,6 @@ import (
"git.golem15.com/golem15/summercms/pact"
)
type testOut struct{ bytes.Buffer }
func (o *testOut) Write(p []byte) (int, error) { return o.Buffer.Write(p) }
func (o *testOut) Println(a ...any) { fmt.Fprintln(&o.Buffer, a...) }
func (o *testOut) Printf(format string, a ...any) {
fmt.Fprintf(&o.Buffer, format, a...)
}
var _ bonfire.Output = (*testOut)(nil)
var _ io.Writer = (*testOut)(nil)
type extraMsg struct{ msg string }
func (e extraMsg) Message() string { return e.msg }
@@ -78,9 +65,10 @@ func runHelloCmd(t *testing.T, p *Plugin) string {
if len(cmds) == 0 {
t.Fatal("no commands")
}
var out testOut
if err := cmds[0].Run(context.Background(), nil, &out); err != nil {
var buf bytes.Buffer
out := bonfire.NewOutput(strings.NewReader(""), &buf, &buf)
if err := cmds[0].Run(context.Background(), nil, out); err != nil {
t.Fatalf("Run: %v", err)
}
return strings.TrimSpace(out.String())
return strings.TrimSpace(buf.String())
}

3
go.mod
View File

@@ -11,6 +11,7 @@ require (
github.com/knadh/koanf/providers/file v1.2.1
github.com/knadh/koanf/v2 v2.3.6
github.com/spf13/cobra v1.10.2
golang.org/x/term v0.46.0
)
require (
@@ -22,5 +23,5 @@ require (
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/spf13/pflag v1.0.9 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/sys v0.48.0 // indirect
)

6
go.sum
View File

@@ -38,8 +38,10 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -110,7 +110,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")
fmt.Fprintf(&b, "\troot := bonfire.NewRoot(%s, commands, out)\n", strconv.Quote(m.Binary))
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("}\n")