refactor(diary): share the console types through internal/consolekit so diary no longer links bonfire (IN-07, quick-261028-rqd)

This commit is contained in:
Jakub Zych
2026-10-28 20:46:32 +01:00
parent 3520d5d480
commit f3f87f3cc4
14 changed files with 364 additions and 169 deletions

View File

@@ -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`.

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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)
}
}
}
}

View File

@@ -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()