diff --git a/bonfire/command.go b/bonfire/command.go index 94c2df8..737d267 100644 --- a/bonfire/command.go +++ b/bonfire/command.go @@ -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) } diff --git a/bonfire/output.go b/bonfire/output.go new file mode 100644 index 0000000..afac4dc --- /dev/null +++ b/bonfire/output.go @@ -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 } diff --git a/bonfire/output_test.go b/bonfire/output_test.go new file mode 100644 index 0000000..18f964d --- /dev/null +++ b/bonfire/output_test.go @@ -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) + } +} diff --git a/bonfire/prompts.go b/bonfire/prompts.go new file mode 100644 index 0000000..f5cef95 --- /dev/null +++ b/bonfire/prompts.go @@ -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 +} diff --git a/bonfire/prompts_test.go b/bonfire/prompts_test.go new file mode 100644 index 0000000..733a247 --- /dev/null +++ b/bonfire/prompts_test.go @@ -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) + } +} diff --git a/bonfire/root.go b/bonfire/root.go index 05c7326..54d40c1 100644 --- a/bonfire/root.go +++ b/bonfire/root.go @@ -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 } diff --git a/bonfire/widgets.go b/bonfire/widgets.go new file mode 100644 index 0000000..2509cff --- /dev/null +++ b/bonfire/widgets.go @@ -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() +} diff --git a/cmd/summer/main.go b/cmd/summer/main.go index 57af315..3d386e4 100644 --- a/cmd/summer/main.go +++ b/cmd/summer/main.go @@ -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) diff --git a/cmd/summer/main_test.go b/cmd/summer/main_test.go index 4c04e66..fdc0d89 100644 --- a/cmd/summer/main_test.go +++ b/cmd/summer/main_test.go @@ -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) diff --git a/examples/hello/hello_test.go b/examples/hello/hello_test.go index 9a877e3..ae4f9da 100644 --- a/examples/hello/hello_test.go +++ b/examples/hello/hello_test.go @@ -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 { diff --git a/examples/hello/main.go b/examples/hello/main.go index 7bfb69b..e4200cf 100644 --- a/examples/hello/main.go +++ b/examples/hello/main.go @@ -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() } diff --git a/examples/hello/plugins/greeter/plugin.go b/examples/hello/plugins/greeter/plugin.go index 3a7f16a..a5a94e9 100644 --- a/examples/hello/plugins/greeter/plugin.go +++ b/examples/hello/plugins/greeter/plugin.go @@ -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 }, diff --git a/examples/hello/plugins/greeter/plugin_test.go b/examples/hello/plugins/greeter/plugin_test.go index 7bc5e59..bb87b09 100644 --- a/examples/hello/plugins/greeter/plugin_test.go +++ b/examples/hello/plugins/greeter/plugin_test.go @@ -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()) } diff --git a/go.mod b/go.mod index 8824788..7d2d10f 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index ffb529b..b822701 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/build/build.go b/internal/build/build.go index 9294c4e..4c6d134 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -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")