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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"errors"
|
||||||
"io"
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"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.
|
// Command is a console command value collected from plugins or the tool.
|
||||||
type Command struct {
|
type Command struct {
|
||||||
Name string
|
Name string
|
||||||
@@ -39,13 +42,6 @@ type Input interface {
|
|||||||
Flag(name string) (string, bool)
|
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 {
|
type cobraInput struct {
|
||||||
cmd *cobra.Command
|
cmd *cobra.Command
|
||||||
spec Command
|
spec Command
|
||||||
@@ -84,27 +80,39 @@ func (in cobraInput) Flag(name string) (string, bool) {
|
|||||||
return val, true
|
return val, true
|
||||||
}
|
}
|
||||||
|
|
||||||
type writerOutput struct {
|
func validCommandName(name string) bool {
|
||||||
w io.Writer
|
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) {
|
func commandUsage(c Command) string {
|
||||||
if o.w == nil {
|
parts := []string{c.Name}
|
||||||
return 0, io.ErrClosedPipe
|
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) {
|
func positionalArgs(args []Arg) cobra.PositionalArgs {
|
||||||
if o.w == nil {
|
required := 0
|
||||||
return
|
for _, a := range args {
|
||||||
|
if a.Required {
|
||||||
|
required++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fmt.Fprintln(o.w, a...)
|
if required == 0 {
|
||||||
}
|
return cobra.ArbitraryArgs
|
||||||
|
|
||||||
func (o writerOutput) Printf(format string, a ...any) {
|
|
||||||
if o.w == nil {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
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
|
package bonfire
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
@@ -9,10 +10,22 @@ import (
|
|||||||
|
|
||||||
// NewRoot builds the shared cobra root used by the summer tool and app binaries.
|
// 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.
|
// 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 {
|
if out == nil {
|
||||||
out = os.Stdout
|
out = os.Stdout
|
||||||
}
|
}
|
||||||
|
if errW == nil {
|
||||||
|
errW = out
|
||||||
|
}
|
||||||
|
if in == nil {
|
||||||
|
in = os.Stdin
|
||||||
|
}
|
||||||
|
output := NewOutput(in, out, errW)
|
||||||
root := &cobra.Command{
|
root := &cobra.Command{
|
||||||
Use: name,
|
Use: name,
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
@@ -21,30 +34,32 @@ func NewRoot(name string, commands []Command, out io.Writer) *cobra.Command {
|
|||||||
DisableDefaultCmd: true,
|
DisableDefaultCmd: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
root.SetIn(in)
|
||||||
root.SetOut(out)
|
root.SetOut(out)
|
||||||
root.SetErr(out)
|
root.SetErr(errW)
|
||||||
for _, command := range commands {
|
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{
|
cmd := &cobra.Command{
|
||||||
Use: c.Name,
|
Use: commandUsage(c),
|
||||||
Short: c.Description,
|
Short: c.Description,
|
||||||
Args: cobra.ArbitraryArgs,
|
Args: positionalArgs(c.Args),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if c.Run == nil {
|
if c.Run == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
writer := out
|
return c.Run(cmd.Context(), cobraInput{cmd: cmd, spec: c, args: args}, 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})
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, flag := range c.Flags {
|
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)
|
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() {
|
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:])
|
root.SetArgs(os.Args[1:])
|
||||||
if err := root.Execute(); err != nil {
|
if err := root.Execute(); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ func TestToolCommandNames(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
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"})
|
root.SetArgs([]string{"make:plugin", "--help"})
|
||||||
if err := root.Execute(); err != nil {
|
if err := root.Execute(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -18,17 +18,13 @@ import (
|
|||||||
func TestGreeterHelloPrintsLayeredConfig(t *testing.T) {
|
func TestGreeterHelloPrintsLayeredConfig(t *testing.T) {
|
||||||
got := runHello(t, nil)
|
got := runHello(t, nil)
|
||||||
want := "name=hello-app posts_per_page=10 debug=false extra=hello-from-optional events=ok collected=greeter handled=true"
|
want := "name=hello-app posts_per_page=10 debug=false extra=hello-from-optional events=ok collected=greeter handled=true"
|
||||||
if got != want {
|
assertGreeting(t, got, want)
|
||||||
t.Fatalf("output = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGreeterHelloSeesDevelopmentOverlay(t *testing.T) {
|
func TestGreeterHelloSeesDevelopmentOverlay(t *testing.T) {
|
||||||
got := runHello(t, map[string]string{"SUMMER_ENV": "development"})
|
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"
|
want := "name=hello-dev posts_per_page=10 debug=true extra=hello-from-optional events=ok collected=greeter handled=true"
|
||||||
if got != want {
|
assertGreeting(t, got, want)
|
||||||
t.Fatalf("output = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGreeterHelloSeesEnvVarOverrideOfPluginDefault(t *testing.T) {
|
func TestGreeterHelloSeesEnvVarOverrideOfPluginDefault(t *testing.T) {
|
||||||
@@ -36,9 +32,7 @@ func TestGreeterHelloSeesEnvVarOverrideOfPluginDefault(t *testing.T) {
|
|||||||
"SUMMER_GOLEM15__HELLO__POSTS_PER_PAGE": "25",
|
"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"
|
want := "name=hello-app posts_per_page=25 debug=false extra=hello-from-optional events=ok collected=greeter handled=true"
|
||||||
if got != want {
|
assertGreeting(t, got, want)
|
||||||
t.Fatalf("output = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func runHello(t *testing.T, env map[string]string) string {
|
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) {
|
func TestGreeterRunsWithoutOptionalPlugin(t *testing.T) {
|
||||||
got := runHelloIDs(t, []string{"golem15.hello", "golem15.greeter"}, nil)
|
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"
|
want := "name=hello-app posts_per_page=10 debug=false extra= events=ok collected=greeter handled=true"
|
||||||
if got != want {
|
assertGreeting(t, got, want)
|
||||||
t.Fatalf("output = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func runHelloIDs(t *testing.T, ids []string, env map[string]string) string {
|
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
|
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"})
|
root.SetArgs([]string{"greeter:hello"})
|
||||||
if err := root.Execute(); err != nil {
|
if err := root.Execute(); err != nil {
|
||||||
t.Fatalf("execute: %v", err)
|
t.Fatalf("execute: %v", err)
|
||||||
@@ -104,9 +99,27 @@ func TestBuiltBinaryGreeterHello(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("bin/hello greeter:hello: %v\n%s", err, out)
|
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" {
|
got := strings.TrimSpace(string(out))
|
||||||
t.Fatalf("output = %q, want layered production config with optional extra and events", got)
|
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 {
|
func runSummerBuild(t *testing.T) string {
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ func run(args []string, out io.Writer) error {
|
|||||||
commands = append(commands, hasCommands.Commands()...)
|
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)
|
root.SetArgs(args)
|
||||||
return root.Execute()
|
return root.Execute()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ func (p *Plugin) Commands() []bonfire.Command {
|
|||||||
}
|
}
|
||||||
handled = ok
|
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)
|
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
|
return nil
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ package greeter
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -15,17 +13,6 @@ import (
|
|||||||
"git.golem15.com/golem15/summercms/pact"
|
"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 }
|
type extraMsg struct{ msg string }
|
||||||
|
|
||||||
func (e extraMsg) Message() string { return e.msg }
|
func (e extraMsg) Message() string { return e.msg }
|
||||||
@@ -78,9 +65,10 @@ func runHelloCmd(t *testing.T, p *Plugin) string {
|
|||||||
if len(cmds) == 0 {
|
if len(cmds) == 0 {
|
||||||
t.Fatal("no commands")
|
t.Fatal("no commands")
|
||||||
}
|
}
|
||||||
var out testOut
|
var buf bytes.Buffer
|
||||||
if err := cmds[0].Run(context.Background(), nil, &out); err != nil {
|
out := bonfire.NewOutput(strings.NewReader(""), &buf, &buf)
|
||||||
|
if err := cmds[0].Run(context.Background(), nil, out); err != nil {
|
||||||
t.Fatalf("Run: %v", err)
|
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/providers/file v1.2.1
|
||||||
github.com/knadh/koanf/v2 v2.3.6
|
github.com/knadh/koanf/v2 v2.3.6
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
|
golang.org/x/term v0.46.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -22,5 +23,5 @@ require (
|
|||||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||||
github.com/spf13/pflag v1.0.9 // indirect
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // 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=
|
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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
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.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
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 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 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
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\tcommands = append(commands, hasCommands.Commands()...)\n")
|
||||||
b.WriteString("\t\t}\n")
|
b.WriteString("\t\t}\n")
|
||||||
b.WriteString("\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("\troot.SetArgs(args)\n")
|
||||||
b.WriteString("\treturn root.Execute()\n")
|
b.WriteString("\treturn root.Execute()\n")
|
||||||
b.WriteString("}\n")
|
b.WriteString("}\n")
|
||||||
|
|||||||
Reference in New Issue
Block a user