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:
145
modules/bonfire/command.go
Normal file
145
modules/bonfire/command.go
Normal 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)
|
||||
}
|
||||
14
modules/bonfire/command_test.go
Normal file
14
modules/bonfire/command_test.go
Normal 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
170
modules/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 }
|
||||
389
modules/bonfire/output_test.go
Normal file
389
modules/bonfire/output_test.go
Normal 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
133
modules/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
|
||||
}
|
||||
146
modules/bonfire/prompts_test.go
Normal file
146
modules/bonfire/prompts_test.go
Normal 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
94
modules/bonfire/root.go
Normal 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
210
modules/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()
|
||||
}
|
||||
Reference in New Issue
Block a user