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:
@@ -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
170
bonfire/output.go
Normal 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
157
bonfire/output_test.go
Normal 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
133
bonfire/prompts.go
Normal 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
92
bonfire/prompts_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
210
bonfire/widgets.go
Normal 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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
3
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
|
||||
)
|
||||
|
||||
6
go.sum
6
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=
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user