From f3f87f3cc4e28bcbb6a48e7d0a089ca387e5a233 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 28 Oct 2026 20:46:32 +0100 Subject: [PATCH] refactor(diary): share the console types through internal/consolekit so diary no longer links bonfire (IN-07, quick-261028-rqd) --- internal/consolekit/consolekit.go | 226 +++++++++++++++++++++++++ internal/consolekit/consolekit_test.go | 44 +++++ modules/bonfire/README.md | 5 +- modules/bonfire/errors.go | 28 +-- modules/bonfire/globals.go | 71 ++------ modules/bonfire/globals_test.go | 4 +- modules/bonfire/live.go | 7 + modules/bonfire/styles.go | 76 ++------- modules/diary/README.md | 4 +- modules/diary/channels.go | 4 +- modules/diary/console.go | 18 +- modules/diary/deps_test.go | 31 ++++ modules/diary/diary.go | 11 +- scripts/check-phase19.8.sh | 4 +- 14 files changed, 364 insertions(+), 169 deletions(-) create mode 100644 internal/consolekit/consolekit.go create mode 100644 internal/consolekit/consolekit_test.go create mode 100644 modules/diary/deps_test.go diff --git a/internal/consolekit/consolekit.go b/internal/consolekit/consolekit.go new file mode 100644 index 00000000..d5589fe9 --- /dev/null +++ b/internal/consolekit/consolekit.go @@ -0,0 +1,226 @@ +// 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 +} diff --git a/internal/consolekit/consolekit_test.go b/internal/consolekit/consolekit_test.go new file mode 100644 index 00000000..d7b76a8b --- /dev/null +++ b/internal/consolekit/consolekit_test.go @@ -0,0 +1,44 @@ +package consolekit + +import ( + "strings" + "testing" +) + +// TestPrintAboveHook: without a registered printer PrintAbove reports +// false; with one it forwards the text and its answer; SetPrintAbove(nil) +// removes it again. +func TestPrintAboveHook(t *testing.T) { + t.Cleanup(func() { SetPrintAbove(nil) }) + SetPrintAbove(nil) + if PrintAbove("x") { + t.Fatal("PrintAbove without a printer reported true") + } + var got []string + SetPrintAbove(func(s string) bool { + got = append(got, s) + return true + }) + if !PrintAbove("line") || strings.Join(got, ",") != "line" { + t.Fatalf("PrintAbove with a printer: got %q", got) + } + SetPrintAbove(nil) + if PrintAbove("again") || len(got) != 1 { + t.Fatalf("PrintAbove after SetPrintAbove(nil): got %q", got) + } +} + +// TestNormalizeAndArgs: Normalize caps the verbosity and lets Quiet imply +// NoInteraction; Args emits the flags bonfire forwards. +func TestNormalizeAndArgs(t *testing.T) { + if g := Normalize(Globals{Verbosity: 9, Quiet: true}); g.Verbosity != MaxVerbosity || !g.NoInteraction { + t.Errorf("Normalize = %+v", g) + } + if g := Normalize(Globals{Verbosity: -2}); g.Verbosity != 0 { + t.Errorf("Normalize negative = %+v", g) + } + g := Globals{Verbosity: 2, Quiet: true, NoInteraction: true, Color: ColorNever, Env: "dev"} + if got := strings.Join(g.Args(), " "); got != "-vv -q --no-ansi --env=dev" { + t.Errorf("Args = %q", got) + } +} diff --git a/modules/bonfire/README.md b/modules/bonfire/README.md index 1a65b17c..bc72a312 100644 --- a/modules/bonfire/README.md +++ b/modules/bonfire/README.md @@ -125,9 +125,8 @@ if catalog.Has("blog:import") { | `bonfire.RootOption` | An option of `bonfire.NewRoot` and `bonfire.NewRootIO`. | | `bonfire.WithVersion` | Sets the `bonfire.Version` shown in the list header; without it the root reads `bonfire.BuildVersion`. | | `bonfire.OnGlobals` | Registers a function that receives the parsed `bonfire.Globals` before every command of the root runs. | -| `bonfire.Globals` | The global options: `Verbosity` (0 to 3), `Quiet`, `Color` (a `bonfire.ColorMode`), `NoInteraction` and `Env`. | +| `bonfire.Globals` | The global options: `Verbosity` (0 to 3), `Quiet`, `Color` (a `bonfire.ColorMode`), `NoInteraction` and `Env`. Its method `Args` turns them back into flags (`-vv`, `-q`, `-n`, `--ansi` or `--no-ansi`, `--env=`) for forwarding. `bonfire.Globals`, `bonfire.ColorMode` and `bonfire.Palette` are aliases of the types in `internal/consolekit`, which diary shares without linking bonfire. | | `bonfire.PeekGlobals` | Reads `bonfire.Globals` from raw arguments without failing, `=value` forms included; stops at `--`. | -| `bonfire.Globals.Args` | Turns `bonfire.Globals` back into flags (`-vv`, `-q`, `-n`, `--ansi` or `--no-ansi`, `--env=`) for forwarding. | | `bonfire.Version` | Application and framework versions of a binary (`App`, `Framework`, `Local`). | | `bonfire.BuildVersion` | Reads the `bonfire.Version` from the binary's build information. | | `bonfire.Version.Header` | The list header: `SummerCMS acme v1.4.0 (framework v0.3.2)`. | @@ -192,7 +191,7 @@ Without these variables, color is enabled only when stdout is a terminal. `--ans ## Dependencies -- SummerCMS modules: `sunscreen` (scrubs error messages); the framework-internal package `internal/argmask` (masks secret flag values in the Try line, shared with summer's delegate errors). +- SummerCMS modules: `sunscreen` (scrubs error messages); the framework-internal packages `internal/argmask` (masks secret flag values in the Try line, shared with summer's delegate errors) and `internal/consolekit` (the color mode, palette, color detection, sanitizer, global options and the print-above hook, shared with diary). - Third-party: `github.com/spf13/cobra`, `github.com/spf13/pflag`, `golang.org/x/term`, `charm.land/lipgloss/v2` (with `lipgloss/v2/table`), `charm.land/bubbletea/v2`, `charm.land/bubbles/v2` (spinner, progress, key), `charm.land/huh/v2` and `github.com/charmbracelet/colorprofile` (decided in `.planning/notes/2026-10-21-console-charm-stack.md`). - Standard library: `bufio`, `context`, `encoding/json`, `errors`, `fmt`, `io`, `os`, `path/filepath`, `runtime/debug`, `slices`, `strconv`, `strings`, `sync`, `time`, `unicode/utf8`. diff --git a/modules/bonfire/errors.go b/modules/bonfire/errors.go index c3999edf..93301720 100644 --- a/modules/bonfire/errors.go +++ b/modules/bonfire/errors.go @@ -6,10 +6,9 @@ import ( "os" "path/filepath" "strings" - "unicode" - "unicode/utf8" "git.golem15.com/golem15/summercms/internal/argmask" + "git.golem15.com/golem15/summercms/internal/consolekit" "git.golem15.com/golem15/summercms/modules/sunscreen" "github.com/charmbracelet/colorprofile" "github.com/spf13/cobra" @@ -134,30 +133,7 @@ func Reported(err error) error { // 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, "\uFFFD") - 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() + return consolekit.Sanitize(s) } // sanitizeLines applies Sanitize to every line of s, keeping the line diff --git a/modules/bonfire/globals.go b/modules/bonfire/globals.go index 6c972f2d..e0fbf296 100644 --- a/modules/bonfire/globals.go +++ b/modules/bonfire/globals.go @@ -2,6 +2,8 @@ package bonfire import ( "strconv" + + "git.golem15.com/golem15/summercms/internal/consolekit" "strings" "github.com/spf13/cobra" @@ -9,24 +11,16 @@ import ( ) // maxVerbosity caps -v, -vv and -vvv (and repeated --verbose). -const maxVerbosity = 3 +const maxVerbosity = consolekit.MaxVerbosity // 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 -} +// (--no-interaction) and --env. The fields are Verbosity (0, or 1 to 3 for +// -v to -vvv), Quiet (implies NoInteraction), Color (ColorAlways for +// --ansi, ColorNever for --no-ansi, ColorAuto otherwise; the last of the +// two flags wins), NoInteraction and Env. Its Args method re-serialises +// them as flags for forwarding to another program. +type Globals = consolekit.Globals // Global option names and shorthands, reserved on every command. const ( @@ -76,7 +70,7 @@ func PeekGlobals(args []string) Globals { } switch { case a == "--": - return g.normalize() + return normalize(g) case a == "--"+flagVerbose: g.Verbosity++ case a == "-q" || a == "--"+flagQuiet: @@ -93,10 +87,10 @@ func PeekGlobals(args []string) Globals { i++ } case isShorthandCluster(a): - g.peekShorthands(a[1:]) + peekShorthands(&g, a[1:]) } } - return g.normalize() + return normalize(g) } // peekCount applies a --verbose=N or -v=N value the way pflag's count flag @@ -138,7 +132,7 @@ func isShorthandCluster(a string) bool { // -q -n, -vvq is -v -v -q, -v=3 sets the count, -qn=false turns -n off). It // stops at the first letter that is not a global shorthand: that may be a // command flag whose value is the rest of the token. -func (g *Globals) peekShorthands(cluster string) { +func peekShorthands(g *Globals, cluster string) { for i := 0; i < len(cluster); i++ { r := cluster[i] if i+1 < len(cluster) && cluster[i+1] == '=' { @@ -167,38 +161,9 @@ func (g *Globals) peekShorthands(cluster string) { } } -func (g Globals) normalize() 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, "--"+flagANSI) - case ColorNever: - out = append(out, "--"+flagNoANSI) - } - if g.Env != "" { - out = append(out, "--"+flagEnv+"="+g.Env) - } - return out +// normalize caps the verbosity and lets Quiet imply NoInteraction. +func normalize(g Globals) Globals { + return consolekit.Normalize(g) } // globalFlags holds the parsed persistent flags of one root. @@ -250,13 +215,13 @@ func (gf *globalFlags) register(fs *pflag.FlagSet) { } func (gf *globalFlags) globals() Globals { - return Globals{ + return normalize(Globals{ Verbosity: gf.verbosity, Quiet: gf.quiet, Color: gf.color, NoInteraction: gf.noInteraction, Env: gf.env, - }.normalize() + }) } // resetFlags puts every persistent flag of root back to its default, so a diff --git a/modules/bonfire/globals_test.go b/modules/bonfire/globals_test.go index 521f2eb2..99d05053 100644 --- a/modules/bonfire/globals_test.go +++ b/modules/bonfire/globals_test.go @@ -101,8 +101,8 @@ func TestGlobalsArgs(t *testing.T) { if got := strings.Join(args, " "); got != tc.want { t.Errorf("%+v.Args() = %q, want %q", tc.g, got, tc.want) } - if back := PeekGlobals(args); back != tc.g.normalize() { - t.Errorf("PeekGlobals(%q) = %+v, want %+v", args, back, tc.g.normalize()) + if back := PeekGlobals(args); back != normalize(tc.g) { + t.Errorf("PeekGlobals(%q) = %+v, want %+v", args, back, normalize(tc.g)) } } } diff --git a/modules/bonfire/live.go b/modules/bonfire/live.go index da97d51f..4e82f2ca 100644 --- a/modules/bonfire/live.go +++ b/modules/bonfire/live.go @@ -23,6 +23,7 @@ import ( "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "git.golem15.com/golem15/summercms/internal/consolekit" ) // Progress bar colors: the cyan to green gradient of the earlier bonfire @@ -558,6 +559,12 @@ func raise(sig os.Signal) { } } +// bonfire's live view printer serves consolekit.PrintAbove, which diary's +// console channel calls without linking bonfire. +func init() { + consolekit.SetPrintAbove(printAboveLines) +} + // printAboveLines prints s, one or more lines, above the running live view, // whichever console started it (bonfire.PrintAbove). It reports false when // no view runs. diff --git a/modules/bonfire/styles.go b/modules/bonfire/styles.go index dfbb5415..b6b94617 100644 --- a/modules/bonfire/styles.go +++ b/modules/bonfire/styles.go @@ -2,61 +2,37 @@ package bonfire import ( "io" - "os" - "strings" - "charm.land/lipgloss/v2" + "git.golem15.com/golem15/summercms/internal/consolekit" "github.com/charmbracelet/colorprofile" ) // ColorMode selects how console output decides on color: from the terminal // and the environment, always, or never. -type ColorMode int +type ColorMode = consolekit.ColorMode 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 + ColorAuto = consolekit.ColorAuto // ColorAlways colors output whatever the writer is (--ansi). - ColorAlways + ColorAlways = consolekit.ColorAlways // ColorNever writes no escape codes at all (--no-ansi). - ColorNever + ColorNever = consolekit.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 -} +// widgets and log lines (Success, Current, Warning, Error, ErrorBold, Muted +// and Header). Render through a writer that applies the color profile +// (DetectProfile), because lipgloss always emits ANSI codes. +type Palette = consolekit.Palette // 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), - } + return consolekit.DefaultPalette() } // DetectProfile returns the color profile for console output written to w. @@ -66,35 +42,5 @@ func DefaultPalette() Palette { // 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 "" + return consolekit.DetectProfile(w, mode, environ) } diff --git a/modules/diary/README.md b/modules/diary/README.md index e1cc3732..9008681d 100644 --- a/modules/diary/README.md +++ b/modules/diary/README.md @@ -14,7 +14,7 @@ The application `main` that `summer build` generates reads the console global op ## Role in the framework -The single owner of the process logger. It builds on `compass` (configuration), `sunscreen` (redaction) and `bonfire` (the console palette and color detection). Framework modules and plugins only ask it for a component logger; they never configure handlers themselves. River logs as component `river` (warn by default), GORM as `db` (through `lagoon`), and each framework module under its package name: `cabana`, `lagoon`, `attach`, `conga`, `cooler`, `postcard`, `beachcomber`, `lighthouse`, `centrifugo`, `flare`, `phrasebook`, `wristband` and `bouncer`. Modules that let the application publish its own `*slog.Logger` (`conga`, `cooler`, `postcard`, `beachcomber`, `lighthouse`, `flare`) still prefer it. +The single owner of the process logger. It builds on `compass` (configuration) and `sunscreen` (redaction), and shares the console palette, color detection and control-character sanitizer with `bonfire` through the leaf package `internal/consolekit`, so diary, and every module that logs through it, does not link bonfire, cobra or the TUI stack. Framework modules and plugins only ask it for a component logger; they never configure handlers themselves. River logs as component `river` (warn by default), GORM as `db` (through `lagoon`), and each framework module under its package name: `cabana`, `lagoon`, `attach`, `conga`, `cooler`, `postcard`, `beachcomber`, `lighthouse`, `centrifugo`, `flare`, `phrasebook`, `wristband` and `bouncer`. Modules that let the application publish its own `*slog.Logger` (`conga`, `cooler`, `postcard`, `beachcomber`, `lighthouse`, `flare`) still prefer it. Use cases: @@ -147,7 +147,7 @@ Environment variables use the `SUMMER_LOG__` prefix with a double underscore bet ## Dependencies -- SummerCMS modules: `bonfire` (palette and color detection), `compass` (configuration), `sunscreen` (redaction). +- SummerCMS modules: `compass` (configuration), `sunscreen` (redaction), and the internal package `internal/consolekit` (the palette, color detection and sanitizer it shares with `bonfire`, whose `bonfire.Globals` and `bonfire.ColorMode` are the same types). - Third-party: `charm.land/lipgloss/v2`, `github.com/charmbracelet/colorprofile` (decided in `.planning/notes/2026-10-21-console-charm-stack.md`), `golang.org/x/term` (whether a console channel writes to a terminal, for `diary.HideOnTTY`). - Standard library: `context`, `errors`, `fmt`, `io`, `io/fs`, `log`, `log/slog`, `maps`, `os`, `path/filepath`, `runtime`, `slices`, `strconv`, `strings`, `sync`, `sync/atomic`, `time`, `unicode`. diff --git a/modules/diary/channels.go b/modules/diary/channels.go index 9ef6f038..67f6f6e1 100644 --- a/modules/diary/channels.go +++ b/modules/diary/channels.go @@ -8,7 +8,7 @@ import ( "path/filepath" "sync/atomic" - "git.golem15.com/golem15/summercms/modules/bonfire" + "git.golem15.com/golem15/summercms/internal/consolekit" "git.golem15.com/golem15/summercms/modules/sunscreen" "github.com/charmbracelet/colorprofile" ) @@ -103,7 +103,7 @@ func (b *channelBuilder) build(name string) (slog.Handler, error) { case FormatLogfmt: leaf = slog.NewTextHandler(w, hopts) default: - profile := bonfire.DetectProfile(w, b.opts.Color, b.opts.Environ) + profile := consolekit.DetectProfile(w, b.opts.Color, b.opts.Environ) if profile > colorprofile.NoTTY { leaf = newConsoleHandler(w, ConsoleOptions{Level: lv, Profile: profile}, b.out.source) } else { diff --git a/modules/diary/console.go b/modules/diary/console.go index 02e7c751..2a581e55 100644 --- a/modules/diary/console.go +++ b/modules/diary/console.go @@ -14,7 +14,7 @@ import ( "time" "charm.land/lipgloss/v2" - "git.golem15.com/golem15/summercms/modules/bonfire" + "git.golem15.com/golem15/summercms/internal/consolekit" "github.com/charmbracelet/colorprofile" "golang.org/x/term" ) @@ -60,7 +60,7 @@ func newConsoleHandler(w io.Writer, opts ConsoleOptions, source *atomic.Bool) *c mu: new(sync.Mutex), level: level, source: source, - palette: bonfire.DefaultPalette(), + palette: consolekit.DefaultPalette(), } } @@ -74,7 +74,7 @@ type consoleHandler struct { mu *sync.Mutex level slog.Leveler source *atomic.Bool - palette bonfire.Palette + palette consolekit.Palette // terminal is true when the handler's writer is a terminal, checked // once when the handler is built: only then are HideOnTTY records // skipped. @@ -148,12 +148,12 @@ func (h *consoleHandler) Handle(ctx context.Context, r slog.Record) error { label, style := levelLabel(r.Level, p) b.WriteString(style.Render(label)) b.WriteString(strings.Repeat(" ", max(0, 5-len(label))+1)) - name := bonfire.Sanitize(component) + name := consolekit.Sanitize(component) if name != "" { b.WriteString(p.Header.Render(name)) } b.WriteString(strings.Repeat(" ", max(0, componentWidth-len(name))+1)) - b.WriteString(bonfire.Sanitize(r.Message)) + b.WriteString(consolekit.Sanitize(r.Message)) for _, list := range [][]consoleAttr{h.attrs, recAttrs} { for _, a := range list { b.WriteByte(' ') @@ -170,7 +170,7 @@ func (h *consoleHandler) Handle(ctx context.Context, r slog.Record) error { } // While a bonfire live view (spinner, progress, process view) runs, // the line prints above it instead of tearing it. - if bonfire.PrintAbove(b.String()) { + if consolekit.PrintAbove(b.String()) { return nil } b.WriteByte('\n') @@ -212,7 +212,7 @@ func isTerminal(w io.Writer) bool { return ok && term.IsTerminal(int(f.Fd())) } -func levelLabel(l slog.Level, p bonfire.Palette) (string, lipgloss.Style) { +func levelLabel(l slog.Level, p consolekit.Palette) (string, lipgloss.Style) { switch { case l < slog.LevelInfo: return l.String(), p.Muted @@ -254,7 +254,7 @@ func appendAttr(out []consoleAttr, prefix string, a slog.Attr) []consoleAttr { } return out } - return append(out, consoleAttr{key: bonfire.Sanitize(prefix + a.Key), value: formatValue(a.Value)}) + return append(out, consoleAttr{key: consolekit.Sanitize(prefix + a.Key), value: formatValue(a.Value)}) } func formatValue(v slog.Value) string { @@ -273,7 +273,7 @@ func formatValue(v slog.Value) string { default: s = v.String() } - s = bonfire.Sanitize(s) + s = consolekit.Sanitize(s) if s == "" || strings.ContainsAny(s, " \t\"=") { return strconv.Quote(s) } diff --git a/modules/diary/deps_test.go b/modules/diary/deps_test.go new file mode 100644 index 00000000..813d8837 --- /dev/null +++ b/modules/diary/deps_test.go @@ -0,0 +1,31 @@ +package diary_test + +import ( + "os/exec" + "strings" + "testing" +) + +// TestDiaryDoesNotLinkTheConsoleStack (IN-07): diary, and the security +// modules that log through it, link neither bonfire nor the TUI stack +// (bubbletea, bubbles, huh) nor cobra. The console types diary shares with +// bonfire live in internal/consolekit. +func TestDiaryDoesNotLinkTheConsoleStack(t *testing.T) { + goTool, err := exec.LookPath("go") + if err != nil { + t.Skip("go tool not on PATH") + } + const module = "git.golem15.com/golem15/summercms/modules/" + out, err := exec.Command(goTool, "list", "-deps", module+"diary", module+"bouncer", module+"wristband").Output() + if err != nil { + t.Fatalf("go list -deps: %v", err) + } + banned := []string{module + "bonfire", "charm.land/bubbletea/v2", "charm.land/bubbles/v2", "charm.land/huh/v2", "github.com/spf13/cobra"} + for _, dep := range strings.Split(string(out), "\n") { + for _, b := range banned { + if dep == b || strings.HasPrefix(dep, b+"/") { + t.Errorf("diary, bouncer or wristband links %s", dep) + } + } + } +} diff --git a/modules/diary/diary.go b/modules/diary/diary.go index 9bfa22c4..eb33bf72 100644 --- a/modules/diary/diary.go +++ b/modules/diary/diary.go @@ -18,7 +18,7 @@ import ( "sync/atomic" "time" - "git.golem15.com/golem15/summercms/modules/bonfire" + "git.golem15.com/golem15/summercms/internal/consolekit" "git.golem15.com/golem15/summercms/modules/compass" ) @@ -33,7 +33,7 @@ type Options struct { // Quiet raises the console channels to warn; files keep their levels. Quiet bool // Color decides on styled console output (bonfire.ColorAuto by default). - Color bonfire.ColorMode + Color consolekit.ColorMode // Root is the application root that relative daily paths resolve // against; empty means the working directory. Root string @@ -214,8 +214,9 @@ func setVerbosityLocked(v int, quiet bool) { // FromGlobals returns the Options for the console global options: their // verbosity, quiet switch and color mode. The generated main passes it to -// Bootstrap and Setup. -func FromGlobals(g bonfire.Globals) Options { +// Bootstrap and Setup. g is a bonfire.Globals: bonfire and diary share the +// type through internal/consolekit, so diary does not link bonfire. +func FromGlobals(g consolekit.Globals) Options { return Options{Verbosity: g.Verbosity, Quiet: g.Quiet, Color: g.Color} } @@ -225,7 +226,7 @@ func FromGlobals(g bonfire.Globals) Options { // rebuilds the channels from the same configuration. The generated main // registers it with bonfire.OnGlobals. It holds the setup lock for its // whole run, so concurrent calls rebuild the channels once. -func ApplyGlobals(g bonfire.Globals) { +func ApplyGlobals(g consolekit.Globals) { mu.Lock() defer mu.Unlock() st := current.Load() diff --git a/scripts/check-phase19.8.sh b/scripts/check-phase19.8.sh index 64789a16..d32953af 100755 --- a/scripts/check-phase19.8.sh +++ b/scripts/check-phase19.8.sh @@ -102,8 +102,8 @@ DIARY_TESTS=( # Plan 05 (review fixes): daily retry, retired writers (WR-04, WR-06). TestDailyRetriesAfterFailure TestRetiredDailyWriterKeepsNoFile TestApplyGlobalsRebuildsOnce - # quick 261028-rqd (info findings): IN-01. - TestConsoleHideOnTTYTerminal + # quick 261028-rqd (info findings): IN-01, IN-07. + TestConsoleHideOnTTYTerminal TestDiaryDoesNotLinkTheConsoleStack ) SUNSCREEN_TESTS=( # Plan 04: the compatibility entry point.