refactor(10.2-01): nest framework packages under modules

- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
This commit is contained in:
Jakub Zych
2026-09-28 02:21:02 +02:00
parent ac1f6d14f4
commit 5e50b166ef
277 changed files with 303 additions and 303 deletions

145
modules/bonfire/command.go Normal file
View File

@@ -0,0 +1,145 @@
package bonfire
import (
"context"
"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
Description string
Flags []Flag
Args []Arg
Run func(ctx context.Context, in Input, out Output) error
}
// Flag describes a string flag passed to a command.
type Flag struct {
Name string
Shorthand string
Description string
Default string
// Bare allows --flag without a value; cobra stores "true".
Bare bool
// Repeatable registers this flag as an ordered, multi-occurrence
// string flag (Cobra StringSlice) instead of a scalar string flag
// (08-CONTEXT.md D-19; 08-PATTERNS.md "bonfire repeatable-flag gap").
// Repeated `--name=a --name=b` preserves insertion order and is read
// back through Input.Flags, never Input.Flag. Existing scalar/bare
// flags are unaffected: Repeatable defaults to false.
Repeatable bool
}
// Arg describes a positional argument.
type Arg struct {
Name string
Description string
Required bool
}
// Input is the parsed argument and flag view handed to Command.Run.
type Input interface {
Args() []string
Argument(name string) (string, bool)
Flag(name string) (string, bool)
// Flags returns the ordered, repeated values of a Repeatable flag
// (D-19). It returns nil for a scalar/bare flag or an unset repeatable
// flag; callers must use Flag for scalar flags.
Flags(name string) []string
}
type cobraInput struct {
cmd *cobra.Command
spec Command
args []string
}
func (in cobraInput) Args() []string { return in.args }
func (in cobraInput) Argument(name string) (string, bool) {
for i, arg := range in.spec.Args {
if arg.Name == name {
if i < len(in.args) {
return in.args[i], true
}
return "", false
}
}
return "", false
}
// Flags returns the ordered values of a Repeatable (Cobra StringSlice)
// flag. It returns nil if the flag was never registered as repeatable
// (wrong pflag type) or the command has no flag set (08-CONTEXT.md D-19).
func (in cobraInput) Flags(name string) []string {
if in.cmd == nil || in.cmd.Flags() == nil {
return nil
}
vals, err := in.cmd.Flags().GetStringSlice(name)
if err != nil {
return nil
}
return vals
}
func (in cobraInput) Flag(name string) (string, bool) {
if in.cmd == nil || in.cmd.Flags() == nil {
return "", false
}
if !in.cmd.Flags().Changed(name) {
val, err := in.cmd.Flags().GetString(name)
if err != nil || val == "" {
return "", false
}
return val, true
}
val, err := in.cmd.Flags().GetString(name)
if err != nil {
return "", false
}
return val, true
}
func validCommandName(name string) bool {
switch name {
case "build", "dev", "serve", "migrate":
return true
}
ns, verb, ok := strings.Cut(name, ":")
return ok && ns != "" && verb != "" && !strings.Contains(verb, " ")
}
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 strings.Join(parts, " ")
}
func positionalArgs(args []Arg) cobra.PositionalArgs {
required := 0
for _, a := range args {
if a.Required {
required++
}
}
if required == 0 {
return cobra.ArbitraryArgs
}
if required == len(args) {
return cobra.ExactArgs(required)
}
return cobra.MinimumNArgs(required)
}

View File

@@ -0,0 +1,14 @@
package bonfire
import (
"io"
"testing"
)
func TestValidBareRuntimeCommandNames(t *testing.T) {
for _, name := range []string{"serve", "migrate", "migrate:rollback", "migrate:status", "build", "dev"} {
if _, err := NewRoot("app", []Command{{Name: name}}, io.Discard); err != nil {
t.Fatalf("%s: %v", name, err)
}
}
}

170
modules/bonfire/output.go Normal file
View File

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

View File

@@ -0,0 +1,389 @@
package bonfire
import (
"bytes"
"context"
"errors"
"slices"
"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 TestFlagAndArgumentParsing(t *testing.T) {
var buf bytes.Buffer
var gotArgs []string
var gotTarget, gotMode, gotUnset string
var gotTargetOK, gotModeOK, gotUnsetOK bool
root, err := NewRootIO("app", []Command{{
Name: "demo:run",
Description: "Run a demo",
Args: []Arg{
{Name: "target", Description: "who", Required: true},
{Name: "extra", Description: "optional"},
},
Flags: []Flag{
{Name: "mode", Shorthand: "m", Default: "slow", Description: "speed"},
{Name: "unused", Default: "", Description: "empty default"},
},
Run: func(ctx context.Context, in Input, out Output) error {
gotArgs = append([]string(nil), in.Args()...)
gotTarget, gotTargetOK = in.Argument("target")
gotMode, gotModeOK = in.Flag("mode")
gotUnset, gotUnsetOK = in.Flag("unused")
out.Println("ran " + gotTarget)
return nil
},
}}, strings.NewReader(""), &buf, &buf)
if err != nil {
t.Fatal(err)
}
root.SetArgs([]string{"demo:run", "-m", "fast", "world"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if !strings.Contains(buf.String(), "ran world") {
t.Fatalf("captured output = %q", buf.String())
}
if strings.Join(gotArgs, ",") != "world" {
t.Fatalf("Args = %v", gotArgs)
}
if !gotTargetOK || gotTarget != "world" {
t.Fatalf("Argument(target) = (%q, %v)", gotTarget, gotTargetOK)
}
if !gotModeOK || gotMode != "fast" {
t.Fatalf("Flag(mode) = (%q, %v)", gotMode, gotModeOK)
}
if gotUnsetOK || gotUnset != "" {
t.Fatalf("unset flag = (%q, %v)", gotUnset, gotUnsetOK)
}
}
func TestInjectedOutputCapture(t *testing.T) {
var stdout, stderr bytes.Buffer
out := NewOutput(strings.NewReader(""), &stdout, &stderr)
out.Info("heads up")
out.Success("done")
out.Warning("careful")
out.Error("boom")
out.Printf("plain %s\n", "line")
if !strings.Contains(stdout.String(), "heads up") || !strings.Contains(stdout.String(), "done") || !strings.Contains(stdout.String(), "careful") {
t.Fatalf("stdout = %q", stdout.String())
}
if !strings.Contains(stdout.String(), "plain line") {
t.Fatalf("Printf missing from stdout: %q", stdout.String())
}
if !strings.Contains(stderr.String(), "boom") {
t.Fatalf("Error should go to stderr, got %q", stderr.String())
}
if strings.Contains(stdout.String(), "boom") {
t.Fatalf("Error leaked to stdout: %q", stdout.String())
}
if strings.Contains(stdout.String()+stderr.String(), "\x1b[") {
t.Fatalf("non-tty messages have ansi: stdout=%q stderr=%q", stdout.String(), stderr.String())
}
}
func TestNonTTYSpinnerError(t *testing.T) {
var buf bytes.Buffer
out := NewOutput(strings.NewReader(""), &buf, &buf)
err := out.Spinner("building", func() error { return errors.New("compile failed") })
if err == nil || err.Error() != "compile failed" {
t.Fatalf("spinner err = %v", err)
}
if got := buf.String(); got != "[...] building\n" {
t.Fatalf("spinner = %q", got)
}
}
func TestNonTTYProgressSkipsIntraDecile(t *testing.T) {
var buf bytes.Buffer
out := NewOutput(strings.NewReader(""), &buf, &buf)
if err := out.Progress(20, func(p Progress) error {
p.Advance(1) // 5%
p.Advance(1) // 10%
p.Advance(1) // 15%
return nil
}); err != nil {
t.Fatal(err)
}
got := buf.String()
if !strings.Contains(got, "[2/20] 10%") {
t.Fatalf("missing 10%% step in %q", got)
}
if strings.Contains(got, "[1/20]") || strings.Contains(got, "[3/20]") {
t.Fatalf("intra-decile progress leaked: %q", got)
}
}
// TestPhase8RedBonfireFlags is the Phase 8 Wave 7 RED anchor (08-07-PLAN.md
// Task 1, D-19). bonfire.Flag/Input are scalar-only today
// (08-PATTERNS.md "bonfire repeatable-flag gap"): a Repeatable flag
// registered through Command.Flags must preserve every repeated
// `--name=value` occurrence in order via Input.Flags, while an ordinary
// scalar flag declared alongside it keeps working through Input.Flag
// unmodified. It fails with the PHASE8_RED:bonfire-flags sentinel while
// Repeatable is not yet wired into Cobra registration;
// scripts/check-phase8-red.sh verifies this failure is fail-closed.
func TestPhase8RedBonfireFlags(t *testing.T) {
var buf bytes.Buffer
var gotRedirect, gotScope []string
var gotMode string
var gotModeOK bool
root, err := NewRootIO("app", []Command{{
Name: "demo:repeat",
Description: "Demo repeatable flags",
Flags: []Flag{
{Name: "redirect-uri", Repeatable: true, Description: "repeatable redirect URI"},
{Name: "scope", Repeatable: true, Description: "repeatable scope"},
{Name: "mode", Default: "slow", Description: "scalar, unaffected by D-19"},
},
Run: func(ctx context.Context, in Input, out Output) error {
gotRedirect = in.Flags("redirect-uri")
gotScope = in.Flags("scope")
gotMode, gotModeOK = in.Flag("mode")
return nil
},
}}, strings.NewReader(""), &buf, &buf)
if err != nil {
t.Fatal(err)
}
root.SetArgs([]string{
"demo:repeat",
"--redirect-uri=https://a.example/callback",
"--redirect-uri=https://b.example/callback",
"--scope=read",
"--mode=fast",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
wantRedirect := []string{"https://a.example/callback", "https://b.example/callback"}
if !slices.Equal(gotRedirect, wantRedirect) {
t.Fatalf("PHASE8_RED:bonfire-flags: repeated --redirect-uri via Input.Flags = %v, want %v in insertion order", gotRedirect, wantRedirect)
}
if !slices.Equal(gotScope, []string{"read"}) {
t.Fatalf("PHASE8_RED:bonfire-flags: repeated --scope via Input.Flags = %v, want [read]", gotScope)
}
if !gotModeOK || gotMode != "fast" {
t.Fatalf("PHASE8_RED:bonfire-flags: existing scalar --mode via Input.Flag = (%q, %v), want (\"fast\", true) unaffected by Repeatable", gotMode, gotModeOK)
}
}
// TestRepeatableFlagUnsetReturnsEmpty proves an unset Repeatable flag comes
// back as an empty/nil slice, not an error or a panic (D-19 GREEN
// companion to TestPhase8RedBonfireFlags).
func TestRepeatableFlagUnsetReturnsEmpty(t *testing.T) {
var buf bytes.Buffer
var got []string
root, err := NewRootIO("app", []Command{{
Name: "demo:unset",
Flags: []Flag{
{Name: "scope", Repeatable: true},
},
Run: func(ctx context.Context, in Input, out Output) error {
got = in.Flags("scope")
return nil
},
}}, strings.NewReader(""), &buf, &buf)
if err != nil {
t.Fatal(err)
}
root.SetArgs([]string{"demo:unset"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Fatalf("unset repeatable flag = %v, want empty", got)
}
}
// TestRepeatableFlagCoexistsWithBareAndScalar proves a Repeatable flag,
// a Bare flag and a scalar flag on the same command parse independently
// (D-19 acceptance criteria: existing scalar/bare callers stay unaffected).
func TestRepeatableFlagCoexistsWithBareAndScalar(t *testing.T) {
var buf bytes.Buffer
var gotRedirect []string
var gotList, gotListOK bool
var gotName string
root, err := NewRootIO("app", []Command{{
Name: "demo:mixed",
Flags: []Flag{
{Name: "redirect-uri", Repeatable: true},
{Name: "list", Bare: true},
{Name: "name", Default: ""},
},
Run: func(ctx context.Context, in Input, out Output) error {
gotRedirect = in.Flags("redirect-uri")
listVal, ok := in.Flag("list")
gotList, gotListOK = listVal == "true", ok
gotName, _ = in.Flag("name")
return nil
},
}}, strings.NewReader(""), &buf, &buf)
if err != nil {
t.Fatal(err)
}
root.SetArgs([]string{"demo:mixed", "--redirect-uri=https://x.example", "--list", "--name=Catalog"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if !slices.Equal(gotRedirect, []string{"https://x.example"}) {
t.Fatalf("redirect-uri = %v", gotRedirect)
}
if !gotListOK || !gotList {
t.Fatalf("list = (%v, %v), want (true, true)", gotList, gotListOK)
}
if gotName != "Catalog" {
t.Fatalf("name = %q", gotName)
}
}
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
modules/bonfire/prompts.go Normal file
View File

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

View File

@@ -0,0 +1,146 @@
package bonfire
import (
"bytes"
"context"
"errors"
"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 TestChoiceEOFUsesDefault(t *testing.T) {
var buf bytes.Buffer
out := NewOutput(strings.NewReader(""), &buf, &buf)
got, err := out.Choice("pick", []string{"a", "b", "c"}, 2)
if err != nil {
t.Fatal(err)
}
if got != "c" {
t.Fatalf("choice eof = %q", got)
}
}
func TestChoiceEmptyOptionsError(t *testing.T) {
var buf bytes.Buffer
out := NewOutput(strings.NewReader("1\n"), &buf, &buf)
_, err := out.Choice("pick", nil, 0)
if err == nil {
t.Fatal("expected empty choice error")
}
}
func TestSecretNotLeakedOnCommandError(t *testing.T) {
var stdout, stderr bytes.Buffer
root, err := NewRootIO("app", []Command{{
Name: "auth:login",
Description: "login",
Run: func(ctx context.Context, in Input, out Output) error {
secret, err := out.Secret("token")
if err != nil {
return err
}
if secret == "" {
return errors.New("empty token")
}
out.Error("login failed")
return errors.New("login failed")
},
}}, strings.NewReader("s3cret\n"), &stdout, &stderr)
if err != nil {
t.Fatal(err)
}
root.SetArgs([]string{"auth:login"})
runErr := root.Execute()
if runErr == nil {
t.Fatal("expected command error")
}
combined := stdout.String() + stderr.String() + runErr.Error()
if strings.Contains(combined, "s3cret") {
t.Fatalf("secret leaked into streams: stdout=%q stderr=%q err=%v", stdout.String(), stderr.String(), runErr)
}
}
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)
}
}

94
modules/bonfire/root.go Normal file
View File

@@ -0,0 +1,94 @@
package bonfire
import (
"fmt"
"io"
"os"
"github.com/spf13/cobra"
)
// 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, 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,
SilenceErrors: true,
CompletionOptions: cobra.CompletionOptions{
DisableDefaultCmd: true,
},
}
root.SetIn(in)
root.SetOut(out)
root.SetErr(errW)
for _, command := range commands {
cmd, err := wrap(command, output)
if err != nil {
return nil, err
}
root.AddCommand(cmd)
}
return root, nil
}
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: commandUsage(c),
Short: c.Description,
Args: positionalArgs(c.Args),
RunE: func(cmd *cobra.Command, args []string) error {
if c.Run == nil {
return nil
}
return c.Run(cmd.Context(), cobraInput{cmd: cmd, spec: c, args: args}, out)
},
}
for _, flag := range c.Flags {
if flag.Repeatable {
// D-19: an ordered, multi-occurrence string flag (Cobra
// StringSlice), read back through Input.Flags. Default is a
// single-element slice only when non-empty, matching the
// scalar path's zero-value convention.
var def []string
if flag.Default != "" {
def = []string{flag.Default}
}
if flag.Shorthand != "" {
cmd.Flags().StringSliceP(flag.Name, flag.Shorthand, def, flag.Description)
} else {
cmd.Flags().StringSlice(flag.Name, def, flag.Description)
}
continue
}
if flag.Shorthand != "" {
cmd.Flags().StringP(flag.Name, flag.Shorthand, flag.Default, flag.Description)
} else {
cmd.Flags().String(flag.Name, flag.Default, flag.Description)
}
if flag.Bare {
if f := cmd.Flags().Lookup(flag.Name); f != nil {
f.NoOptDefVal = "true"
}
}
}
return cmd, nil
}

210
modules/bonfire/widgets.go Normal file
View File

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