// Package consolekit holds the console types and helpers shared by bonfire // and diary without the TUI stack: the color mode, the palette, color // detection, the control-character sanitizer, the global options and the // hook that prints a line above a running live view. It imports lipgloss and // colorprofile only, so diary (and the security modules that log through // it) never link cobra, bubbletea, bubbles or huh. bonfire re-exports every // identifier under its own name. package consolekit import ( "io" "os" "strings" "sync/atomic" "unicode" "unicode/utf8" "charm.land/lipgloss/v2" "github.com/charmbracelet/colorprofile" ) // ColorMode selects how console output decides on color: from the terminal // and the environment, always, or never. type ColorMode int const ( // ColorAuto colors output when the writer is a terminal and the // environment allows it (NO_COLOR and TERM=dumb disable color, // FORCE_COLOR enables it on any writer). ColorAuto ColorMode = iota // ColorAlways colors output whatever the writer is (--ansi). ColorAlways // ColorNever writes no escape codes at all (--no-ansi). ColorNever ) // Palette is the shared console look: the status styles of list, help, // widgets and log lines. Render through a writer that applies the color // profile (DetectProfile), because lipgloss always emits ANSI codes. type Palette struct { // Success marks completed work (green). Success lipgloss.Style // Current marks the item in progress and informational lines (cyan). Current lipgloss.Style // Warning marks warnings (yellow). Warning lipgloss.Style // Error marks failures (red). Error lipgloss.Style // ErrorBold marks fatal failures and error levels (bold red). ErrorBold lipgloss.Style // Muted marks secondary detail such as times and keys (gray). Muted lipgloss.Style // Header marks headings and names (bold). Header lipgloss.Style } // DefaultPalette returns the console palette, ported from SSU's status // styles: green success, cyan current, yellow warning, red and bold red // errors, gray (240) muted text and bold headers. func DefaultPalette() Palette { return Palette{ Success: lipgloss.NewStyle().Foreground(lipgloss.Color("2")), Current: lipgloss.NewStyle().Foreground(lipgloss.Color("6")), Warning: lipgloss.NewStyle().Foreground(lipgloss.Color("3")), Error: lipgloss.NewStyle().Foreground(lipgloss.Color("1")), ErrorBold: lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true), Muted: lipgloss.NewStyle().Foreground(lipgloss.Color("240")), Header: lipgloss.NewStyle().Bold(true), } } // DetectProfile returns the color profile for console output written to w. // ColorNever gives colorprofile.NoTTY (no escape codes at all). ColorAlways // gives the detected profile raised to at least 256 colors. ColorAuto treats // a non-empty FORCE_COLOR as ColorAlways unless NO_COLOR is set or TERM is // dumb, and otherwise detects the profile from w and environ, which honours // NO_COLOR and TERM=dumb. A nil environ means os.Environ(). func DetectProfile(w io.Writer, mode ColorMode, environ []string) colorprofile.Profile { if mode == ColorNever { return colorprofile.NoTTY } if environ == nil { environ = os.Environ() } if mode == ColorAuto && forceColor(environ) { mode = ColorAlways } p := colorprofile.Detect(w, environ) if mode == ColorAlways && p < colorprofile.ANSI256 { p = colorprofile.ANSI256 } return p } func forceColor(environ []string) bool { if envValue(environ, "NO_COLOR") != "" || envValue(environ, "TERM") == "dumb" { return false } return envValue(environ, "FORCE_COLOR") != "" } func envValue(environ []string, key string) string { for i := len(environ) - 1; i >= 0; i-- { k, v, ok := strings.Cut(environ[i], "=") if ok && k == key { return v } } return "" } // Sanitize removes control characters other than tab from s; carriage // returns and line feeds become spaces, so a multi-line value stays on one // line. Text from outside the operator's control (database or remote error // text, names an administrator typed) then cannot move the cursor, clear // the screen or set the window title. Invalid UTF-8 becomes U+FFFD first, // so a raw 8-bit C1 byte (such as 0x9b, CSI) cannot reach a terminal that // honours it, and the result is always valid UTF-8. A clean, valid string // is returned unchanged. func Sanitize(s string) string { clean := utf8.ValidString(s) if clean { for _, r := range s { if r != '\t' && unicode.IsControl(r) { clean = false break } } } if clean { return s } s = strings.ToValidUTF8(s, "�") var b strings.Builder for _, r := range s { switch { case r == '\n' || r == '\r': b.WriteByte(' ') case r != '\t' && unicode.IsControl(r): default: b.WriteRune(r) } } return b.String() } // MaxVerbosity caps -v, -vv and -vvv (and repeated --verbose). const MaxVerbosity = 3 // Globals are the global console options every command accepts: -v, -vv and // -vvv (--verbose), -q (--quiet), --ansi and --no-ansi, -n // (--no-interaction) and --env. type Globals struct { // Verbosity is 0 by default and 1 to 3 for -v, -vv and -vvv. Verbosity int // Quiet suppresses decoration and info lines; it implies NoInteraction. Quiet bool // Color is ColorAlways for --ansi, ColorNever for --no-ansi and // ColorAuto otherwise; the last of the two flags wins. Color ColorMode // NoInteraction makes prompts return their defaults without asking. NoInteraction bool // Env is the --env value: the environment the command runs under. Env string } // Normalize caps the verbosity to 0..MaxVerbosity and lets Quiet imply // NoInteraction. func Normalize(g Globals) Globals { g.Verbosity = min(max(g.Verbosity, 0), MaxVerbosity) if g.Quiet { g.NoInteraction = true } return g } // Args re-serialises the globals as command-line flags, for forwarding to // another program: -v, -vv or -vvv, -q, -n, --ansi or --no-ansi and // --env=. The zero value gives no arguments. func (g Globals) Args() []string { var out []string if v := min(g.Verbosity, MaxVerbosity); v > 0 { out = append(out, "-"+strings.Repeat("v", v)) } if g.Quiet { out = append(out, "-q") } if g.NoInteraction && !g.Quiet { out = append(out, "-n") } switch g.Color { case ColorAlways: out = append(out, "--ansi") case ColorNever: out = append(out, "--no-ansi") } if g.Env != "" { out = append(out, "--env="+g.Env) } return out } // printAboveHook is the function PrintAbove calls; bonfire registers its // live view printer when it is linked. var printAboveHook atomic.Pointer[func(string) bool] // SetPrintAbove registers fn as the printer PrintAbove calls; nil removes // it. bonfire registers its live view printer in an init function. func SetPrintAbove(fn func(string) bool) { if fn == nil { printAboveHook.Store(nil) return } printAboveHook.Store(&fn) } // PrintAbove prints s, one or more lines, above the running live view // through the registered printer and reports whether it did. Without a // printer (bonfire is not linked, so no live view can run) it reports // false and the caller writes s itself. func PrintAbove(s string) bool { if fn := printAboveHook.Load(); fn != nil { return (*fn)(s) } return false }