Files
summercms/bonfire/output.go
Jakub Zych a6004e490f 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
2026-09-16 13:54:44 +02:00

171 lines
3.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }