diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed3d23b..5eac50f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,9 +16,9 @@ jobs: with: go-version: '1.22' - name: golangci-lint - uses: golangci/golangci-lint-action@v6 + uses: golangci/golangci-lint-action@v8 with: - version: latest + version: v2.5.0 args: --timeout=5m - name: Check formatting run: | diff --git a/.gitignore b/.gitignore index c1072c7..9e1babf 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,7 @@ RELEASE.md # Go build artifacts bin/ -sysgreet +/sysgreet *.test *.exe *.exe~ diff --git a/.golangci.yml b/.golangci.yml index cd1a4a7..36abddf 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,46 +1,54 @@ +version: "2" run: - timeout: 5m - tests: true modules-download-mode: readonly - + tests: true linters: enable: - - errcheck - - gosimple - - govet - - ineffassign - - staticcheck - - unused - - gofmt - - goimports - - misspell - - unconvert - - unparam + - copyloopvar - goconst - gocyclo - gosec - - copyloopvar - -linters-settings: - gocyclo: - min-complexity: 15 - goconst: - min-len: 3 - min-occurrences: 3 - misspell: - locale: US - + - misspell + - unconvert + - unparam + settings: + goconst: + min-len: 3 + min-occurrences: 3 + gocyclo: + min-complexity: 15 + misspell: + locale: US + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + rules: + - linters: + - gocyclo + - gosec + - unparam + path: _test\.go + - linters: + - staticcheck + text: cgo-gcc-prolog + paths: + - third_party$ + - builtin$ + - examples$ issues: - exclude-rules: - # Exclude some linters from running on tests files - - path: _test\.go - linters: - - gocyclo - - gosec - - unparam - # Ignore CGO warnings from dependencies - - text: "cgo-gcc-prolog" - linters: - - staticcheck max-issues-per-linter: 0 max-same-issues: 0 +formatters: + enable: + - gofmt + - goimports + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bc42d6..98fe658 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## v1.1.0 + +### Added + +- **Width-aware rendering** - Sysgreet now detects the terminal width and guarantees the banner never wraps mid-glyph. When the configured font is too wide it steps down gracefully: drop the domain from the hostname (the full name moves to an info line), try a narrower font, and finally render a clean single-line ruled header. Narrow tmux panes and phone SSH sessions stay readable. +- **`Small` font** - A narrower FIGlet font (from the standard figlet distribution) embedded as the last art step before the plain-header fallback. +- **Truecolor gradients** - On terminals advertising `COLORTERM=truecolor`, gradient stops are interpolated into a smooth 24-bit fade. 16-color terminals keep the existing per-line cycling. +- **`--json` flag** - Emit the banner as structured JSON for scripting (section data includes raw percentages for thresholding in pipelines). Never prompts for bootstrap. +- **New flags** - `--font` (per-run font override), `--width` (assume a terminal width), `--no-color`, `--config` (explicit config path), `--list-fonts`. +- **`layout.max_width`** - Config key (and `SYSGREET_LAYOUT_MAX_WIDTH`) to cap banner width below the detected terminal size. +- **Windows legacy console support** - Virtual terminal processing is enabled before writing escapes; when the console refuses, output falls back to plain text instead of printing raw escape codes. + +### Fixed + +- **`NO_COLOR` now covers the whole banner** - Previously the hostname art kept its gradient with `NO_COLOR` set; escape sequences are now fully suppressed, including when output is piped or `TERM=dumb`. +- **Piped output is plain** - `sysgreet > motd.txt` no longer embeds ANSI codes; color is keyed off whether stdout is a terminal. +- **`--text` and `--demo` honor your config** - Both modes previously ignored the config file, so custom fonts and gradients silently didn't apply. +- **Compact mode is actually compact** - `layout.compact` now emits a true single line using the plain hostname instead of embedding the multi-line art. +- **Unknown fonts fall back deterministically** - A typo in `ascii.font` now falls back to the default font (logged under `SYSGREET_DEBUG`) instead of picking a random font every login. +- **Long body lines clip cleanly** - Info lines that exceed the terminal width are truncated with an ellipsis instead of wrapping. + +### Changed + +- **Collectors run concurrently** - All five collectors gather in parallel under a shared 250 ms deadline, so a slow metric source can never hang a login. Windows logins no longer pay the 100 ms CPU sample serially. +- **One color palette** - The ANSI color tables previously duplicated (and drifted) across packages now live in a single `internal/terminal` package alongside width and capability detection. + ## v0.9.1 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9d3a3f5..4b9b101 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -154,7 +154,8 @@ sysgreet/ │ ├── collectors/ # System data collectors │ ├── config/ # Configuration loading │ ├── network/ # Network utilities -│ └── render/ # Layout and color rendering +│ ├── render/ # Layout, clipping, and JSON rendering +│ └── terminal/ # Terminal width/color detection, ANSI palette ├── test/ │ ├── benchmarks/ # Performance benchmarks │ └── integration/ # Platform-specific integration tests @@ -199,6 +200,18 @@ Sysgreet has strict performance requirements: When adding new collectors or features, ensure they don't violate these constraints. +Collectors run concurrently inside `Providers.Gather` under a shared 250 ms +deadline, so honor the `context.Context` you receive — a collector that +ignores cancellation can still delay a login. + +## Output Rules + +- Never print wider than the terminal. The ascii renderer receives a + `MaxWidth` and walks a fallback ladder; anything you add to the banner body + is clipped by the render layer. Don't bypass either. +- All color must flow through `internal/terminal` so `NO_COLOR`, `TERM=dumb`, + piped output, and Windows console quirks stay handled in one place. + ## Documentation ### README Updates diff --git a/README.md b/README.md index 6b1f69b..1af5e0b 100644 --- a/README.md +++ b/README.md @@ -38,17 +38,26 @@ network or depending on external runtimes. - **Single static binary** - Go 1.22+, no CGO, no daemons, no service dependencies. +- **Fits any terminal** - Sysgreet measures the terminal before printing and + steps the banner down gracefully (shorter hostname, then a narrower font, + then a clean one-line header) instead of wrapping ASCII art into garbage. + Split tmux panes and phone SSH sessions stay readable. - **Cross-platform parity** - Linux/macOS show load averages; Windows surfaces - CPU usage. Interface filtering avoids noisy virtual adapters everywhere. + CPU usage (and legacy consoles get plain text instead of raw escape codes). + Interface filtering avoids noisy virtual adapters everywhere. - **Configurable yet optional** - YAML or TOML profiles toggle sections, pick fonts/colors, set layout order, and cap the interface list. Defaults "just work" with zero files. - **Graceful degradation** - Missing metrics or SSH metadata simply fall back; the banner keeps rendering. -- **Performance-guarded** - Startup benchmark (<50 ms median, <80 ms p95) runs in - CI; process RSS stays <15 MB. -- **Professional aesthetics** - Unicode block fonts with gradient colors, - automatic monochrome fallback, 80-column mindful layout. +- **Performance-guarded** - Collectors run in parallel under a hard 250 ms + deadline; the startup benchmark (<50 ms median, <80 ms p95) runs in CI and + process RSS stays <15 MB. +- **Professional aesthetics** - Unicode block fonts with gradient colors — + smooth 24-bit fades on truecolor terminals — plus automatic monochrome + fallback and full `NO_COLOR` support. +- **Script-friendly** - `--json` emits the same data as structured JSON; + piped output is always plain text. --- @@ -107,6 +116,19 @@ sysgreet --disable sysgreet --text "Production DB" sysgreet --text "Coffee Break" sysgreet --text "Deploy Day" + +# JSON mode - structured output for scripts (no art, no prompts) +sysgreet --json | jq -r '.hostname' +``` + +**Useful flags:** + +```bash +sysgreet --list-fonts # Print the embedded fonts +sysgreet --font "ANSI Shadow" # One-off font override +sysgreet --width 60 # Preview how a 60-column session renders +sysgreet --no-color # Plain output (NO_COLOR works too) +sysgreet --config ~/alt.yaml # Point at a specific config file ``` > **Tip:** Use `--text` to create custom banners for different environments, reminders, or just for fun. Great for distinguishing production boxes, marking maintenance windows, or adding personality to your terminals. @@ -119,7 +141,10 @@ sysgreet --text "Deploy Day" Sysgreet looks for configuration in this order: -1. `SYSGREET_CONFIG` environment variable (absolute or `~/` paths) +1. `--config` flag or `SYSGREET_CONFIG` environment variable (absolute or + `~/` paths). An explicit path is exclusive — if the file is missing, + sysgreet uses built-in defaults rather than silently reading another + config. 2. `~/.config/sysgreet/config.yaml` (or `.yml`, `.toml`) 3. `~/.sysgreet.yaml` / `.toml` @@ -147,6 +172,7 @@ display: layout: compact: false + max_width: 0 # cap banner width in columns; 0 = detected terminal width sections: ["header", "network", "system", "resources"] network: @@ -182,12 +208,28 @@ Environment variables override everything (e.g. yellow, ≥90% in red). Windows surfaces realtime CPU usage; Unix hosts show load averages. +### Terminal width handling + +The hostname art is only printed when it actually fits. On narrow terminals +sysgreet steps down automatically: + +1. Full hostname in your configured font +2. Hostname without the domain (`pve1.home.lan` → `PVE1`, with the full name + kept on an info line) +3. Progressively narrower fonts (`standard`, then `Small`) +4. A single ruled line — `═════ PVE1 ═════` — that fits any width + +Set `layout.max_width` to cap the width below what the terminal reports, or +pass `--width` to preview a specific size. + --- ## Performance guarantees - **Startup** - `< 50 ms` median, `< 80 ms` p95 (validated by `go test -bench Startup ./test/benchmarks`) +- **Never hangs a login** - Collectors run concurrently under a shared 250 ms + deadline; a stuck metric source just drops its section - **Binary footprint** - `< 10 MB` for all release targets (GoReleaser checks) - **Runtime memory** - `< 15 MB` RSS for default banner - **No network activity** - All data collected locally, offline-safe @@ -237,7 +279,6 @@ platform-specific improvements before diving in. ## Roadmap -- Optional JSON output for scripting in CI/CD pipelines - Extended GPU/storage telemetry for workstation profiles - Pluggable section framework (e.g., Kubernetes context, vault status) - Prebuilt Windows installer for enterprise onboarding diff --git a/assets/fonts/Small.flf b/assets/fonts/Small.flf new file mode 100644 index 0000000..c6b5bfc --- /dev/null +++ b/assets/fonts/Small.flf @@ -0,0 +1,1097 @@ +flf2a$ 5 4 13 15 10 0 22415 96 +Small by Glenn Chappell 4/93 -- based on Standard +Includes ISO Latin-1 +figlet release 2.1 -- 12 Aug 1994 +Permission is hereby given to modify this font, as long as the +modifier's name is placed on a comment line. + +Modified by Paul Burton 12/96 to include new parameter +supported by FIGlet and FIGWin. May also be slightly modified for better use +of new full-width/kern/smush alternatives, but default output is NOT changed. + + $@ + $@ + $@ + $@ + $@@ + _ @ + | |@ + |_|@ + (_)@ + @@ + _ _ @ + ( | )@ + V V @ + $ @ + @@ + _ _ @ + _| | |_ @ + |_ . _|@ + |_ _|@ + |_|_| @@ + @ + ||_@ + (_-<@ + / _/@ + || @@ + _ __ @ + (_)/ / @ + / /_ @ + /_/(_)@ + @@ + __ @ + / _|___ @ + > _|_ _|@ + \_____| @ + @@ + _ @ + ( )@ + |/ @ + $ @ + @@ + __@ + / /@ + | | @ + | | @ + \_\@@ + __ @ + \ \ @ + | |@ + | |@ + /_/ @@ + @ + _/\_@ + > <@ + \/ @ + @@ + _ @ + _| |_ @ + |_ _|@ + |_| @ + @@ + @ + @ + _ @ + ( )@ + |/ @@ + @ + ___ @ + |___|@ + $ @ + @@ + @ + @ + _ @ + (_)@ + @@ + __@ + / /@ + / / @ + /_/ @ + @@ + __ @ + / \ @ + | () |@ + \__/ @ + @@ + _ @ + / |@ + | |@ + |_|@ + @@ + ___ @ + |_ )@ + / / @ + /___|@ + @@ + ____@ + |__ /@ + |_ \@ + |___/@ + @@ + _ _ @ + | | | @ + |_ _|@ + |_| @ + @@ + ___ @ + | __|@ + |__ \@ + |___/@ + @@ + __ @ + / / @ + / _ \@ + \___/@ + @@ + ____ @ + |__ |@ + / / @ + /_/ @ + @@ + ___ @ + ( _ )@ + / _ \@ + \___/@ + @@ + ___ @ + / _ \@ + \_, /@ + /_/ @ + @@ + _ @ + (_)@ + _ @ + (_)@ + @@ + _ @ + (_)@ + _ @ + ( )@ + |/ @@ + __@ + / /@ + < < @ + \_\@ + @@ + @ + ___ @ + |___|@ + |___|@ + @@ + __ @ + \ \ @ + > >@ + /_/ @ + @@ + ___ @ + |__ \@ + /_/@ + (_) @ + @@ + ____ @ + / __ \ @ + / / _` |@ + \ \__,_|@ + \____/ @@ + _ @ + /_\ @ + / _ \ @ + /_/ \_\@ + @@ + ___ @ + | _ )@ + | _ \@ + |___/@ + @@ + ___ @ + / __|@ + | (__ @ + \___|@ + @@ + ___ @ + | \ @ + | |) |@ + |___/ @ + @@ + ___ @ + | __|@ + | _| @ + |___|@ + @@ + ___ @ + | __|@ + | _| @ + |_| @ + @@ + ___ @ + / __|@ + | (_ |@ + \___|@ + @@ + _ _ @ + | || |@ + | __ |@ + |_||_|@ + @@ + ___ @ + |_ _|@ + | | @ + |___|@ + @@ + _ @ + _ | |@ + | || |@ + \__/ @ + @@ + _ __@ + | |/ /@ + | ' < @ + |_|\_\@ + @@ + _ @ + | | @ + | |__ @ + |____|@ + @@ + __ __ @ + | \/ |@ + | |\/| |@ + |_| |_|@ + @@ + _ _ @ + | \| |@ + | .` |@ + |_|\_|@ + @@ + ___ @ + / _ \ @ + | (_) |@ + \___/ @ + @@ + ___ @ + | _ \@ + | _/@ + |_| @ + @@ + ___ @ + / _ \ @ + | (_) |@ + \__\_\@ + @@ + ___ @ + | _ \@ + | /@ + |_|_\@ + @@ + ___ @ + / __|@ + \__ \@ + |___/@ + @@ + _____ @ + |_ _|@ + | | @ + |_| @ + @@ + _ _ @ + | | | |@ + | |_| |@ + \___/ @ + @@ + __ __@ + \ \ / /@ + \ V / @ + \_/ @ + @@ + __ __@ + \ \ / /@ + \ \/\/ / @ + \_/\_/ @ + @@ + __ __@ + \ \/ /@ + > < @ + /_/\_\@ + @@ + __ __@ + \ \ / /@ + \ V / @ + |_| @ + @@ + ____@ + |_ /@ + / / @ + /___|@ + @@ + __ @ + | _|@ + | | @ + | | @ + |__|@@ + __ @ + \ \ @ + \ \ @ + \_\@ + @@ + __ @ + |_ |@ + | |@ + | |@ + |__|@@ + /\ @ + |/\|@ + $ @ + $ @ + @@ + @ + @ + @ + ___ @ + |___|@@ + _ @ + ( )@ + \|@ + $ @ + @@ + @ + __ _ @ + / _` |@ + \__,_|@ + @@ + _ @ + | |__ @ + | '_ \@ + |_.__/@ + @@ + @ + __ @ + / _|@ + \__|@ + @@ + _ @ + __| |@ + / _` |@ + \__,_|@ + @@ + @ + ___ @ + / -_)@ + \___|@ + @@ + __ @ + / _|@ + | _|@ + |_| @ + @@ + @ + __ _ @ + / _` |@ + \__, |@ + |___/ @@ + _ @ + | |_ @ + | ' \ @ + |_||_|@ + @@ + _ @ + (_)@ + | |@ + |_|@ + @@ + _ @ + (_)@ + | |@ + _/ |@ + |__/ @@ + _ @ + | |__@ + | / /@ + |_\_\@ + @@ + _ @ + | |@ + | |@ + |_|@ + @@ + @ + _ __ @ + | ' \ @ + |_|_|_|@ + @@ + @ + _ _ @ + | ' \ @ + |_||_|@ + @@ + @ + ___ @ + / _ \@ + \___/@ + @@ + @ + _ __ @ + | '_ \@ + | .__/@ + |_| @@ + @ + __ _ @ + / _` |@ + \__, |@ + |_|@@ + @ + _ _ @ + | '_|@ + |_| @ + @@ + @ + ___@ + (_-<@ + /__/@ + @@ + _ @ + | |_ @ + | _|@ + \__|@ + @@ + @ + _ _ @ + | || |@ + \_,_|@ + @@ + @ + __ __@ + \ V /@ + \_/ @ + @@ + @ + __ __ __@ + \ V V /@ + \_/\_/ @ + @@ + @ + __ __@ + \ \ /@ + /_\_\@ + @@ + @ + _ _ @ + | || |@ + \_, |@ + |__/ @@ + @ + ___@ + |_ /@ + /__|@ + @@ + __@ + / /@ + _| | @ + | | @ + \_\@@ + _ @ + | |@ + | |@ + | |@ + |_|@@ + __ @ + \ \ @ + | |_@ + | | @ + /_/ @@ + /\/|@ + |/\/ @ + $ @ + $ @ + @@ + _ _ @ + (_)(_)@ + /--\ @ + /_/\_\@ + @@ + _ _ @ + (_)(_)@ + / __ \@ + \____/@ + @@ + _ _ @ + (_) (_)@ + | |_| |@ + \___/ @ + @@ + _ _ @ + (_)(_)@ + / _` |@ + \__,_|@ + @@ + _ _ @ + (_)_(_)@ + / _ \ @ + \___/ @ + @@ + _ _ @ + (_)(_)@ + | || |@ + \_,_|@ + @@ + ___ @ + / _ \@ + | |< <@ + | ||_/@ + |_| @@ +160 NO-BREAK SPACE + $@ + $@ + $@ + $@ + $@@ +161 INVERTED EXCLAMATION MARK + _ @ + (_)@ + | |@ + |_|@ + @@ +162 CENT SIGN + @ + || @ + / _)@ + \ _)@ + || @@ +163 POUND SIGN + __ @ + _/ _\ @ + |_ _|_ @ + (_,___|@ + @@ +164 CURRENCY SIGN + /\_/\@ + \ . /@ + / _ \@ + \/ \/@ + @@ +165 YEN SIGN + __ __ @ + \ V / @ + |__ __|@ + |__ __|@ + |_| @@ +166 BROKEN BAR + _ @ + | |@ + |_|@ + | |@ + |_|@@ +167 SECTION SIGN + __ @ + / _)@ + /\ \ @ + \ \/ @ + (__/ @@ +168 DIAERESIS + _ _ @ + (_)(_)@ + $ $ @ + $ $ @ + @@ +169 COPYRIGHT SIGN + ____ @ + / __ \ @ + / / _| \@ + \ \__| /@ + \____/ @@ +170 FEMININE ORDINAL INDICATOR + __ _ @ + / _` |@ + \__,_|@ + |____|@ + @@ +171 LEFT-POINTING DOUBLE ANGLE QUOTATION MARK + ____@ + / / /@ + < < < @ + \_\_\@ + @@ +172 NOT SIGN + ____ @ + |__ |@ + |_|@ + $ @ + @@ +173 SOFT HYPHEN + @ + __ @ + |__|@ + $ @ + @@ +174 REGISTERED SIGN + ____ @ + / __ \ @ + / | -) \@ + \ ||\\ /@ + \____/ @@ +175 MACRON + ___ @ + |___|@ + $ @ + $ @ + @@ +176 DEGREE SIGN + _ @ + /.\@ + \_/@ + $ @ + @@ +177 PLUS-MINUS SIGN + _ @ + _| |_ @ + |_ _|@ + _|_|_ @ + |_____|@@ +178 SUPERSCRIPT TWO + __ @ + |_ )@ + /__|@ + $ @ + @@ +179 SUPERSCRIPT THREE + ___@ + |_ /@ + |__)@ + $ @ + @@ +180 ACUTE ACCENT + __@ + /_/@ + $ @ + $ @ + @@ +181 MICRO SIGN + @ + _ _ @ + | || |@ + | .,_|@ + |_| @@ +182 PILCROW SIGN + ____ @ + / |@ + \_ | |@ + |_|_|@ + @@ +183 MIDDLE DOT + @ + _ @ + (_)@ + $ @ + @@ +184 CEDILLA + @ + @ + @ + _ @ + )_)@@ +185 SUPERSCRIPT ONE + _ @ + / |@ + |_|@ + $ @ + @@ +186 MASCULINE ORDINAL INDICATOR + ___ @ + / _ \@ + \___/@ + |___|@ + @@ +187 RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK + ____ @ + \ \ \ @ + > > >@ + /_/_/ @ + @@ +188 VULGAR FRACTION ONE QUARTER + _ __ @ + / |/ /__ @ + |_/ /_' |@ + /_/ |_|@ + @@ +189 VULGAR FRACTION ONE HALF + _ __ @ + / |/ /_ @ + |_/ /_ )@ + /_//__|@ + @@ +190 VULGAR FRACTION THREE QUARTERS + ___ __ @ + |_ // /__ @ + |__) /_' |@ + /_/ |_|@ + @@ +191 INVERTED QUESTION MARK + _ @ + (_) @ + / /_ @ + \___|@ + @@ +192 LATIN CAPITAL LETTER A WITH GRAVE + __ @ + \_\ @ + /--\ @ + /_/\_\@ + @@ +193 LATIN CAPITAL LETTER A WITH ACUTE + __ @ + /_/ @ + /--\ @ + /_/\_\@ + @@ +194 LATIN CAPITAL LETTER A WITH CIRCUMFLEX + /\ @ + |/\| @ + /--\ @ + /_/\_\@ + @@ +195 LATIN CAPITAL LETTER A WITH TILDE + /\/|@ + |/\/ @ + /--\ @ + /_/\_\@ + @@ +196 LATIN CAPITAL LETTER A WITH DIAERESIS + _ _ @ + (_)(_)@ + /--\ @ + /_/\_\@ + @@ +197 LATIN CAPITAL LETTER A WITH RING ABOVE + __ @ + (()) @ + /--\ @ + /_/\_\@ + @@ +198 LATIN CAPITAL LETTER AE + ____ @ + /, __|@ + / _ _| @ + /_/|___|@ + @@ +199 LATIN CAPITAL LETTER C WITH CEDILLA + ___ @ + / __|@ + | (__ @ + \___|@ + )_) @@ +200 LATIN CAPITAL LETTER E WITH GRAVE + __ @ + \_\@ + | -<@ + |__<@ + @@ +201 LATIN CAPITAL LETTER E WITH ACUTE + __@ + /_/@ + | -<@ + |__<@ + @@ +202 LATIN CAPITAL LETTER E WITH CIRCUMFLEX + /\ @ + |/\|@ + | -<@ + |__<@ + @@ +203 LATIN CAPITAL LETTER E WITH DIAERESIS + _ _ @ + (_)(_)@ + | -< @ + |__< @ + @@ +204 LATIN CAPITAL LETTER I WITH GRAVE + __ @ + \_\ @ + |_ _|@ + |___|@ + @@ +205 LATIN CAPITAL LETTER I WITH ACUTE + __ @ + /_/ @ + |_ _|@ + |___|@ + @@ +206 LATIN CAPITAL LETTER I WITH CIRCUMFLEX + //\ @ + |/_\|@ + |_ _|@ + |___|@ + @@ +207 LATIN CAPITAL LETTER I WITH DIAERESIS + _ _ @ + (_)_(_)@ + |_ _| @ + |___| @ + @@ +208 LATIN CAPITAL LETTER ETH + ____ @ + | __ \ @ + |_ _|) |@ + |____/ @ + @@ +209 LATIN CAPITAL LETTER N WITH TILDE + /\/|@ + |/\/ @ + | \| |@ + |_|\_|@ + @@ +210 LATIN CAPITAL LETTER O WITH GRAVE + __ @ + \_\_ @ + / __ \@ + \____/@ + @@ +211 LATIN CAPITAL LETTER O WITH ACUTE + __ @ + _/_/ @ + / __ \@ + \____/@ + @@ +212 LATIN CAPITAL LETTER O WITH CIRCUMFLEX + /\ @ + |/\| @ + / __ \@ + \____/@ + @@ +213 LATIN CAPITAL LETTER O WITH TILDE + /\/|@ + |/\/ @ + / __ \@ + \____/@ + @@ +214 LATIN CAPITAL LETTER O WITH DIAERESIS + _ _ @ + (_)(_)@ + / __ \@ + \____/@ + @@ +215 MULTIPLICATION SIGN + @ + /\/\@ + > <@ + \/\/@ + @@ +216 LATIN CAPITAL LETTER O WITH STROKE + ____ @ + / _//\ @ + | (//) |@ + \//__/ @ + @@ +217 LATIN CAPITAL LETTER U WITH GRAVE + __ @ + _\_\_ @ + | |_| |@ + \___/ @ + @@ +218 LATIN CAPITAL LETTER U WITH ACUTE + __ @ + _/_/_ @ + | |_| |@ + \___/ @ + @@ +219 LATIN CAPITAL LETTER U WITH CIRCUMFLEX + //\ @ + |/ \| @ + | |_| |@ + \___/ @ + @@ +220 LATIN CAPITAL LETTER U WITH DIAERESIS + _ _ @ + (_) (_)@ + | |_| |@ + \___/ @ + @@ +221 LATIN CAPITAL LETTER Y WITH ACUTE + __ @ + _/_/_@ + \ V /@ + |_| @ + @@ +222 LATIN CAPITAL LETTER THORN + _ @ + | |_ @ + | -_)@ + |_| @ + @@ +223 LATIN SMALL LETTER SHARP S + ___ @ + / _ \@ + | |< <@ + | ||_/@ + |_| @@ +224 LATIN SMALL LETTER A WITH GRAVE + __ @ + \_\_ @ + / _` |@ + \__,_|@ + @@ +225 LATIN SMALL LETTER A WITH ACUTE + __ @ + _/_/ @ + / _` |@ + \__,_|@ + @@ +226 LATIN SMALL LETTER A WITH CIRCUMFLEX + /\ @ + |/\| @ + / _` |@ + \__,_|@ + @@ +227 LATIN SMALL LETTER A WITH TILDE + /\/|@ + |/\/ @ + / _` |@ + \__,_|@ + @@ +228 LATIN SMALL LETTER A WITH DIAERESIS + _ _ @ + (_)(_)@ + / _` |@ + \__,_|@ + @@ +229 LATIN SMALL LETTER A WITH RING ABOVE + __ @ + (()) @ + / _` |@ + \__,_|@ + @@ +230 LATIN SMALL LETTER AE + @ + __ ___ @ + / _` -_)@ + \__,___|@ + @@ +231 LATIN SMALL LETTER C WITH CEDILLA + @ + __ @ + / _|@ + \__|@ + )_)@@ +232 LATIN SMALL LETTER E WITH GRAVE + __ @ + \_\ @ + / -_)@ + \___|@ + @@ +233 LATIN SMALL LETTER E WITH ACUTE + __ @ + /_/ @ + / -_)@ + \___|@ + @@ +234 LATIN SMALL LETTER E WITH CIRCUMFLEX + //\ @ + |/_\|@ + / -_)@ + \___|@ + @@ +235 LATIN SMALL LETTER E WITH DIAERESIS + _ _ @ + (_)_(_)@ + / -_) @ + \___| @ + @@ +236 LATIN SMALL LETTER I WITH GRAVE + __ @ + \_\@ + | |@ + |_|@ + @@ +237 LATIN SMALL LETTER I WITH ACUTE + __@ + /_/@ + | |@ + |_|@ + @@ +238 LATIN SMALL LETTER I WITH CIRCUMFLEX + //\ @ + |/_\|@ + | | @ + |_| @ + @@ +239 LATIN SMALL LETTER I WITH DIAERESIS + _ _ @ + (_)_(_)@ + | | @ + |_| @ + @@ +240 LATIN SMALL LETTER ETH + \\/\ @ + \/\\ @ + / _` |@ + \___/ @ + @@ +241 LATIN SMALL LETTER N WITH TILDE + /\/| @ + |/\/ @ + | ' \ @ + |_||_|@ + @@ +242 LATIN SMALL LETTER O WITH GRAVE + __ @ + \_\ @ + / _ \@ + \___/@ + @@ +243 LATIN SMALL LETTER O WITH ACUTE + __ @ + /_/ @ + / _ \@ + \___/@ + @@ +244 LATIN SMALL LETTER O WITH CIRCUMFLEX + //\ @ + |/_\|@ + / _ \@ + \___/@ + @@ +245 LATIN SMALL LETTER O WITH TILDE + /\/|@ + |/\/ @ + / _ \@ + \___/@ + @@ +246 LATIN SMALL LETTER O WITH DIAERESIS + _ _ @ + (_)_(_)@ + / _ \ @ + \___/ @ + @@ +247 DIVISION SIGN + _ @ + (_) @ + |___|@ + (_) @ + @@ +248 LATIN SMALL LETTER O WITH STROKE + @ + ___ @ + / //\@ + \//_/@ + @@ +249 LATIN SMALL LETTER U WITH GRAVE + __ @ + \_\_ @ + | || |@ + \_,_|@ + @@ +250 LATIN SMALL LETTER U WITH ACUTE + __ @ + _/_/ @ + | || |@ + \_,_|@ + @@ +251 LATIN SMALL LETTER U WITH CIRCUMFLEX + /\ @ + |/\| @ + | || |@ + \_,_|@ + @@ +252 LATIN SMALL LETTER U WITH DIAERESIS + _ _ @ + (_)(_)@ + | || |@ + \_,_|@ + @@ +253 LATIN SMALL LETTER Y WITH ACUTE + __ @ + _/_/ @ + | || |@ + \_, |@ + |__/ @@ +254 LATIN SMALL LETTER THORN + _ @ + | |__ @ + | '_ \@ + | .__/@ + |_| @@ +255 LATIN SMALL LETTER Y WITH DIAERESIS + _ _ @ + (_)(_)@ + | || |@ + \_, |@ + |__/ @@ diff --git a/cmd/sysgreet/console_unix.go b/cmd/sysgreet/console_unix.go new file mode 100644 index 0000000..e34b6c7 --- /dev/null +++ b/cmd/sysgreet/console_unix.go @@ -0,0 +1,11 @@ +//go:build !windows + +package main + +import "os" + +// enableVirtualTerminal is a no-op outside Windows; every supported Unix +// terminal understands ANSI escapes natively. +func enableVirtualTerminal(_ *os.File) bool { + return true +} diff --git a/cmd/sysgreet/console_windows.go b/cmd/sysgreet/console_windows.go new file mode 100644 index 0000000..dc2392b --- /dev/null +++ b/cmd/sysgreet/console_windows.go @@ -0,0 +1,25 @@ +//go:build windows + +package main + +import ( + "os" + + "golang.org/x/sys/windows" +) + +// enableVirtualTerminal switches the Windows console into VT processing so +// ANSI escapes render as colors instead of literal text. Returns false when +// the console refuses (legacy conhost), in which case output stays plain. +func enableVirtualTerminal(out *os.File) bool { + handle := windows.Handle(out.Fd()) + var mode uint32 + if err := windows.GetConsoleMode(handle, &mode); err != nil { + // Not a console (piped/redirected); nothing to enable. + return true + } + if mode&windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING != 0 { + return true + } + return windows.SetConsoleMode(handle, mode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) == nil +} diff --git a/cmd/sysgreet/main.go b/cmd/sysgreet/main.go index 149080c..8a4fda2 100644 --- a/cmd/sysgreet/main.go +++ b/cmd/sysgreet/main.go @@ -13,6 +13,7 @@ import ( "github.com/veteranbv/sysgreet/internal/collectors" "github.com/veteranbv/sysgreet/internal/config" "github.com/veteranbv/sysgreet/internal/render" + "github.com/veteranbv/sysgreet/internal/terminal" ) var ( @@ -22,101 +23,146 @@ var ( ) func main() { + if err := run(); err != nil { + if errors.Is(err, bootstrap.ErrUserCanceled) { + return + } + fmt.Fprintf(os.Stderr, "sysgreet: %v\n", err) + os.Exit(1) + } +} + +func run() error { ctx := context.Background() settings := parseFlags() if settings.Version { fmt.Printf("sysgreet %s (commit: %s, built: %s)\n", version, commit, date) - return + return nil } - - policyEnv := os.Getenv("SYSGREET_CONFIG_POLICY") - interactive := resolveInteractivity() if settings.Disable { - return + return nil + } + if settings.ConfigPath != "" { + // Reuse the SYSGREET_CONFIG plumbing so load and bootstrap agree + // on the path. + if err := os.Setenv("SYSGREET_CONFIG", settings.ConfigPath); err != nil { + return err + } } renderer, err := ascii.NewRenderer() if err != nil { - fmt.Fprintf(os.Stderr, "sysgreet: %v\n", err) - os.Exit(1) + return err } - if handled, err := runTextMode(renderer, settings.Text); err != nil { - fmt.Fprintf(os.Stderr, "sysgreet: %v\n", err) - os.Exit(1) - } else if handled { - return - } - - if handled, err := runDemoMode(renderer, settings.Demo); err != nil { - fmt.Fprintf(os.Stderr, "sysgreet: %v\n", err) - os.Exit(1) - } else if handled { - return - } - - // Normal mode: bootstrap config and collect real data - cfgPath := config.DefaultWritePath() - if err := maybeBootstrap(ctx, cfgPath, settings.PolicyFlag, policyEnv, interactive, bootstrap.IO{Stdin: os.Stdin, Stdout: os.Stdout, Stderr: os.Stderr}); err != nil { - if errors.Is(err, bootstrap.ErrUserCanceled) { - return + if settings.ListFonts { + for _, font := range renderer.Fonts() { + fmt.Println(font) } - fmt.Fprintf(os.Stderr, "sysgreet: %v\n", err) - os.Exit(1) + return nil } - cfg, _, err := config.Load() + // Legacy Windows consoles need virtual terminal processing switched on + // before any escape sequences are written; when that fails, fall back + // to plain output rather than printing raw escapes. + ansiOK := enableVirtualTerminal(os.Stdout) + env := terminal.DetectEnv(os.Stdout, settings.NoColor || !ansiOK) + + cfg, err := loadConfig(ctx, settings) if err != nil { - fmt.Fprintf(os.Stderr, "sysgreet: %v\n", err) - os.Exit(1) + return err + } + env = render.ApplyConfig(env, cfg) + if settings.Width > 0 { + // The flag wins over both the detected width and layout.max_width. + env.Width = settings.Width } - providers := collectors.Providers{ - System: collectors.NewSystemCollector(), - Network: collectors.NewNetworkCollector(cfg.Network.MaxInterfaces), - Resources: collectors.NewResourceCollector(), - Session: collectors.NewSessionCollector(), - LastLogin: collectors.NewLastLoginCollector(), + if settings.Text != "" { + return runTextMode(renderer, settings.Text, cfg, env) } - hostBanner, err := banner.New(providers, renderer, banner.BuildersForConfig(cfg)) + buildEnv := env + if settings.JSON { + // Scripted output must not vary with terminal geometry or color + // support; build against a neutral environment. + buildEnv = terminal.Env{} + } + output, err := buildBanner(ctx, renderer, cfg, buildEnv, settings.Demo) if err != nil { - fmt.Fprintf(os.Stderr, "sysgreet: %v\n", err) - os.Exit(1) + return err } + return printBanner(output, cfg, env, settings.JSON) +} - output, _, err := hostBanner.Build(ctx, cfg) +// loadConfig bootstraps a config on first run and loads it. --text, --demo, +// and --json are one-shot or scripted invocations that must never prompt, +// so bootstrap only runs in normal mode. +func loadConfig(ctx context.Context, settings runSettings) (config.Config, error) { + if settings.Text == "" && !settings.Demo && !settings.JSON { + if err := maybeBootstrap(ctx, settings); err != nil { + return config.Config{}, err + } + } + cfg, _, err := config.Load() if err != nil { - fmt.Fprintf(os.Stderr, "sysgreet: %v\n", err) - os.Exit(1) + return config.Config{}, err + } + if settings.Font != "" { + cfg.ASCII.Font = settings.Font } + return cfg, nil +} - layout := render.NewRenderer(cfg.ASCII.Monochrome) - fmt.Println(layout.Render(output, cfg)) +func printBanner(output banner.Output, cfg config.Config, env terminal.Env, asJSON bool) error { + if asJSON { + doc, err := render.RenderJSON(output, cfg) + if err != nil { + return err + } + fmt.Println(doc) + return nil + } + fmt.Println(render.NewRenderer(env).Render(output, cfg)) + return nil } type runSettings struct { PolicyFlag string + ConfigPath string + Font string + Width int Disable bool Demo bool + JSON bool + ListFonts bool + NoColor bool Text string Version bool } func parseFlags() runSettings { policyFlag := flag.String("config-policy", "", "Config bootstrap policy: prompt, keep, or overwrite") + configPath := flag.String("config", "", "Path to a config file (overrides default lookup)") + font := flag.String("font", "", "Font override for this run (see --list-fonts)") + width := flag.Int("width", 0, "Assume this terminal width instead of detecting it") disable := flag.Bool("disable", false, "Disable sysgreet output") demo := flag.Bool("demo", false, "Demo mode with 'SYSGREET' banner and fake data") + jsonOut := flag.Bool("json", false, "Emit the banner as JSON for scripting") + listFonts := flag.Bool("list-fonts", false, "List embedded fonts and exit") + noColor := flag.Bool("no-color", false, "Disable colored output") text := flag.String("text", "", "Render custom text as ASCII art (e.g., --text \"Tea Pot\")") showVersion := flag.Bool("version", false, "Show version information") flag.Usage = func() { fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s\n", os.Args[0]) flag.PrintDefaults() fmt.Fprintln(flag.CommandLine.Output(), "\nEnvironment variables:") + fmt.Fprintln(flag.CommandLine.Output(), " SYSGREET_CONFIG Config file path (same as --config)") fmt.Fprintln(flag.CommandLine.Output(), " SYSGREET_CONFIG_POLICY Config bootstrap policy (prompt|keep|overwrite)") fmt.Fprintln(flag.CommandLine.Output(), " SYSGREET_ASSUME_TTY Force interactive prompts (testing/support)") + fmt.Fprintln(flag.CommandLine.Output(), " NO_COLOR Disable colored output (same as --no-color)") fmt.Fprintln(flag.CommandLine.Output(), " CI When set, disables interactive prompts by default") fmt.Fprintln(flag.CommandLine.Output(), "\nBootstrap:") fmt.Fprintln(flag.CommandLine.Output(), " First run writes curated defaults (ANSI Regular font with gradient, metadata).") @@ -125,60 +171,78 @@ func parseFlags() runSettings { flag.Parse() return runSettings{ PolicyFlag: *policyFlag, + ConfigPath: *configPath, + Font: *font, + Width: *width, Disable: *disable, Demo: *demo, + JSON: *jsonOut, + ListFonts: *listFonts, + NoColor: *noColor, Text: *text, Version: *showVersion, } } -func resolveInteractivity() bool { - interactive := isInteractive() - if os.Getenv("CI") != "" { - interactive = false +func buildBanner(ctx context.Context, renderer *ascii.Renderer, cfg config.Config, env terminal.Env, demo bool) (banner.Output, error) { + if demo { + hostBanner, err := banner.New(collectors.Providers{}, renderer, banner.BuildersForConfig(cfg)) + if err != nil { + return banner.Output{}, err + } + return hostBanner.BuildWithSnapshot(collectors.DemoSnapshot(), cfg, env), nil } - if os.Getenv("SYSGREET_ASSUME_TTY") != "" { - interactive = true + + providers := collectors.Providers{ + System: collectors.NewSystemCollector(), + Network: collectors.NewNetworkCollector(cfg.Network.MaxInterfaces), + Resources: collectors.NewResourceCollector(), + Session: collectors.NewSessionCollector(), + LastLogin: collectors.NewLastLoginCollector(), } - return interactive + hostBanner, err := banner.New(providers, renderer, banner.BuildersForConfig(cfg)) + if err != nil { + return banner.Output{}, err + } + output, _, err := hostBanner.Build(ctx, cfg, env) + return output, err } -func runTextMode(renderer *ascii.Renderer, text string) (bool, error) { - if text == "" { - return false, nil - } - cfg := config.Default() - art, _, _, err := renderer.RenderWithGradient(text, cfg.ASCII.Font, cfg.ASCII.Color, cfg.ASCII.Gradient, cfg.ASCII.Monochrome) +func runTextMode(renderer *ascii.Renderer, text string, cfg config.Config, env terminal.Env) error { + art, err := renderer.Render(text, ascii.RenderOptions{ + Font: cfg.ASCII.Font, + Color: cfg.ASCII.Color, + Gradient: cfg.ASCII.Gradient, + Monochrome: cfg.ASCII.Monochrome, + MaxWidth: env.Width, + Profile: env.Profile, + }) if err != nil { - return false, err + return err } - fmt.Printf("\n%s\n\n", art) - return true, nil + fmt.Printf("\n%s\n\n", art.Text) + return nil } -func runDemoMode(renderer *ascii.Renderer, enabled bool) (bool, error) { - if !enabled { - return false, nil +func resolveInteractivity() bool { + interactive := isInteractive() + if os.Getenv("CI") != "" { + interactive = false } - cfg := config.Default() - demoSnap := collectors.DemoSnapshot() - providers := collectors.Providers{} // Empty providers for demo - hostBanner, err := banner.New(providers, renderer, banner.BuildersForConfig(cfg)) - if err != nil { - return false, err + if os.Getenv("SYSGREET_ASSUME_TTY") != "" { + interactive = true } - output := hostBanner.BuildWithSnapshot(demoSnap, cfg) - layout := render.NewRenderer(cfg.ASCII.Monochrome) - fmt.Println(layout.Render(output, cfg)) - return true, nil + return interactive } -func maybeBootstrap(ctx context.Context, cfgPath, policyFlag, policyEnv string, interactive bool, io bootstrap.IO) error { +func maybeBootstrap(ctx context.Context, settings runSettings) error { + cfgPath := config.DefaultWritePath() if cfgPath == "" { return nil } + policyEnv := os.Getenv("SYSGREET_CONFIG_POLICY") info, statErr := os.Stat(cfgPath) - policyProvided := policyFlag != "" || policyEnv != "" + policyProvided := settings.PolicyFlag != "" || policyEnv != "" configMissing := errors.Is(statErr, os.ErrNotExist) configIsDir := statErr == nil && info.IsDir() if statErr != nil && !configMissing { @@ -187,7 +251,12 @@ func maybeBootstrap(ctx context.Context, cfgPath, policyFlag, policyEnv string, if !policyProvided && !configMissing && !configIsDir { return nil } - _, err := bootstrap.Bootstrap(ctx, cfgPath, io, bootstrap.Options{FlagPolicy: policyFlag, EnvPolicy: policyEnv, Interactive: interactive}) + io := bootstrap.IO{Stdin: os.Stdin, Stdout: os.Stdout, Stderr: os.Stderr} + _, err := bootstrap.Bootstrap(ctx, cfgPath, io, bootstrap.Options{ + FlagPolicy: settings.PolicyFlag, + EnvPolicy: policyEnv, + Interactive: resolveInteractivity(), + }) return err } diff --git a/configs/example.toml b/configs/example.toml index 760d6bc..f17ee27 100644 --- a/configs/example.toml +++ b/configs/example.toml @@ -13,7 +13,10 @@ datetime = true last_login = true [ascii] -# Font options: "ANSI Regular", "ANSI Shadow", "Block", "DOS Rebel", "Basic", "standard", "slant" +# Font options: "ANSI Regular", "ANSI Shadow", "Banner", "Basic", "Block", +# "Blocks", "DOS Rebel", "Small", "standard", "slant" (or run --list-fonts). +# When the terminal is too narrow for this font, sysgreet automatically +# steps down: shorter hostname, then a narrower font, then a plain header. font = "ANSI Regular" # Gradient: cycle colors per line (overrides 'color' if set) @@ -27,6 +30,8 @@ monochrome = false [layout] compact = false +# Cap the banner width in columns. 0 means use the detected terminal width. +max_width = 0 sections = ["header", "network", "system", "resources"] [network] diff --git a/configs/example.yaml b/configs/example.yaml index 78a825a..b4035ab 100644 --- a/configs/example.yaml +++ b/configs/example.yaml @@ -13,7 +13,10 @@ display: last_login: true ascii: - # Font options: "ANSI Regular", "ANSI Shadow", "Block", "DOS Rebel", "Basic", "standard", "slant" + # Font options: "ANSI Regular", "ANSI Shadow", "Banner", "Basic", "Block", + # "Blocks", "DOS Rebel", "Small", "standard", "slant" (or run --list-fonts). + # When the terminal is too narrow for this font, sysgreet automatically + # steps down: shorter hostname, then a narrower font, then a plain header. font: "ANSI Regular" # Gradient: cycle colors per line (overrides 'color' if set) @@ -27,6 +30,8 @@ ascii: layout: compact: false + # Cap the banner width in columns. 0 means use the detected terminal width. + max_width: 0 sections: - header - system diff --git a/docs/examples/config-scenarios.md b/docs/examples/config-scenarios.md index f6bfd2b..4b681b5 100644 --- a/docs/examples/config-scenarios.md +++ b/docs/examples/config-scenarios.md @@ -46,6 +46,35 @@ network: max_interfaces: 5 ``` +## Narrow Terminals and tmux Panes + +Sysgreet detects the terminal width at startup and degrades the banner +automatically, so nothing is required for split panes or phone SSH clients. +To cap the width below what the terminal reports (for example to keep logs +tidy), set: + +```yaml +layout: + max_width: 80 +``` + +Useful companions: + +- `sysgreet --width 60` previews what a 60-column session will see. +- `SYSGREET_LAYOUT_MAX_WIDTH=100` does the same per environment. +- On very tight widths the hostname renders as a single ruled line instead of + multi-line art — it never wraps into garbage. + +## Scripting with JSON + +`sysgreet --json` emits the banner data as structured JSON (no art, no color, +no bootstrap prompts), with section `data` carrying raw values like +`memory_used_percent` for thresholding: + +```bash +sysgreet --json | jq -r '.sections[] | select(.key=="resources").data.memory_used_percent' +``` + ## Troubleshooting Metric Discrepancies If resource values appear inconsistent with native tools: diff --git a/docs/examples/default-output.md b/docs/examples/default-output.md index 10140e2..0db26f9 100644 --- a/docs/examples/default-output.md +++ b/docs/examples/default-output.md @@ -42,4 +42,27 @@ Resources Note: The banner uses ANSI Regular font with a blue-to-white gradient (brightblue → blue → cyan → brightcyan → white). +## Narrow Terminal Degradation + +The same banner adapts to the available width instead of wrapping. At 50 +columns the default font no longer fits, so sysgreet steps down to the +`Small` font: + +```text + _ + ___ _ _ ___ __ _ _ _ ___ ___ | |_ + (_-< | || | (_-< / _` | | '_| / -_) / -_) | _| + /__/ \_, | /__/ \__, | |_| \___| \___| \__| + |__/ |___/ +``` + +And when even that is too wide (long hostnames, very tight panes), the header +collapses to a single ruled line: + +```text +═════════════ SYSGREET ═════════════ +``` + +Preview any width with `sysgreet --width `. + Use this snapshot for QA validation and regression testing until golden files are finalized. diff --git a/docs/examples/fonts.md b/docs/examples/fonts.md index d495bb3..b798d3c 100644 --- a/docs/examples/fonts.md +++ b/docs/examples/fonts.md @@ -9,10 +9,16 @@ The Sysgreet banner embeds multiple FIGlet fonts to ensure the CLI operates offl - **`Block.flf`** - Classic blocky style - **`Blocks.flf`** - Variant of block style - **`DOS Rebel.flf`** - Modern, clean DOS-style font +- **`Banner.flf`** - Classic wide banner style - **`Basic.flf`** - Simple hash-mark style +- **`Small.flf`** - Narrow variant of standard, used automatically on tight terminals - **`standard.flf`** - Classic FIGlet standard font - **`slant.flf`** - Tall, angular slanted font +Run `sysgreet --list-fonts` to print this list from the binary itself, and +`sysgreet --text "Test" --font "ANSI Shadow"` to preview one without touching +your config. + All fonts originate from the FIGlet project () and community repositories. They are licensed under the FIGlet Font License, which permits redistribution and embedding in binaries. License text is included at the top of each font file. Fonts are stored in `assets/fonts/` and shipped with the binary via Go's `//go:embed` directive. @@ -24,11 +30,21 @@ Fonts can be configured in your config file: ```yaml ascii: font: "ANSI Regular" # Default - compact Unicode blocks - # or "ANSI Shadow", "Block", "DOS Rebel", "Basic", "standard", "slant" + # or "ANSI Shadow", "Banner", "Basic", "Block", "Blocks", "DOS Rebel", + # "Small", "standard", "slant" ``` Or test fonts quickly: ```bash -sysgreet --text "Test" --config-policy=keep +sysgreet --text "Test" --font "Small" ``` + +## Width behavior + +The configured font is a preference, not a promise. Sysgreet measures the art +against the terminal before printing; when it would overflow, it drops the +domain from the hostname, then tries `standard` and `Small`, and finally +renders a one-line ruled header. A misspelled font name falls back to the +default (`ANSI Regular`) — set `SYSGREET_DEBUG=1` to see a note when that +happens. diff --git a/go.mod b/go.mod index b551025..7cbc08b 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,8 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) +require golang.org/x/term v0.20.0 + require ( github.com/go-ole/go-ole v1.2.6 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect @@ -17,5 +19,5 @@ require ( github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - golang.org/x/sys v0.20.0 // indirect + golang.org/x/sys v0.20.0 ) diff --git a/go.sum b/go.sum index 7574a2b..4301da7 100644 --- a/go.sum +++ b/go.sum @@ -42,6 +42,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/ascii/ascii.go b/internal/ascii/ascii.go index b36cff2..ea46640 100644 --- a/internal/ascii/ascii.go +++ b/internal/ascii/ascii.go @@ -1,6 +1,6 @@ package ascii -import "strings" +import "github.com/veteranbv/sysgreet/internal/terminal" // RenderOptions describes how ASCII art should be generated. type RenderOptions struct { @@ -9,16 +9,24 @@ type RenderOptions struct { Gradient []string Monochrome bool Uppercase bool + // MaxWidth caps the art width in columns. Zero means unconstrained. + MaxWidth int + // ShortenDomain allows the width ladder to drop everything after the + // first dot (pve1.home.lan -> pve1). Only hostname rendering sets + // this; user-supplied --text must never be rewritten. + ShortenDomain bool + // Profile gates color output for the target terminal. + Profile terminal.Profile } -// RenderHostname renders the hostname into ASCII art using the configured options. -func (r *Renderer) RenderHostname(hostname string, opts RenderOptions) (string, string, string, error) { - text := strings.TrimSpace(hostname) - if text == "" { - text = "sysgreet" - } - if opts.Uppercase { - text = strings.ToUpper(text) - } - return r.RenderWithGradient(text, opts.Font, opts.Color, opts.Gradient, opts.Monochrome) +// Art is the result of rendering a headline, along with what the width +// ladder had to do to make it fit. +type Art struct { + Text string // final output, colored per Profile + Font string // font used; "plain" when the ladder hit bottom + Color string + Width int // widest line in columns, ignoring escape sequences + // Shortened reports that the domain was dropped from the name to fit + // the terminal, so callers can surface the full name elsewhere. + Shortened bool } diff --git a/internal/ascii/ascii_test.go b/internal/ascii/ascii_test.go index c343da2..d7836f8 100644 --- a/internal/ascii/ascii_test.go +++ b/internal/ascii/ascii_test.go @@ -1,48 +1,333 @@ package ascii -import "testing" +import ( + "strings" + "testing" -func TestRendererRenderSpecificFont(t *testing.T) { + "github.com/veteranbv/sysgreet/internal/terminal" +) + +func mustRenderer(t *testing.T) *Renderer { + t.Helper() r, err := NewRenderer() if err != nil { t.Fatalf("NewRenderer() error = %v", err) } - art, font, _, err := r.Render("host", "standard", "reset", true) + return r +} + +func TestRendererRenderSpecificFont(t *testing.T) { + r := mustRenderer(t) + art, err := r.Render("host", RenderOptions{Font: "standard", Monochrome: true}) if err != nil { t.Fatalf("Render() error = %v", err) } - if font != "standard" { - t.Fatalf("expected font 'standard', got %s", font) + if art.Font != "standard" { + t.Fatalf("expected font 'standard', got %s", art.Font) } - if len(art) == 0 { + if len(art.Text) == 0 { t.Fatalf("expected non-empty art output") } - if art == "host" { + if art.Text == "host" { t.Fatalf("expected ASCII art, got plain text") } + if art.Width <= 0 { + t.Fatalf("expected positive art width, got %d", art.Width) + } } func TestRendererRandomFontSelection(t *testing.T) { - r, err := NewRenderer() - if err != nil { - t.Fatalf("NewRenderer() error = %v", err) - } - _, font, color, err := r.Render("host", "random", "random", false) + r := mustRenderer(t) + art, err := r.Render("host", RenderOptions{Font: "random", Color: "random", Profile: terminal.ProfileANSI}) if err != nil { t.Fatalf("Render() error = %v", err) } - fonts := r.Fonts() found := false - for _, candidate := range fonts { - if font == candidate { + for _, candidate := range r.Fonts() { + if art.Font == candidate { found = true break } } if !found { - t.Fatalf("random font %s not in available list", font) + t.Fatalf("random font %s not in available list", art.Font) } - if color == "reset" { + if art.Color == "reset" { t.Fatalf("expected random color selection") } } + +func TestRendererUnknownFontFallsBackDeterministically(t *testing.T) { + r := mustRenderer(t) + first, err := r.Render("host", RenderOptions{Font: "no-such-font", Monochrome: true}) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + if first.Font != defaultFont { + t.Fatalf("expected fallback to %q, got %q", defaultFont, first.Font) + } + // A typo must not roll a new font per invocation. + for i := 0; i < 3; i++ { + art, err := r.Render("host", RenderOptions{Font: "no-such-font", Monochrome: true}) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + if art.Font != first.Font { + t.Fatalf("unknown font fallback not deterministic: %q vs %q", art.Font, first.Font) + } + } +} + +func TestRendererFitsWithinMaxWidth(t *testing.T) { + r := mustRenderer(t) + for _, width := range []int{40, 60, 80, 120} { + art, err := r.Render("proxmox-node-01", RenderOptions{ + Font: "ANSI Regular", + Uppercase: true, + MaxWidth: width, + Profile: terminal.ProfileNoColor, + }) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + if art.Width > width { + t.Errorf("width %d: art is %d columns wide", width, art.Width) + } + for _, line := range strings.Split(art.Text, "\n") { + if n := len([]rune(terminal.Strip(line))); n > width { + t.Errorf("width %d: line overflows at %d columns: %q", width, n, line) + } + } + } +} + +func TestRendererShortensDomainToFit(t *testing.T) { + r := mustRenderer(t) + art, err := r.Render("pve1.home.lan", RenderOptions{ + Font: "ANSI Regular", + Uppercase: true, + MaxWidth: 60, + ShortenDomain: true, + Profile: terminal.ProfileNoColor, + }) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + if !art.Shortened { + t.Fatalf("expected domain to be dropped at width 60, got font %q width %d", art.Font, art.Width) + } + if art.Width > 60 { + t.Fatalf("shortened art still overflows: %d columns", art.Width) + } +} + +func TestRendererNeverShortensLiteralText(t *testing.T) { + r := mustRenderer(t) + // --text content must survive verbatim: a dotted string squeezed into + // a tiny width may shrink or clip, but never lose everything after + // the first dot. + art, err := r.Render("v2.1.0-rc.1", RenderOptions{ + Font: "ANSI Regular", + MaxWidth: 30, + Profile: terminal.ProfileNoColor, + }) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + if art.Shortened { + t.Fatalf("literal text was shortened at a dot: %q", art.Text) + } + if !strings.Contains(terminal.Strip(art.Text), "v2.1") { + t.Fatalf("literal text lost its dotted content: %q", art.Text) + } + if art.Width > 30 { + t.Fatalf("literal text overflows: %d columns", art.Width) + } +} + +func TestRendererFallsBackToNarrowFont(t *testing.T) { + r := mustRenderer(t) + // Wide enough for the name in Small but not in ANSI Regular. + art, err := r.Render("media-server", RenderOptions{ + Font: "ANSI Regular", + Uppercase: true, + MaxWidth: 70, + Profile: terminal.ProfileNoColor, + }) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + if art.Font == "ANSI Regular" { + t.Fatalf("expected a narrower font at width 70, art is %d columns", art.Width) + } + if art.Width > 70 { + t.Fatalf("fallback art still overflows: %d columns", art.Width) + } +} + +func TestRendererPlainHeaderAtTinyWidth(t *testing.T) { + r := mustRenderer(t) + art, err := r.Render("media-server-vault-01", RenderOptions{ + Font: "ANSI Regular", + Uppercase: true, + MaxWidth: 30, + Profile: terminal.ProfileNoColor, + }) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + if art.Font != plainFont { + t.Fatalf("expected plain header at width 30, got font %q", art.Font) + } + if strings.Contains(art.Text, "\n") { + t.Fatalf("plain header must be a single line, got %q", art.Text) + } + if art.Width > 30 { + t.Fatalf("plain header overflows: %d columns", art.Width) + } + if !strings.Contains(art.Text, "MEDIA-SERVER-VAULT-01") { + t.Fatalf("plain header lost the hostname: %q", art.Text) + } +} + +func TestRendererUnconstrainedKeepsConfiguredFont(t *testing.T) { + r := mustRenderer(t) + art, err := r.Render("media-server-vault-01", RenderOptions{ + Font: "ANSI Regular", + Uppercase: true, + Monochrome: true, + }) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + if art.Font != "ANSI Regular" { + t.Fatalf("unconstrained render should keep the configured font, got %q", art.Font) + } + if art.Shortened { + t.Fatalf("unconstrained render should not shorten the name") + } +} + +func TestRendererNoColorProfileEmitsNoEscapes(t *testing.T) { + r := mustRenderer(t) + art, err := r.Render("host", RenderOptions{ + Font: "standard", + Gradient: []string{"brightblue", "blue", "cyan"}, + Profile: terminal.ProfileNoColor, + }) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + if strings.Contains(art.Text, "\033[") { + t.Fatalf("NO_COLOR output contains escape sequences: %q", art.Text) + } +} + +func TestRendererGradientProfiles(t *testing.T) { + r := mustRenderer(t) + gradient := []string{"brightblue", "blue", "cyan", "brightcyan", "white"} + + ansi, err := r.Render("host", RenderOptions{Font: "standard", Gradient: gradient, Profile: terminal.ProfileANSI}) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + if !strings.Contains(ansi.Text, "\033[94m") { + t.Errorf("ANSI gradient missing 16-color escape: %q", ansi.Text) + } + if strings.Contains(ansi.Text, "\033[38;2;") { + t.Errorf("ANSI profile must not emit truecolor escapes") + } + + tc, err := r.Render("host", RenderOptions{Font: "standard", Gradient: gradient, Profile: terminal.ProfileTrueColor}) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + if !strings.Contains(tc.Text, "\033[38;2;") { + t.Errorf("truecolor gradient missing 24-bit escape: %q", tc.Text) + } +} + +func TestShortName(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"pve1.home.lan", "pve1"}, + {"plain-host", "plain-host"}, + {".weird", ".weird"}, + {"host.", "host"}, + } + for _, tt := range tests { + if got := shortName(tt.in); got != tt.want { + t.Errorf("shortName(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestLerpStops(t *testing.T) { + stops := []string{"blue", "white"} + start, ok := lerpStops(stops, 0) + if !ok { + t.Fatal("lerpStops failed at t=0") + } + blue, _ := terminal.RGB("blue") + if start != blue { + t.Errorf("t=0 should equal first stop, got %v want %v", start, blue) + } + end, ok := lerpStops(stops, 1) + if !ok { + t.Fatal("lerpStops failed at t=1") + } + white, _ := terminal.RGB("white") + if end != white { + t.Errorf("t=1 should equal last stop, got %v want %v", end, white) + } +} + +func TestRendererNonASCIITextUsesPlainHeader(t *testing.T) { + r := mustRenderer(t) + // go-figure's strict mode log.Fatals on non-ASCII, and non-strict mode + // silently drops characters; both are wrong. Non-ASCII text must render + // via the plain header with its content intact. + for _, text := range []string{"日本語のホスト", "web-サーバ", "café-host"} { + art, err := r.Render(text, RenderOptions{ + Font: "ANSI Regular", + MaxWidth: 40, + Profile: terminal.ProfileNoColor, + }) + if err != nil { + t.Fatalf("Render(%q) error = %v", text, err) + } + if art.Font != plainFont { + t.Errorf("Render(%q): expected plain header, got font %q", text, art.Font) + } + if !strings.Contains(art.Text, text) { + t.Errorf("Render(%q): text not preserved verbatim: %q", text, art.Text) + } + if art.Width > 40 { + t.Errorf("Render(%q): overflows at %d columns", text, art.Width) + } + } +} + +func TestPlainHeaderUsesAvailableWidth(t *testing.T) { + r := mustRenderer(t) + // 70 columns of hostname in a 100-column terminal: the ruled header + // must widen to fit the name rather than clip it at the preferred + // 60-column rule length. + name := strings.Repeat("a", 70) + art, err := r.Render(name+".home.lan", RenderOptions{ + Font: "ANSI Regular", + MaxWidth: 100, + Profile: terminal.ProfileNoColor, + }) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + if !strings.Contains(art.Text, name) { + t.Fatalf("plain header clipped a name that fits the terminal: %q", art.Text) + } + if art.Width > 100 { + t.Fatalf("plain header overflows: %d columns", art.Width) + } +} diff --git a/internal/ascii/fonts.go b/internal/ascii/fonts.go index 4448139..b244bef 100644 --- a/internal/ascii/fonts.go +++ b/internal/ascii/fonts.go @@ -4,34 +4,46 @@ import ( "bytes" "fmt" "io/fs" + "log" "math/rand" + "os" "path/filepath" "strings" "time" "github.com/common-nighthawk/go-figure" "github.com/veteranbv/sysgreet/assets" + "github.com/veteranbv/sysgreet/internal/terminal" ) -const resetColor = "reset" +const ( + resetColor = "reset" -var colorCodes = map[string]string{ - "reset": "\033[0m", - "red": "\033[31m", - "green": "\033[32m", - "yellow": "\033[33m", - "blue": "\033[34m", - "purple": "\033[35m", - "cyan": "\033[36m", - "gray": "\033[37m", - "white": "\033[97m", - "brightblue": "\033[94m", - "brightcyan": "\033[96m", - "brightwhite": "\033[97m", -} + // defaultFont is used when a configured font does not exist. A typo in + // the config should degrade predictably, not roll a random font on + // every login. + defaultFont = "ANSI Regular" + + // plainFont is reported when the width ladder falls all the way back + // to a single-line header. + plainFont = "plain" +) + +// narrowFonts are tried in order (narrowest last) when the configured font +// is too wide for the terminal. +var narrowFonts = []string{"standard", "Small"} var supportedColors = []string{"red", "green", "yellow", "blue", "purple", "cyan", "gray", "white"} +var debugEnabled = os.Getenv("SYSGREET_DEBUG") != "" + +func debugf(format string, args ...any) { + if !debugEnabled { + return + } + log.Printf("sysgreet ascii: "+format, args...) +} + // Renderer produces ASCII art banners from embedded FIGlet fonts. type Renderer struct { fonts map[string][]byte @@ -71,53 +83,257 @@ func (r *Renderer) Fonts() []string { return append([]string{}, r.order...) } -// Render creates ASCII art for the input string using the specified font and color. -// If fontName or colorName equal "random", selections are randomized from the embedded sets. -// If gradient is provided, it will apply different colors to each line of the banner. -func (r *Renderer) Render(text, fontName, colorName string, monochrome bool) (string, string, string, error) { - return r.RenderWithGradient(text, fontName, colorName, nil, monochrome) +// Render creates ASCII art for the input string. When opts.MaxWidth is set +// and the art would overflow, the renderer walks a fallback ladder — drop +// the domain from the name, try narrower fonts, and finally emit a plain +// single-line header — so output never wraps mid-glyph. +func (r *Renderer) Render(text string, opts RenderOptions) (Art, error) { + text = strings.TrimSpace(text) + if text == "" { + text = "sysgreet" + } + if opts.Uppercase { + text = strings.ToUpper(text) + } + + // FIGlet fonts cover printable ASCII; rendering anything else would + // silently drop characters, so such text goes straight to the plain + // header, which preserves it verbatim. + if !fontRenderable(text) { + return r.plainHeader(text, text, opts), nil + } + + texts := []string{text} + if opts.ShortenDomain { + if short := shortName(text); short != text { + texts = append(texts, short) + } + } + + for _, font := range r.fontLadder(opts.Font) { + for _, candidate := range texts { + rows := r.rows(candidate, font) + width := maxRowWidth(rows) + if width == 0 { + // FIGlet fonts only cover ASCII; text that renders to + // nothing (e.g. CJK) falls through to the plain header. + continue + } + if opts.MaxWidth > 0 && width > opts.MaxWidth { + continue + } + art, color := r.colorize(rows, opts) + return Art{ + Text: art, + Font: font, + Color: color, + Width: width, + Shortened: candidate != text, + }, nil + } + } + + return r.plainHeader(texts[len(texts)-1], text, opts), nil +} + +// fontLadder returns the fonts to try, widest first: the requested font, +// then the built-in narrow fallbacks. +func (r *Renderer) fontLadder(requested string) []string { + ladder := []string{r.resolveFont(requested)} + for _, name := range narrowFonts { + if _, ok := r.fonts[name]; !ok { + continue + } + if name == ladder[0] { + continue + } + ladder = append(ladder, name) + } + return ladder +} + +// resolveFont maps a configured font name to an embedded font. Unknown +// names fall back to the default deterministically. +func (r *Renderer) resolveFont(name string) string { + if name == "" || name == "random" { + return r.randomFont() + } + if _, ok := r.fonts[name]; ok { + return name + } + debugf("unknown font %q, falling back to %q", name, defaultFont) + if _, ok := r.fonts[defaultFont]; ok { + return defaultFont + } + return r.order[0] +} + +// rows renders text with a font in non-strict mode: characters the font +// does not cover are dropped rather than aborting the process (go-figure's +// strict mode calls log.Fatal on non-ASCII input). +func (r *Renderer) rows(text, font string) []string { + fig := figure.NewFigureWithFont(text, bytes.NewReader(r.fonts[font]), false) + return fig.Slicify() +} + +func maxRowWidth(rows []string) int { + widest := 0 + for _, row := range rows { + if n := terminal.DisplayWidth(row); n > widest { + widest = n + } + } + return widest +} + +// fontRenderable reports whether every rune falls in the printable ASCII +// range the embedded FIGlet fonts cover. +func fontRenderable(text string) bool { + for _, r := range text { + if r < ' ' || r > '~' { + return false + } + } + return true } -// RenderWithGradient creates ASCII art with optional gradient coloring per line. -func (r *Renderer) RenderWithGradient(text, fontName, colorName string, gradient []string, monochrome bool) (string, string, string, error) { - if fontName == "" || fontName == "random" { - fontName = r.randomFont() - } else if _, ok := r.fonts[fontName]; !ok { - fontName = r.randomFont() +// shortName drops everything after the first dot, so pve1.home.lan renders +// as pve1. The full name stays available for an info line. +func shortName(text string) string { + if idx := strings.IndexByte(text, '.'); idx > 0 { + return text[:idx] } + return text +} - fontData := r.fonts[fontName] - fig := figure.NewFigureWithFont(text, bytes.NewReader(fontData), true) - rows := fig.Slicify() +// plainHeader is the bottom of the ladder: a one-line ruled header that fits +// any terminal. Something like ═════ PVE1 ═════. +func (r *Renderer) plainHeader(name, fullText string, opts RenderOptions) Art { + // Preferred rule length; long names widen it rather than get clipped, + // as long as the terminal allows. + const maxRule = 60 + avail := opts.MaxWidth + + label := name + if avail > 0 { + label = terminal.Clip(label, avail) + } + labelWidth := terminal.DisplayWidth(label) + + target := maxRule + if labelWidth+4 > target { + target = labelWidth + 4 + } + if avail > 0 && target > avail { + target = avail + } + + line := label + if fill := target - labelWidth - 2; fill >= 2 { + left := fill / 2 + right := fill - left + line = strings.Repeat("═", left) + " " + label + " " + strings.Repeat("═", right) + } color := resetColor - var asciiArt string + if !opts.Monochrome && opts.Profile != terminal.ProfileNoColor { + color = r.headerColor(opts) + line = terminal.Wrap(opts.Profile, color, line) + } + return Art{ + Text: line, + Font: plainFont, + Color: color, + Width: terminal.DisplayWidth(terminal.Strip(line)), + Shortened: name != fullText, + } +} - if !monochrome && len(gradient) > 0 { - // Apply gradient coloring per line - var coloredRows []string +// headerColor picks the accent color for the plain header: the first +// gradient stop when a gradient is configured, otherwise the single color. +func (r *Renderer) headerColor(opts RenderOptions) string { + if len(opts.Gradient) > 0 { + if _, ok := terminal.Code(opts.Gradient[0]); ok { + return strings.ToLower(opts.Gradient[0]) + } + } + return r.pickColor(opts.Color) +} + +// colorize joins the art rows, applying gradient or single-color styling +// according to the terminal profile. +func (r *Renderer) colorize(rows []string, opts RenderOptions) (string, string) { + if opts.Monochrome || opts.Profile == terminal.ProfileNoColor { + return strings.Join(rows, "\n"), resetColor + } + + if len(opts.Gradient) > 0 { + codes := gradientCodes(opts.Gradient, len(rows), opts.Profile) + colored := make([]string, len(rows)) for i, row := range rows { - gradientColor := gradient[i%len(gradient)] - if code, ok := colorCodes[strings.ToLower(gradientColor)]; ok { - coloredRows = append(coloredRows, fmt.Sprintf("%s%s%s", code, row, colorCodes[resetColor])) - } else { - coloredRows = append(coloredRows, row) + if codes[i] == "" { + colored[i] = row + continue + } + colored[i] = codes[i] + row + terminal.Reset + } + return strings.Join(colored, "\n"), strings.Join(opts.Gradient, ",") + } + + color := r.pickColor(opts.Color) + art := strings.Join(rows, "\n") + if code, ok := terminal.Code(color); ok && color != resetColor { + art = code + art + terminal.Reset + } + return art, color +} + +// gradientCodes returns one escape sequence per row. On truecolor terminals +// the configured stops are interpolated into a smooth 24-bit gradient; on +// 16-color terminals the stops cycle per row as before. +func gradientCodes(stops []string, n int, profile terminal.Profile) []string { + codes := make([]string, n) + if profile == terminal.ProfileTrueColor { + for i := range codes { + t := 0.0 + if n > 1 { + t = float64(i) / float64(n-1) + } + if rgb, ok := lerpStops(stops, t); ok { + codes[i] = terminal.ForegroundRGB(rgb[0], rgb[1], rgb[2]) } } - asciiArt = strings.Join(coloredRows, "\n") - color = strings.Join(gradient, ",") - } else if !monochrome { - // Single color mode - asciiArt = strings.Join(rows, "\n") - color = r.pickColor(colorName) - if code, ok := colorCodes[color]; ok && color != resetColor { - asciiArt = fmt.Sprintf("%s%s%s", code, asciiArt, colorCodes[resetColor]) + return codes + } + for i := range codes { + if code, ok := terminal.Code(stops[i%len(stops)]); ok { + codes[i] = code } - } else { - asciiArt = strings.Join(rows, "\n") } + return codes +} - return asciiArt, fontName, color, nil +// lerpStops interpolates the named gradient stops at position t in [0, 1]. +func lerpStops(stops []string, t float64) ([3]uint8, bool) { + if len(stops) == 1 { + return terminal.RGB(stops[0]) + } + pos := t * float64(len(stops)-1) + seg := int(pos) + if seg >= len(stops)-1 { + seg = len(stops) - 2 + } + frac := pos - float64(seg) + from, okFrom := terminal.RGB(stops[seg]) + to, okTo := terminal.RGB(stops[seg+1]) + if !okFrom || !okTo { + return [3]uint8{}, false + } + var rgb [3]uint8 + for c := 0; c < 3; c++ { + rgb[c] = uint8(float64(from[c]) + (float64(to[c])-float64(from[c]))*frac + 0.5) + } + return rgb, true } func (r *Renderer) randomFont() string { @@ -129,7 +345,7 @@ func (r *Renderer) pickColor(name string) string { return supportedColors[r.rnd.Intn(len(supportedColors))] } name = strings.ToLower(name) - if _, ok := colorCodes[name]; ok { + if _, ok := terminal.Code(name); ok { return name } return resetColor diff --git a/internal/banner/banner.go b/internal/banner/banner.go index 2bf23e2..7285a16 100644 --- a/internal/banner/banner.go +++ b/internal/banner/banner.go @@ -8,6 +8,7 @@ import ( "github.com/veteranbv/sysgreet/internal/ascii" "github.com/veteranbv/sysgreet/internal/collectors" "github.com/veteranbv/sysgreet/internal/config" + "github.com/veteranbv/sysgreet/internal/terminal" ) // Section represents a rendered section of the banner body. @@ -20,10 +21,11 @@ type Section struct { // Header captures ASCII art metadata for the hostname banner. type Header struct { - Art string - Font string - Color string - Lines []string + Hostname string // plain hostname, for compact and JSON output + Art string + Font string + Color string + Lines []string } // Output encapsulates the full banner content ready for layout rendering. @@ -54,38 +56,45 @@ func New(providers collectors.Providers, renderer *ascii.Renderer, builders []Bu return &Banner{providers: providers, ascii: renderer, builders: builders}, nil } -// Build produces the banner output using the provided configuration. -func (b *Banner) Build(ctx context.Context, cfg config.Config) (Output, collectors.Snapshot, error) { +// Build produces the banner output using the provided configuration and +// terminal environment. +func (b *Banner) Build(ctx context.Context, cfg config.Config, env terminal.Env) (Output, collectors.Snapshot, error) { snap := b.providers.Gather(ctx) - return b.BuildWithSnapshot(snap, cfg), snap, nil + return b.BuildWithSnapshot(snap, cfg, env), snap, nil } // BuildWithSnapshot renders banner output using a pre-collected snapshot (e.g., for demo mode). -func (b *Banner) BuildWithSnapshot(snap collectors.Snapshot, cfg config.Config) Output { - header := b.buildHeader(snap, cfg) +func (b *Banner) BuildWithSnapshot(snap collectors.Snapshot, cfg config.Config, env terminal.Env) Output { + header := b.buildHeader(snap, cfg, env) sections := b.buildSections(snap, cfg) return Output{Header: header, Sections: sections} } -func (b *Banner) buildHeader(snap collectors.Snapshot, cfg config.Config) Header { +func (b *Banner) buildHeader(snap collectors.Snapshot, cfg config.Config, env terminal.Env) Header { name := snap.System.Hostname if strings.TrimSpace(name) == "" { name = "sysgreet" } - art, font, color, err := b.ascii.RenderHostname(name, ascii.RenderOptions{ - Font: cfg.ASCII.Font, - Color: cfg.ASCII.Color, - Gradient: cfg.ASCII.Gradient, - Monochrome: cfg.ASCII.Monochrome, - Uppercase: true, + art, err := b.ascii.Render(name, ascii.RenderOptions{ + Font: cfg.ASCII.Font, + Color: cfg.ASCII.Color, + Gradient: cfg.ASCII.Gradient, + Monochrome: cfg.ASCII.Monochrome, + Uppercase: true, + MaxWidth: env.Width, + ShortenDomain: true, + Profile: env.Profile, }) if err != nil { // Fallback to plain text when ASCII rendering fails. - art = name - font = "plain" - color = "reset" + art = ascii.Art{Text: name, Font: "plain", Color: "reset"} } lines := []string{} + if art.Shortened { + // The domain was dropped from the art to fit the terminal; keep + // the full name visible on its own line. + lines = append(lines, name) + } if cfg.Display.OS { line := snap.System.OS if snap.System.OSVersion != "" { @@ -94,9 +103,13 @@ func (b *Banner) buildHeader(snap collectors.Snapshot, cfg config.Config) Header if snap.System.Arch != "" { line += " (" + snap.System.Arch + ")" } - lines = append(lines, line) + // A timed-out or missing system collector leaves these fields + // empty; omit the line rather than render it blank. + if strings.TrimSpace(line) != "" { + lines = append(lines, line) + } } - return Header{Art: art, Font: font, Color: color, Lines: lines} + return Header{Hostname: name, Art: art.Text, Font: art.Font, Color: art.Color, Lines: lines} } func (b *Banner) buildSections(snap collectors.Snapshot, cfg config.Config) []Section { diff --git a/internal/banner/banner_test.go b/internal/banner/banner_test.go index 241645a..dfdab40 100644 --- a/internal/banner/banner_test.go +++ b/internal/banner/banner_test.go @@ -2,12 +2,14 @@ package banner import ( "context" + "strings" "testing" "time" "github.com/veteranbv/sysgreet/internal/ascii" "github.com/veteranbv/sysgreet/internal/collectors" "github.com/veteranbv/sysgreet/internal/config" + "github.com/veteranbv/sysgreet/internal/terminal" ) func TestNew(t *testing.T) { @@ -140,7 +142,7 @@ func TestBanner_Build(t *testing.T) { t.Fatalf("New() error = %v", err) } - output, snap, err := banner.Build(context.Background(), tt.cfg) + output, snap, err := banner.Build(context.Background(), tt.cfg, terminal.Env{}) if err != nil { t.Fatalf("Build() error = %v", err) } @@ -233,7 +235,7 @@ func TestBanner_buildHeader(t *testing.T) { }, } - header := banner.buildHeader(snap, cfg) + header := banner.buildHeader(snap, cfg, terminal.Env{}) if header.Art == "" { t.Error("expected non-empty ASCII art") @@ -334,3 +336,66 @@ func mustRenderer(t *testing.T) *ascii.Renderer { } return r } + +func TestBanner_buildHeaderShortensForNarrowTerminal(t *testing.T) { + b, err := New(collectors.Providers{}, mustRenderer(t), nil) + if err != nil { + t.Fatalf("New() error = %v", err) + } + snap := collectors.Snapshot{ + System: collectors.SystemInfo{Hostname: "pve1.home.lan"}, + } + cfg := config.Default() + + header := b.buildHeader(snap, cfg, terminal.Env{Width: 60}) + + if header.Hostname != "pve1.home.lan" { + t.Errorf("header.Hostname = %q, want full name", header.Hostname) + } + found := false + for _, line := range header.Lines { + if line == "pve1.home.lan" { + found = true + } + } + if !found { + t.Errorf("expected full hostname line when art is shortened, lines = %v", header.Lines) + } +} + +func TestBanner_buildHeaderRespectsEnvWidth(t *testing.T) { + b, err := New(collectors.Providers{}, mustRenderer(t), nil) + if err != nil { + t.Fatalf("New() error = %v", err) + } + snap := collectors.Snapshot{ + System: collectors.SystemInfo{Hostname: "media-server-vault-01"}, + } + + // env.Width is the single width authority; layout.max_width folds into + // it upstream via render.ApplyConfig. + header := b.buildHeader(snap, config.Default(), terminal.Env{Width: 40}) + + for _, line := range strings.Split(header.Art, "\n") { + if n := len([]rune(line)); n > 40 { + t.Errorf("art line exceeds env width 40 (%d): %q", n, line) + } + } +} + +func TestBanner_buildHeaderOmitsBlankOSLine(t *testing.T) { + b, err := New(collectors.Providers{}, mustRenderer(t), nil) + if err != nil { + t.Fatalf("New() error = %v", err) + } + // A timed-out system collector leaves SystemInfo zero-valued. + snap := collectors.Snapshot{} + cfg := config.Default() + + header := b.buildHeader(snap, cfg, terminal.Env{}) + for _, line := range header.Lines { + if strings.TrimSpace(line) == "" { + t.Fatalf("header contains a blank line: %q", header.Lines) + } + } +} diff --git a/internal/banner/sections_system.go b/internal/banner/sections_system.go index 85ba0e8..a5b1ad2 100644 --- a/internal/banner/sections_system.go +++ b/internal/banner/sections_system.go @@ -26,14 +26,16 @@ func (SystemSectionBuilder) Build(snap collectors.Snapshot, cfg config.Config) ( if cfg.Display.Uptime && snap.System.Uptime > 0 { lines = append(lines, fmt.Sprintf("Uptime: %s", humanDuration(snap.System.Uptime))) } - if cfg.Display.User { - userLine := fmt.Sprintf("User: %s", valueOrFallback(snap.System.CurrentUser, "unknown")) + if cfg.Display.User && strings.TrimSpace(snap.System.CurrentUser) != "" { + userLine := fmt.Sprintf("User: %s", snap.System.CurrentUser) if snap.System.HomeDir != "" { userLine += " " + snap.System.HomeDir } lines = append(lines, userLine) } - if cfg.Display.Datetime { + // A timed-out system collector leaves Datetime zero; omit the line + // rather than print the epoch. + if cfg.Display.Datetime && !snap.System.Datetime.IsZero() { lines = append(lines, fmt.Sprintf("Time: %s", snap.System.Datetime.Format(time.RFC1123))) } if cfg.Display.LastLogin && snap.LastLogin != nil { @@ -97,13 +99,6 @@ func humanDuration(d time.Duration) string { return strings.Join(segments, " ") } -func valueOrFallback(value, fallback string) string { - if strings.TrimSpace(value) == "" { - return fallback - } - return value -} - func formatAddress(addr collectors.Address, withInterface bool) string { if !withInterface || strings.TrimSpace(addr.Interface) == "" { return addr.IP diff --git a/internal/banner/sections_test.go b/internal/banner/sections_test.go index b36a2cf..0423739 100644 --- a/internal/banner/sections_test.go +++ b/internal/banner/sections_test.go @@ -1,6 +1,7 @@ package banner import ( + "strings" "testing" "time" @@ -345,28 +346,6 @@ func TestHumanDuration(t *testing.T) { } } -func TestValueOrFallback(t *testing.T) { - tests := []struct { - name string - value string - fallback string - want string - }{ - {"non-empty value", "testvalue", "fallback", "testvalue"}, - {"empty value", "", "fallback", "fallback"}, - {"whitespace only", " ", "fallback", "fallback"}, - {"whitespace with content", " test ", "fallback", " test "}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := valueOrFallback(tt.value, tt.fallback); got != tt.want { - t.Errorf("valueOrFallback(%q, %q) = %q, want %q", tt.value, tt.fallback, got, tt.want) - } - }) - } -} - func TestFormatAddress(t *testing.T) { tests := []struct { name string @@ -408,3 +387,17 @@ func TestFormatAddress(t *testing.T) { }) } } + +func TestSystemSectionOmitsUnavailableData(t *testing.T) { + // A timed-out system collector leaves the snapshot zero-valued; the + // section must not invent an epoch timestamp or unknown user. + section, ok := SystemSectionBuilder{}.Build(collectors.Snapshot{}, config.Default()) + if !ok { + return // nothing to render is acceptable + } + for _, line := range section.Lines { + if strings.Contains(line, "0001") || strings.Contains(line, "unknown") { + t.Fatalf("section renders placeholder data: %q", line) + } + } +} diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 95172cf..ed03555 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -122,7 +122,7 @@ func overwriteConfig(ctx context.Context, cfgPath string, stderr io.Writer, resu if err := ctx.Err(); err != nil { return result, err } - data, err := renderDefaultConfig(now) + data, err := renderDefaultConfig(now, cfgPath) if err != nil { return result, fmt.Errorf("bootstrap: render default config: %w", err) } @@ -174,7 +174,7 @@ func createNewConfig(ctx context.Context, cfgPath string, stderr io.Writer, resu return result, nil } - data, err := renderDefaultConfig(now) + data, err := renderDefaultConfig(now, cfgPath) if err != nil { return result, fmt.Errorf("bootstrap: render default config: %w", err) } diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index edc7981..7cfb680 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -9,6 +9,8 @@ import ( "time" "gopkg.in/yaml.v3" + + "github.com/veteranbv/sysgreet/internal/config" ) type configYAML struct { @@ -80,3 +82,25 @@ func TestBootstrapCreatesDefaultConfig(t *testing.T) { t.Fatalf("created_at not RFC3339: %v", err) } } + +func TestBootstrapWritesTOMLForTOMLPath(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.toml") + + _, err := Bootstrap(context.Background(), cfgPath, IO{}, Options{FlagPolicy: "overwrite", Interactive: false}) + if err != nil { + t.Fatalf("Bootstrap: %v", err) + } + + t.Setenv("SYSGREET_CONFIG", cfgPath) + cfg, used, err := config.Load() + if err != nil { + t.Fatalf("bootstrapped .toml config does not parse: %v", err) + } + if used != cfgPath { + t.Fatalf("expected %s to be loaded, got %q", cfgPath, used) + } + if cfg.ASCII.Font != config.Default().ASCII.Font { + t.Fatalf("bootstrapped config lost defaults: %+v", cfg.ASCII) + } +} diff --git a/internal/bootstrap/default_profile.go b/internal/bootstrap/default_profile.go index c812b8a..4274c4e 100644 --- a/internal/bootstrap/default_profile.go +++ b/internal/bootstrap/default_profile.go @@ -1,17 +1,25 @@ package bootstrap import ( + "path/filepath" + "strings" "time" + "github.com/pelletier/go-toml/v2" "gopkg.in/yaml.v3" "github.com/veteranbv/sysgreet/internal/config" ) -// renderDefaultConfig marshals the default sysgreet configuration into YAML with metadata. -func renderDefaultConfig(now time.Time) ([]byte, error) { +// renderDefaultConfig marshals the default sysgreet configuration with +// metadata, in the format implied by the target path's extension so that a +// bootstrapped .toml config parses as TOML. +func renderDefaultConfig(now time.Time, path string) ([]byte, error) { cfg := config.Default() cfg.Version = config.SchemaVersion cfg.CreatedAt = now.UTC().Format(time.RFC3339) + if strings.EqualFold(filepath.Ext(path), ".toml") { + return toml.Marshal(cfg) + } return yaml.Marshal(cfg) } diff --git a/internal/collectors/resources_test.go b/internal/collectors/resources_test.go index a6a583d..b8e89ea 100644 --- a/internal/collectors/resources_test.go +++ b/internal/collectors/resources_test.go @@ -3,6 +3,7 @@ package collectors import ( "context" "testing" + "time" ) func TestResourceCollectorReturnsMetrics(t *testing.T) { @@ -27,3 +28,94 @@ func TestResourceCollectorReturnsMetrics(t *testing.T) { t.Fatalf("expected CPU mode set") } } + +func TestGatherRunsCollectorsConcurrently(t *testing.T) { + // Each stub sleeps 30ms; serial execution would take 150ms. + start := time.Now() + providers := Providers{ + System: slowSystemCollector{}, + Network: slowNetworkCollector{}, + Resources: slowResourceCollector{}, + Session: slowSessionCollector{}, + LastLogin: slowLastLoginCollector{}, + } + snap := providers.Gather(context.Background()) + elapsed := time.Since(start) + + if elapsed > 120*time.Millisecond { + t.Errorf("Gather took %v; collectors do not appear to run concurrently", elapsed) + } + if snap.System.Hostname != "slow" { + t.Errorf("system snapshot missing, got %+v", snap.System) + } + if snap.Network.Primary == nil { + t.Error("network snapshot missing") + } + if snap.Session.RemoteAddr != "203.0.113.7" { + t.Errorf("session snapshot missing, got %+v", snap.Session) + } + if snap.LastLogin == nil { + t.Error("last login snapshot missing") + } +} + +func TestGatherToleratesHangingCollector(t *testing.T) { + // One collector blocks without honoring ctx; the others finish. Gather + // must return at the deadline with the finished results applied. + providers := Providers{ + System: hangingSystemCollector{}, + Session: slowSessionCollector{}, + } + start := time.Now() + snap := providers.Gather(context.Background()) + if elapsed := time.Since(start); elapsed > gatherTimeout+150*time.Millisecond { + t.Errorf("Gather took %v; timeout did not bound a hanging collector", elapsed) + } + if snap.Session.RemoteAddr != "203.0.113.7" { + t.Errorf("results from finished collectors should survive a timeout, got %+v", snap.Session) + } +} + +type slowSystemCollector struct{} + +func (slowSystemCollector) CollectSystem(ctx context.Context) (SystemInfo, error) { + time.Sleep(30 * time.Millisecond) + return SystemInfo{Hostname: "slow"}, nil +} + +type slowNetworkCollector struct{} + +func (slowNetworkCollector) CollectNetwork(ctx context.Context) (NetworkInfo, error) { + time.Sleep(30 * time.Millisecond) + return NetworkInfo{Primary: &Address{IP: "10.0.0.1", Interface: "eth0"}}, nil +} + +type slowResourceCollector struct{} + +func (slowResourceCollector) CollectResources(ctx context.Context) (ResourceInfo, error) { + time.Sleep(30 * time.Millisecond) + return ResourceInfo{}, nil +} + +type slowSessionCollector struct{} + +func (slowSessionCollector) CollectSession(ctx context.Context) (SessionInfo, error) { + time.Sleep(30 * time.Millisecond) + return SessionInfo{RemoteAddr: "203.0.113.7"}, nil +} + +type slowLastLoginCollector struct{} + +func (slowLastLoginCollector) CollectLastLogin(ctx context.Context) (*LastLoginInfo, error) { + time.Sleep(30 * time.Millisecond) + return &LastLoginInfo{Timestamp: time.Now()}, nil +} + +// hangingSystemCollector ignores context cancellation entirely, modeling a +// blocking syscall; Gather must still return at its deadline. +type hangingSystemCollector struct{} + +func (hangingSystemCollector) CollectSystem(ctx context.Context) (SystemInfo, error) { + time.Sleep(10 * time.Second) + return SystemInfo{}, nil +} diff --git a/internal/collectors/system.go b/internal/collectors/system.go index e4b0b23..a599d7f 100644 --- a/internal/collectors/system.go +++ b/internal/collectors/system.go @@ -5,6 +5,10 @@ import ( "time" ) +// gatherTimeout bounds total collection time. A login banner that is a few +// metrics short beats one that hangs the shell. +const gatherTimeout = 250 * time.Millisecond + // SystemInfo captures host identity and session metadata. type SystemInfo struct { Hostname string @@ -112,51 +116,94 @@ type Providers struct { LastLogin LastLoginCollector } -// Gather builds a Snapshot using the configured providers. Missing collectors are tolerated to allow graceful degradation. +// Gather builds a Snapshot using the configured providers. Collectors run +// concurrently and share a single deadline; whatever has finished by then is +// used and the rest is abandoned, so a stuck collector can never delay a +// login even if it ignores context cancellation. Missing collectors and +// failures are tolerated to allow graceful degradation. func (p Providers) Gather(ctx context.Context) Snapshot { - var snap Snapshot + ctx, cancel := context.WithTimeout(ctx, gatherTimeout) + defer cancel() + + // Each collector sends an apply function; the snapshot is only ever + // touched from this goroutine, so an abandoned straggler cannot race + // the caller. The channel is buffered so stragglers finish into it + // instead of leaking blocked forever. + results := make(chan func(*Snapshot), 5) + launched := 0 + launch := func(fn func() func(*Snapshot)) { + launched++ + go func() { + results <- fn() + }() + } + if p.System != nil { - if sys, err := p.System.CollectSystem(ctx); err == nil { - fmtSystem(&snap.System, sys) - } else { - recordError("system", err) - } + launch(func() func(*Snapshot) { + sys, err := p.System.CollectSystem(ctx) + if err != nil { + recordError("system", err) + return nil + } + return func(s *Snapshot) { s.System = sys } + }) } if p.Network != nil { - if netInfo, err := p.Network.CollectNetwork(ctx); err == nil { - snap.Network = netInfo - } else { - recordError("network", err) - } + launch(func() func(*Snapshot) { + netInfo, err := p.Network.CollectNetwork(ctx) + if err != nil { + recordError("network", err) + return nil + } + return func(s *Snapshot) { s.Network = netInfo } + }) } if p.Resources != nil { - if res, err := p.Resources.CollectResources(ctx); err == nil { - snap.Resources = res - } else { - recordError("resources", err) - } + launch(func() func(*Snapshot) { + res, err := p.Resources.CollectResources(ctx) + if err != nil { + recordError("resources", err) + return nil + } + return func(s *Snapshot) { s.Resources = res } + }) } if p.Session != nil { - if session, err := p.Session.CollectSession(ctx); err == nil { - snap.Session = session - } else { - recordError("session", err) - } + launch(func() func(*Snapshot) { + session, err := p.Session.CollectSession(ctx) + if err != nil { + recordError("session", err) + return nil + } + return func(s *Snapshot) { s.Session = session } + }) } if p.LastLogin != nil { - if last, err := p.LastLogin.CollectLastLogin(ctx); err == nil { - snap.LastLogin = last - } else { - recordError("last_login", err) + launch(func() func(*Snapshot) { + last, err := p.LastLogin.CollectLastLogin(ctx) + if err != nil { + recordError("last_login", err) + return nil + } + return func(s *Snapshot) { s.LastLogin = last } + }) + } + + var snap Snapshot + for i := 0; i < launched; i++ { + select { + case apply := <-results: + if apply != nil { + apply(&snap) + } + case <-ctx.Done(): + recordError("gather", ctx.Err()) + return snap } } return snap } -func fmtSystem(dst *SystemInfo, src SystemInfo) { - *dst = src -} - // DemoSnapshot returns a realistic demo snapshot for screenshots and testing. func DemoSnapshot() Snapshot { now := time.Now() diff --git a/internal/config/config.go b/internal/config/config.go index c864f47..f061af6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,8 +21,11 @@ func Load() (Config, string, error) { cfg := Default() candidatePaths := defaultConfigPaths() + // An explicit config path (--config or SYSGREET_CONFIG) is exclusive: + // if that file is absent we fall back to built-in defaults, never to a + // different config file the user didn't ask for. if custom := os.Getenv("SYSGREET_CONFIG"); custom != "" { - candidatePaths = append([]string{custom}, candidatePaths...) + candidatePaths = []string{custom} } var usedPath string @@ -177,6 +180,9 @@ func mergeLayout(base *Config, layout *rawLayout) { if layout.Compact != nil { base.Layout.Compact = *layout.Compact } + if layout.MaxWidth != nil && *layout.MaxWidth >= 0 { + base.Layout.MaxWidth = *layout.MaxWidth + } } func mergeNetwork(base *Config, network *rawNetwork) { @@ -244,6 +250,11 @@ func applyLayoutOverrides(layout *LayoutConfig) { if v, ok := lookupBool("SYSGREET_LAYOUT_COMPACT"); ok { layout.Compact = v } + if max := os.Getenv("SYSGREET_LAYOUT_MAX_WIDTH"); max != "" { + if parsed, err := parseInt(max); err == nil && parsed >= 0 { + layout.MaxWidth = parsed + } + } if sections := os.Getenv("SYSGREET_LAYOUT_SECTIONS"); sections != "" { parts := strings.Split(sections, ",") var cleaned []string @@ -323,6 +334,7 @@ type rawASCII struct { type rawLayout struct { Compact *bool `yaml:"compact" toml:"compact"` + MaxWidth *int `yaml:"max_width" toml:"max_width"` Sections *[]string `yaml:"sections" toml:"sections"` } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4bde443..e2dde60 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -126,3 +126,81 @@ func TestDefaultWritePathFallsBackToHome(t *testing.T) { t.Fatalf("expected %s, got %s", expected, got) } } + +func TestLoad_MaxWidthFromYAML(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + cfgDir := dir + "/.config/sysgreet" + if err := os.MkdirAll(cfgDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + content := []byte(`layout: + max_width: 100 +`) + if err := os.WriteFile(cfgDir+"/config.yaml", content, 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + cfg, _, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.Layout.MaxWidth != 100 { + t.Fatalf("expected max_width 100, got %d", cfg.Layout.MaxWidth) + } +} + +func TestLoad_MaxWidthFromEnv(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("SYSGREET_LAYOUT_MAX_WIDTH", "90") + + cfg, _, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.Layout.MaxWidth != 90 { + t.Fatalf("expected max_width 90 from env, got %d", cfg.Layout.MaxWidth) + } +} + +func TestLoad_MaxWidthRejectsNegative(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("SYSGREET_LAYOUT_MAX_WIDTH", "-5") + + cfg, _, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.Layout.MaxWidth != 0 { + t.Fatalf("negative max_width should be ignored, got %d", cfg.Layout.MaxWidth) + } +} + +func TestLoad_ExplicitConfigPathIsExclusive(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + cfgDir := dir + "/.config/sysgreet" + if err := os.MkdirAll(cfgDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + // A default-path config that must NOT be picked up when an explicit + // path is requested but missing. + decoy := []byte("ascii:\n font: \"slant\"\n") + if err := os.WriteFile(cfgDir+"/config.yaml", decoy, 0o644); err != nil { + t.Fatalf("write decoy config: %v", err) + } + t.Setenv("SYSGREET_CONFIG", dir+"/does-not-exist.yaml") + + cfg, used, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if used != "" { + t.Fatalf("no config should have been loaded, got %q", used) + } + if cfg.ASCII.Font != Default().ASCII.Font { + t.Fatalf("expected built-in defaults, got font %q from decoy config", cfg.ASCII.Font) + } +} diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 28cc9c2..905ef94 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -27,7 +27,10 @@ type ASCIIConfig struct { // LayoutConfig controls banner layout options. type LayoutConfig struct { - Compact bool `yaml:"compact" toml:"compact"` + Compact bool `yaml:"compact" toml:"compact"` + // MaxWidth caps the banner width in columns. Zero means use the + // detected terminal width. + MaxWidth int `yaml:"max_width" toml:"max_width"` Sections []string `yaml:"sections" toml:"sections"` } diff --git a/internal/render/color.go b/internal/render/color.go index 3d24189..16a4851 100644 --- a/internal/render/color.go +++ b/internal/render/color.go @@ -1,65 +1,25 @@ package render import ( - "os" - "strings" + "github.com/veteranbv/sysgreet/internal/terminal" ) -var ansiCodes = map[string]string{ - "red": "\033[31m", - "yellow": "\033[33m", - "green": "\033[32m", - "cyan": "\033[36m", - "reset": "\033[0m", -} - -// Colorizer wraps text in ANSI sequences when enabled. +// Colorizer wraps text in ANSI sequences when the terminal profile allows. type Colorizer struct { - enabled bool + profile terminal.Profile } -// NewColorizer creates a colorizer that may be disabled via NO_COLOR or explicit flag. -func NewColorizer(disable bool) Colorizer { - if disable { - return Colorizer{enabled: false} - } - if _, ok := os.LookupEnv("NO_COLOR"); ok { - return Colorizer{enabled: false} - } - return Colorizer{enabled: true} +// NewColorizer creates a colorizer for the given terminal profile. +func NewColorizer(profile terminal.Profile) Colorizer { + return Colorizer{profile: profile} } -// Wrap applies color when enabled. +// Wrap applies color when the profile supports it. func (c Colorizer) Wrap(color, text string) string { - if !c.enabled { - return text - } - code, ok := ansiCodes[color] - if !ok || color == "reset" { - return text - } - return code + text + ansiCodes["reset"] + return terminal.Wrap(c.profile, color, text) } // Strip removes ANSI escape sequences from a string. func Strip(input string) string { - var b strings.Builder - i := 0 - runes := []rune(input) - for i < len(runes) { - if runes[i] == '' { - // Skip until letter - i++ - for i < len(runes) && ((runes[i] >= '0' && runes[i] <= '9') || runes[i] == '[' || runes[i] == ';') { - i++ - } - if i < len(runes) { - i++ - } - continue - } - b.WriteRune(runes[i]) - i++ - } - return b.String() + return terminal.Strip(input) } diff --git a/internal/render/color_test.go b/internal/render/color_test.go index b0780cd..ce581b4 100644 --- a/internal/render/color_test.go +++ b/internal/render/color_test.go @@ -1,64 +1,16 @@ package render import ( - "os" "strings" "testing" -) - -func TestNewColorizer(t *testing.T) { - tests := []struct { - name string - disable bool - setNoColor bool - wantEnabled bool - }{ - { - name: "enabled by default", - disable: false, - setNoColor: false, - wantEnabled: true, - }, - { - name: "explicitly disabled", - disable: true, - setNoColor: false, - wantEnabled: false, - }, - { - name: "disabled via NO_COLOR env", - disable: false, - setNoColor: true, - wantEnabled: false, - }, - { - name: "both disabled", - disable: true, - setNoColor: true, - wantEnabled: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Setup NO_COLOR environment - if tt.setNoColor { - os.Setenv("NO_COLOR", "1") - defer os.Unsetenv("NO_COLOR") - } - - c := NewColorizer(tt.disable) - if c.enabled != tt.wantEnabled { - t.Errorf("NewColorizer(%v).enabled = %v, want %v", tt.disable, c.enabled, tt.wantEnabled) - } - }) - } -} + "github.com/veteranbv/sysgreet/internal/terminal" +) func TestColorizer_Wrap(t *testing.T) { tests := []struct { name string - enabled bool + profile terminal.Profile color string text string wantContains []string @@ -66,49 +18,42 @@ func TestColorizer_Wrap(t *testing.T) { }{ { name: "red color enabled", - enabled: true, + profile: terminal.ProfileANSI, color: "red", text: "ERROR", wantContains: []string{"\033[31m", "ERROR", "\033[0m"}, }, { name: "yellow color enabled", - enabled: true, + profile: terminal.ProfileANSI, color: "yellow", text: "WARNING", wantContains: []string{"\033[33m", "WARNING", "\033[0m"}, }, { name: "green color enabled", - enabled: true, + profile: terminal.ProfileANSI, color: "green", text: "OK", wantContains: []string{"\033[32m", "OK", "\033[0m"}, }, { name: "cyan color enabled", - enabled: true, + profile: terminal.ProfileANSI, color: "cyan", text: "INFO", wantContains: []string{"\033[36m", "INFO", "\033[0m"}, }, { - name: "disabled colorizer", - enabled: false, + name: "no-color profile", + profile: terminal.ProfileNoColor, color: "red", text: "ERROR", wantEqual: true, }, - { - name: "reset color is passthrough", - enabled: true, - color: "reset", - text: "TEXT", - wantEqual: true, - }, { name: "unknown color is passthrough", - enabled: true, + profile: terminal.ProfileANSI, color: "magenta", text: "TEXT", wantEqual: true, @@ -117,7 +62,7 @@ func TestColorizer_Wrap(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - c := Colorizer{enabled: tt.enabled} + c := NewColorizer(tt.profile) result := c.Wrap(tt.color, tt.text) if tt.wantEqual { @@ -137,62 +82,8 @@ func TestColorizer_Wrap(t *testing.T) { } func TestStrip(t *testing.T) { - tests := []struct { - name string - input string - want string - }{ - { - name: "red ANSI code", - input: "\033[31mERROR\033[0m", - want: "ERROR", - }, - { - name: "yellow ANSI code", - input: "\033[33mWARNING\033[0m", - want: "WARNING", - }, - { - name: "multiple ANSI codes", - input: "\033[31mRED\033[0m and \033[32mGREEN\033[0m", - want: "RED and GREEN", - }, - { - name: "no ANSI codes", - input: "plain text", - want: "plain text", - }, - { - name: "empty string", - input: "", - want: "", - }, - { - name: "complex ANSI sequence", - input: "\033[1;31mBOLD RED\033[0m", - want: "BOLD RED", - }, - { - name: "only ANSI codes", - input: "\033[31m\033[0m", - want: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := Strip(tt.input) - if result != tt.want { - t.Errorf("Strip(%q) = %q, want %q", tt.input, result, tt.want) - } - }) - } -} - -func TestStripPreservesNonANSI(t *testing.T) { - input := "Hello 世界 🌍" - result := Strip(input) - if result != input { - t.Errorf("Strip(%q) = %q, want %q", input, result, input) + input := "\033[31mRED\033[0m and \033[32mGREEN\033[0m" + if got := Strip(input); got != "RED and GREEN" { + t.Errorf("Strip(%q) = %q", input, got) } } diff --git a/internal/render/json.go b/internal/render/json.go new file mode 100644 index 0000000..5b04e96 --- /dev/null +++ b/internal/render/json.go @@ -0,0 +1,50 @@ +package render + +import ( + "encoding/json" + + "github.com/veteranbv/sysgreet/internal/banner" + "github.com/veteranbv/sysgreet/internal/config" +) + +type jsonSection struct { + Key string `json:"key"` + Title string `json:"title"` + Lines []string `json:"lines"` + Data map[string]any `json:"data,omitempty"` +} + +type jsonBanner struct { + Hostname string `json:"hostname"` + Header []string `json:"header"` + Sections []jsonSection `json:"sections"` +} + +// RenderJSON emits the banner as structured JSON for scripting. Sections +// follow the configured layout order, same as the text output. +func RenderJSON(out banner.Output, cfg config.Config) (string, error) { + doc := jsonBanner{ + Hostname: out.Header.Hostname, + Header: out.Header.Lines, + Sections: []jsonSection{}, + } + if doc.Header == nil { + doc.Header = []string{} + } + for _, section := range orderSections(out.Sections, cfg.Layout.Sections) { + if len(section.Lines) == 0 { + continue + } + doc.Sections = append(doc.Sections, jsonSection{ + Key: section.Key, + Title: section.Title, + Lines: section.Lines, + Data: section.Data, + }) + } + data, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return "", err + } + return string(data), nil +} diff --git a/internal/render/layout.go b/internal/render/layout.go index 00ce7fa..f6aeaa2 100644 --- a/internal/render/layout.go +++ b/internal/render/layout.go @@ -6,16 +6,35 @@ import ( "github.com/veteranbv/sysgreet/internal/banner" "github.com/veteranbv/sysgreet/internal/config" + "github.com/veteranbv/sysgreet/internal/terminal" ) +// bodyIndent prefixes every section line in the full layout. +const bodyIndent = " " + // Renderer formats banner output into terminal-friendly text. type Renderer struct { colorizer Colorizer + width int +} + +// NewRenderer instantiates a renderer for the given terminal environment. +// A zero env.Width leaves lines unclipped. +func NewRenderer(env terminal.Env) Renderer { + return Renderer{colorizer: NewColorizer(env.Profile), width: env.Width} } -// NewRenderer instantiates a renderer with optional color suppression. -func NewRenderer(disableColor bool) Renderer { - return Renderer{colorizer: NewColorizer(disableColor)} +// ApplyConfig folds config-driven constraints into the detected terminal +// environment: layout.max_width caps the width and ascii.monochrome forces +// plain output everywhere, including resource threshold highlights. +func ApplyConfig(env terminal.Env, cfg config.Config) terminal.Env { + if max := cfg.Layout.MaxWidth; max > 0 && (env.Width == 0 || max < env.Width) { + env.Width = max + } + if cfg.ASCII.Monochrome { + env.Profile = terminal.ProfileNoColor + } + return env } // Render produces the final banner string. @@ -30,25 +49,32 @@ func (r Renderer) Render(out banner.Output, cfg config.Config) string { if len(out.Header.Lines) > 0 { builder.WriteString("\n") for _, line := range out.Header.Lines { - builder.WriteString(line) + builder.WriteString(r.clip(line, 0)) builder.WriteString("\n") } } + // The indent has to fit inside the width cap too; drop it on absurdly + // narrow terminals rather than overflow. + indent := bodyIndent + if r.width > 0 && r.width <= len(bodyIndent) { + indent = "" + } + sections := orderSections(out.Sections, cfg.Layout.Sections) for _, section := range sections { if len(section.Lines) == 0 { continue } builder.WriteString("\n") - builder.WriteString(section.Title) + builder.WriteString(r.clip(section.Title, 0)) builder.WriteString("\n") for _, line := range section.Lines { - formatted := line + formatted := r.clip(line, len(indent)) if section.Key == "resources" { - formatted = r.highlightResource(section, line) + formatted = r.highlightResource(section, formatted) } - builder.WriteString(" ") + builder.WriteString(indent) builder.WriteString(formatted) builder.WriteString("\n") } @@ -56,9 +82,18 @@ func (r Renderer) Render(out banner.Output, cfg config.Config) string { return strings.TrimRight(builder.String(), "\n") } +// renderCompact emits a single pipe-separated line using the plain hostname +// rather than the multi-line art. func (r Renderer) renderCompact(out banner.Output, cfg config.Config) string { - parts := []string{Strip(out.Header.Art)} - parts = append(parts, out.Header.Lines...) + parts := []string{strings.ToUpper(out.Header.Hostname)} + for _, line := range out.Header.Lines { + // The full-hostname fallback line exists to supplement shortened + // art; compact output already leads with the full name. + if line == out.Header.Hostname { + continue + } + parts = append(parts, line) + } sections := orderSections(out.Sections, cfg.Layout.Sections) for _, section := range sections { if len(section.Lines) == 0 { @@ -67,7 +102,21 @@ func (r Renderer) renderCompact(out banner.Output, cfg config.Config) string { parts = append(parts, section.Title) parts = append(parts, section.Lines...) } - return strings.Join(parts, " | ") + return r.clip(strings.Join(parts, " | "), 0) +} + +// clip truncates a line to the terminal width in display columns, +// accounting for indent and marking the cut with an ellipsis. Zero width +// leaves the line untouched. +func (r Renderer) clip(line string, indent int) string { + if r.width <= 0 { + return line + } + limit := r.width - indent + if limit < 1 { + limit = 1 + } + return terminal.Clip(line, limit) } func orderSections(sections []banner.Section, desired []string) []banner.Section { diff --git a/internal/render/layout_test.go b/internal/render/layout_test.go index 06950d8..575785b 100644 --- a/internal/render/layout_test.go +++ b/internal/render/layout_test.go @@ -6,6 +6,7 @@ import ( "github.com/veteranbv/sysgreet/internal/banner" "github.com/veteranbv/sysgreet/internal/config" + "github.com/veteranbv/sysgreet/internal/terminal" ) func TestRenderer_Render(t *testing.T) { @@ -85,7 +86,7 @@ func TestRenderer_Render(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - r := NewRenderer(true) // Disable color for deterministic output + r := NewRenderer(terminal.Env{}) // No color for deterministic output result := r.Render(tt.output, tt.cfg) for _, want := range tt.wantContains { @@ -109,8 +110,9 @@ func TestRenderer_RenderCompact(t *testing.T) { name: "compact mode with separator", output: banner.Output{ Header: banner.Header{ - Art: "HOST", - Lines: []string{"Linux 6.0"}, + Hostname: "host", + Art: "line1\nline2\nline3", + Lines: []string{"Linux 6.0"}, }, Sections: []banner.Section{ { @@ -132,7 +134,7 @@ func TestRenderer_RenderCompact(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - r := NewRenderer(true) + r := NewRenderer(terminal.Env{}) result := r.Render(tt.output, tt.cfg) for _, want := range tt.wantContains { @@ -282,7 +284,7 @@ func TestRenderer_HighlightResource(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - r := NewRenderer(tt.disableColor) + r := NewRenderer(envFor(tt.disableColor)) result := r.highlightResource(tt.section, tt.line) if !strings.Contains(result, tt.wantContains) { @@ -314,7 +316,7 @@ func TestRenderer_WrapForPercent(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - r := NewRenderer(tt.disableColor) + r := NewRenderer(envFor(tt.disableColor)) result := r.wrapForPercent(tt.pct, tt.line) hasColor := result != tt.line @@ -324,3 +326,226 @@ func TestRenderer_WrapForPercent(t *testing.T) { }) } } + +// envFor maps the old disable-color boolean used across these tests to a +// terminal environment. +func envFor(disable bool) terminal.Env { + if disable { + return terminal.Env{} + } + return terminal.Env{Profile: terminal.ProfileANSI} +} + +func TestRenderer_CompactIsSingleLine(t *testing.T) { + out := banner.Output{ + Header: banner.Header{ + Hostname: "pve1", + Art: "██\n██\n██", + Lines: []string{"Linux 6.8"}, + }, + Sections: []banner.Section{ + {Key: "system", Title: "System", Lines: []string{"Uptime: 1d"}}, + }, + } + cfg := config.Config{Layout: config.LayoutConfig{Compact: true}} + result := NewRenderer(terminal.Env{}).Render(out, cfg) + + if strings.Contains(result, "\n") { + t.Fatalf("compact output must be a single line, got:\n%s", result) + } + if !strings.Contains(result, "PVE1") { + t.Errorf("compact output missing hostname, got: %s", result) + } + if strings.Contains(result, "██") { + t.Errorf("compact output must not include ASCII art, got: %s", result) + } +} + +func TestRenderer_ClipsBodyLinesToWidth(t *testing.T) { + out := banner.Output{ + Header: banner.Header{Hostname: "host", Art: "HOST"}, + Sections: []banner.Section{ + { + Key: "system", + Title: "System", + Lines: []string{"Last login: Fri, 10 Oct 2025 09:45:00 PDT from 203.0.113.10"}, + }, + }, + } + cfg := config.Default() + result := NewRenderer(terminal.Env{Width: 40}).Render(out, cfg) + + for _, line := range strings.Split(result, "\n") { + if n := len([]rune(line)); n > 40 { + t.Errorf("line exceeds width 40 (%d): %q", n, line) + } + } + if !strings.Contains(result, "…") { + t.Errorf("expected clipped line to end with ellipsis, got:\n%s", result) + } +} + +func TestRenderer_UnconstrainedLeavesLinesAlone(t *testing.T) { + long := "Last login: Fri, 10 Oct 2025 09:45:00 PDT from 203.0.113.10" + out := banner.Output{ + Header: banner.Header{Hostname: "host", Art: "HOST"}, + Sections: []banner.Section{ + {Key: "system", Title: "System", Lines: []string{long}}, + }, + } + result := NewRenderer(terminal.Env{}).Render(out, config.Default()) + if !strings.Contains(result, long) { + t.Errorf("unconstrained render should not clip lines, got:\n%s", result) + } +} + +func TestRenderJSON(t *testing.T) { + out := banner.Output{ + Header: banner.Header{ + Hostname: "pve1", + Art: "ART", + Lines: []string{"Linux 6.8 (x86_64)"}, + }, + Sections: []banner.Section{ + {Key: "resources", Title: "Resources", Lines: []string{"Mem: 23% used"}, Data: map[string]any{"memory_used_percent": 23}}, + {Key: "system", Title: "System", Lines: []string{"Uptime: 1d"}}, + }, + } + cfg := config.Default() + doc, err := RenderJSON(out, cfg) + if err != nil { + t.Fatalf("RenderJSON error: %v", err) + } + for _, want := range []string{`"hostname": "pve1"`, `"Linux 6.8 (x86_64)"`, `"memory_used_percent": 23`} { + if !strings.Contains(doc, want) { + t.Errorf("JSON missing %q:\n%s", want, doc) + } + } + if strings.Contains(doc, "ART") { + t.Errorf("JSON should not include ASCII art:\n%s", doc) + } + // Sections must follow the configured order: system before resources. + if strings.Index(doc, `"key": "system"`) > strings.Index(doc, `"key": "resources"`) { + t.Errorf("JSON sections not in layout order:\n%s", doc) + } +} + +func TestRenderer_CompactClipsToWidth(t *testing.T) { + out := banner.Output{ + Header: banner.Header{ + Hostname: "pve1", + Lines: []string{"Linux 6.8 (x86_64)"}, + }, + Sections: []banner.Section{ + {Key: "system", Title: "System", Lines: []string{ + "Uptime: 4d 12h 30m", + "Last login: Fri, 10 Oct 2025 09:45:00 PDT (203.0.113.10)", + }}, + }, + } + cfg := config.Config{Layout: config.LayoutConfig{Compact: true}} + result := NewRenderer(terminal.Env{Width: 50}).Render(out, cfg) + + if n := len([]rune(result)); n > 50 { + t.Errorf("compact line exceeds width 50 (%d): %q", n, result) + } + if !strings.HasSuffix(result, "…") { + t.Errorf("expected clipped compact line to end with ellipsis: %q", result) + } +} + +func TestApplyConfig(t *testing.T) { + base := terminal.Env{Width: 120, Profile: terminal.ProfileANSI} + + capped := ApplyConfig(base, config.Config{Layout: config.LayoutConfig{MaxWidth: 80}}) + if capped.Width != 80 { + t.Errorf("max_width should cap detected width: got %d", capped.Width) + } + + wider := ApplyConfig(base, config.Config{Layout: config.LayoutConfig{MaxWidth: 200}}) + if wider.Width != 120 { + t.Errorf("max_width above terminal width must not widen: got %d", wider.Width) + } + + unknown := ApplyConfig(terminal.Env{Profile: terminal.ProfileANSI}, config.Config{Layout: config.LayoutConfig{MaxWidth: 80}}) + if unknown.Width != 80 { + t.Errorf("max_width should apply when terminal width is unknown: got %d", unknown.Width) + } + + mono := ApplyConfig(base, config.Config{ASCII: config.ASCIIConfig{Monochrome: true}}) + if mono.Profile != terminal.ProfileNoColor { + t.Errorf("monochrome config should force ProfileNoColor, got %v", mono.Profile) + } + + untouched := ApplyConfig(base, config.Config{}) + if untouched != base { + t.Errorf("empty config should leave env unchanged: %+v", untouched) + } +} + +func TestRenderer_ClipsSectionTitles(t *testing.T) { + out := banner.Output{ + Header: banner.Header{Hostname: "vm", Art: "VM"}, + Sections: []banner.Section{ + {Key: "resources", Title: "Resources", Lines: []string{"Mem: 4%"}}, + }, + } + result := NewRenderer(terminal.Env{Width: 8}).Render(out, config.Default()) + for _, line := range strings.Split(result, "\n") { + if n := len([]rune(line)); n > 8 { + t.Errorf("line exceeds width 8 (%d): %q", n, line) + } + } +} + +func TestRenderer_CompactDoesNotDuplicateHostname(t *testing.T) { + out := banner.Output{ + Header: banner.Header{ + Hostname: "pve1.home.lan", + // The width ladder shortened the art, so the full hostname was + // added as an info line. + Lines: []string{"pve1.home.lan", "Linux 6.8 (x86_64)"}, + }, + } + cfg := config.Config{Layout: config.LayoutConfig{Compact: true}} + result := NewRenderer(terminal.Env{}).Render(out, cfg) + + if strings.Count(strings.ToLower(result), "pve1.home.lan") != 1 { + t.Fatalf("compact output repeats the hostname: %q", result) + } + if !strings.Contains(result, "Linux 6.8 (x86_64)") { + t.Fatalf("compact output lost the OS line: %q", result) + } +} + +func TestRenderer_TinyWidthsNeverOverflow(t *testing.T) { + out := banner.Output{ + Header: banner.Header{Hostname: "vm", Art: "…"}, + Sections: []banner.Section{ + {Key: "system", Title: "System", Lines: []string{"Uptime: 4d 12h"}}, + }, + } + for _, width := range []int{1, 2, 3, 4} { + result := NewRenderer(terminal.Env{Width: width}).Render(out, config.Default()) + for _, line := range strings.Split(result, "\n") { + if n := len([]rune(line)); n > width { + t.Errorf("width %d: line has %d columns: %q", width, n, line) + } + } + } +} + +func TestRenderer_ClipsWideRunesByColumns(t *testing.T) { + out := banner.Output{ + Header: banner.Header{Hostname: "vm", Art: "VM"}, + Sections: []banner.Section{ + {Key: "system", Title: "System", Lines: []string{"User: 田中太郎 /home/田中太郎"}}, + }, + } + result := NewRenderer(terminal.Env{Width: 20}).Render(out, config.Default()) + for _, line := range strings.Split(result, "\n") { + if n := terminal.DisplayWidth(line); n > 20 { + t.Errorf("line occupies %d columns at width 20: %q", n, line) + } + } +} diff --git a/internal/terminal/terminal.go b/internal/terminal/terminal.go new file mode 100644 index 0000000..0e6d83e --- /dev/null +++ b/internal/terminal/terminal.go @@ -0,0 +1,217 @@ +// Package terminal detects the capabilities of the terminal sysgreet writes +// to: how many columns are available and how much color it can display. It +// also owns the ANSI palette shared by the ascii and render packages. +package terminal + +import ( + "fmt" + "os" + "strconv" + "strings" + + "golang.org/x/term" +) + +// Profile describes the color capability of the output stream. +type Profile int + +const ( + // ProfileNoColor means no escape sequences should be emitted. + ProfileNoColor Profile = iota + // ProfileANSI means the classic 16-color palette is available. + ProfileANSI + // ProfileTrueColor means 24-bit foreground colors are available. + ProfileTrueColor +) + +// Env captures everything the renderers need to know about the output stream. +type Env struct { + // Width is the usable column count. Zero means unconstrained (for + // example when output is piped and COLUMNS is unset). + Width int + Profile Profile +} + +// DetectEnv inspects the output stream and environment once at startup. +// disableColor forces ProfileNoColor regardless of terminal capability. +func DetectEnv(out *os.File, disableColor bool) Env { + return Env{ + Width: Width(out), + Profile: DetectProfile(out, disableColor), + } +} + +// Width reports the column count of the terminal attached to out, falling +// back to the COLUMNS environment variable. Returns 0 when unknown so +// callers can render at natural width. +func Width(out *os.File) int { + if out != nil { + if w, _, err := term.GetSize(int(out.Fd())); err == nil && w > 0 { + return w + } + } + if cols := os.Getenv("COLUMNS"); cols != "" { + if w, err := strconv.Atoi(strings.TrimSpace(cols)); err == nil && w > 0 { + return w + } + } + return 0 +} + +// DetectProfile determines the color capability of out. Precedence: +// explicit disable, then NO_COLOR (https://no-color.org), then TERM=dumb, +// then whether out is a terminal at all, then COLORTERM for truecolor. +func DetectProfile(out *os.File, disableColor bool) Profile { + if disableColor { + return ProfileNoColor + } + if _, ok := os.LookupEnv("NO_COLOR"); ok { + return ProfileNoColor + } + if os.Getenv("TERM") == "dumb" { + return ProfileNoColor + } + if out == nil || !term.IsTerminal(int(out.Fd())) { + return ProfileNoColor + } + switch os.Getenv("COLORTERM") { + case "truecolor", "24bit": + return ProfileTrueColor + } + return ProfileANSI +} + +// Reset is the ANSI attribute reset sequence. +const Reset = "\033[0m" + +// ansiCodes maps the color names accepted in configuration to foreground +// escape sequences. +var ansiCodes = map[string]string{ + "red": "\033[31m", + "green": "\033[32m", + "yellow": "\033[33m", + "blue": "\033[34m", + "purple": "\033[35m", + "cyan": "\033[36m", + "gray": "\033[37m", + "white": "\033[97m", + "brightblue": "\033[94m", + "brightcyan": "\033[96m", + "brightwhite": "\033[97m", +} + +// rgbValues maps the same color names to 24-bit values for truecolor +// gradients. Values follow the common xterm palette. +var rgbValues = map[string][3]uint8{ + "red": {205, 49, 49}, + "green": {13, 188, 121}, + "yellow": {229, 229, 16}, + "blue": {36, 114, 200}, + "purple": {188, 63, 188}, + "cyan": {17, 168, 205}, + "gray": {229, 229, 229}, + "white": {255, 255, 255}, + "brightblue": {59, 142, 234}, + "brightcyan": {41, 184, 219}, + "brightwhite": {255, 255, 255}, +} + +// Code returns the ANSI escape sequence for a named color. +func Code(name string) (string, bool) { + code, ok := ansiCodes[strings.ToLower(name)] + return code, ok +} + +// RGB returns the 24-bit value for a named color. +func RGB(name string) ([3]uint8, bool) { + rgb, ok := rgbValues[strings.ToLower(name)] + return rgb, ok +} + +// ForegroundRGB builds a 24-bit foreground escape sequence. +func ForegroundRGB(r, g, b uint8) string { + return fmt.Sprintf("\033[38;2;%d;%d;%dm", r, g, b) +} + +// Wrap surrounds text with the named color when the profile allows it. +func Wrap(p Profile, color, text string) string { + if p == ProfileNoColor { + return text + } + code, ok := ansiCodes[strings.ToLower(color)] + if !ok { + return text + } + return code + text + Reset +} + +// RuneWidth returns the terminal column width of r: 2 for the common East +// Asian wide/fullwidth and emoji ranges, 1 otherwise. Zero-width combining +// marks are rare in banner content and treated as width 1. +func RuneWidth(r rune) int { + switch { + case r >= 0x1100 && r <= 0x115F, // Hangul Jamo + r >= 0x2E80 && r <= 0xA4CF, // CJK radicals through Yi + r >= 0xAC00 && r <= 0xD7A3, // Hangul syllables + r >= 0xF900 && r <= 0xFAFF, // CJK compatibility ideographs + r >= 0xFE30 && r <= 0xFE4F, // CJK compatibility forms + r >= 0xFF00 && r <= 0xFF60, // fullwidth forms + r >= 0xFFE0 && r <= 0xFFE6, + r >= 0x1F300 && r <= 0x1FAFF, // emoji + r >= 0x20000 && r <= 0x3FFFD: // CJK extensions + return 2 + } + return 1 +} + +// DisplayWidth returns the terminal column count of s, which must not +// contain escape sequences (Strip first if it might). +func DisplayWidth(s string) int { + width := 0 + for _, r := range s { + width += RuneWidth(r) + } + return width +} + +// Clip truncates s to at most width display columns, marking dropped +// content with an ellipsis. s must not contain escape sequences. +func Clip(s string, width int) string { + if width <= 0 || DisplayWidth(s) <= width { + return s + } + if width == 1 { + return "…" + } + cols := 0 + for i, r := range s { + rw := RuneWidth(r) + if cols+rw > width-1 { + return s[:i] + "…" + } + cols += rw + } + return s +} + +// Strip removes ANSI CSI escape sequences from a string. +func Strip(input string) string { + var b strings.Builder + runes := []rune(input) + i := 0 + for i < len(runes) { + if runes[i] == '\033' { + i++ + for i < len(runes) && ((runes[i] >= '0' && runes[i] <= '9') || runes[i] == '[' || runes[i] == ';') { + i++ + } + if i < len(runes) { + i++ + } + continue + } + b.WriteRune(runes[i]) + i++ + } + return b.String() +} diff --git a/internal/terminal/terminal_test.go b/internal/terminal/terminal_test.go new file mode 100644 index 0000000..f5ae909 --- /dev/null +++ b/internal/terminal/terminal_test.go @@ -0,0 +1,178 @@ +package terminal + +import ( + "os" + "path/filepath" + "testing" +) + +// pipeFile returns a non-terminal file for detection tests. +func pipeFile(t *testing.T) *os.File { + t.Helper() + f, err := os.Create(filepath.Join(t.TempDir(), "out")) + if err != nil { + t.Fatalf("create temp file: %v", err) + } + t.Cleanup(func() { _ = f.Close() }) + return f +} + +func TestDetectProfileExplicitDisable(t *testing.T) { + if got := DetectProfile(pipeFile(t), true); got != ProfileNoColor { + t.Errorf("explicit disable: got %v, want ProfileNoColor", got) + } +} + +func TestDetectProfileNoColorEnv(t *testing.T) { + t.Setenv("NO_COLOR", "1") + if got := DetectProfile(pipeFile(t), false); got != ProfileNoColor { + t.Errorf("NO_COLOR set: got %v, want ProfileNoColor", got) + } +} + +func TestDetectProfileDumbTerm(t *testing.T) { + t.Setenv("TERM", "dumb") + if got := DetectProfile(pipeFile(t), false); got != ProfileNoColor { + t.Errorf("TERM=dumb: got %v, want ProfileNoColor", got) + } +} + +func TestDetectProfileNonTerminal(t *testing.T) { + // A regular file is not a terminal, so color must be off even with a + // color-friendly environment. + t.Setenv("TERM", "xterm-256color") + t.Setenv("COLORTERM", "truecolor") + if got := DetectProfile(pipeFile(t), false); got != ProfileNoColor { + t.Errorf("non-terminal output: got %v, want ProfileNoColor", got) + } +} + +func TestWidthFallsBackToColumnsEnv(t *testing.T) { + t.Setenv("COLUMNS", "72") + if got := Width(pipeFile(t)); got != 72 { + t.Errorf("COLUMNS fallback: got %d, want 72", got) + } +} + +func TestWidthUnknown(t *testing.T) { + t.Setenv("COLUMNS", "") + if got := Width(pipeFile(t)); got != 0 { + t.Errorf("unknown width: got %d, want 0", got) + } +} + +func TestWidthIgnoresGarbageColumns(t *testing.T) { + t.Setenv("COLUMNS", "not-a-number") + if got := Width(pipeFile(t)); got != 0 { + t.Errorf("garbage COLUMNS: got %d, want 0", got) + } +} + +func TestWrap(t *testing.T) { + tests := []struct { + name string + profile Profile + color string + text string + want string + }{ + {"ansi red", ProfileANSI, "red", "ERROR", "\033[31mERROR\033[0m"}, + {"truecolor uses named code", ProfileTrueColor, "cyan", "X", "\033[36mX\033[0m"}, + {"no color passthrough", ProfileNoColor, "red", "ERROR", "ERROR"}, + {"unknown color passthrough", ProfileANSI, "magenta", "TEXT", "TEXT"}, + {"case insensitive", ProfileANSI, "RED", "E", "\033[31mE\033[0m"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Wrap(tt.profile, tt.color, tt.text); got != tt.want { + t.Errorf("Wrap(%v, %q, %q) = %q, want %q", tt.profile, tt.color, tt.text, got, tt.want) + } + }) + } +} + +func TestStrip(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"red code", "\033[31mERROR\033[0m", "ERROR"}, + {"multiple codes", "\033[31mRED\033[0m and \033[32mGREEN\033[0m", "RED and GREEN"}, + {"truecolor code", "\033[38;2;36;114;200mBLUE\033[0m", "BLUE"}, + {"no codes", "plain text", "plain text"}, + {"empty", "", ""}, + {"compound sequence", "\033[1;31mBOLD RED\033[0m", "BOLD RED"}, + {"only codes", "\033[31m\033[0m", ""}, + {"unicode preserved", "Hello 世界 🌍", "Hello 世界 🌍"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Strip(tt.input); got != tt.want { + t.Errorf("Strip(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestForegroundRGB(t *testing.T) { + if got := ForegroundRGB(36, 114, 200); got != "\033[38;2;36;114;200m" { + t.Errorf("ForegroundRGB = %q", got) + } +} + +func TestCodeAndRGBCoverSameNames(t *testing.T) { + for name := range ansiCodes { + if _, ok := rgbValues[name]; !ok { + t.Errorf("color %q has an ANSI code but no RGB value", name) + } + } + for name := range rgbValues { + if _, ok := ansiCodes[name]; !ok { + t.Errorf("color %q has an RGB value but no ANSI code", name) + } + } +} + +func TestDisplayWidth(t *testing.T) { + tests := []struct { + in string + want int + }{ + {"hello", 5}, + {"", 0}, + {"日本語", 6}, + {"héllo", 5}, + {"user-日本", 9}, + } + for _, tt := range tests { + if got := DisplayWidth(tt.in); got != tt.want { + t.Errorf("DisplayWidth(%q) = %d, want %d", tt.in, got, tt.want) + } + } +} + +func TestClip(t *testing.T) { + tests := []struct { + in string + width int + want string + }{ + {"hello", 10, "hello"}, + {"hello", 5, "hello"}, + {"hello", 4, "hel…"}, + {"hello", 1, "…"}, + {"hello", 0, "hello"}, // zero width = unconstrained + {"日本語です", 4, "日…"}, + {"日本語です", 5, "日本…"}, + } + for _, tt := range tests { + got := Clip(tt.in, tt.width) + if got != tt.want { + t.Errorf("Clip(%q, %d) = %q, want %q", tt.in, tt.width, got, tt.want) + } + if tt.width > 0 && DisplayWidth(got) > tt.width { + t.Errorf("Clip(%q, %d) = %q occupies %d columns", tt.in, tt.width, got, DisplayWidth(got)) + } + } +} diff --git a/test/benchmarks/startup_bench_test.go b/test/benchmarks/startup_bench_test.go index 5e27def..5bfbbcf 100644 --- a/test/benchmarks/startup_bench_test.go +++ b/test/benchmarks/startup_bench_test.go @@ -8,6 +8,7 @@ import ( "github.com/veteranbv/sysgreet/internal/banner" "github.com/veteranbv/sysgreet/internal/collectors" "github.com/veteranbv/sysgreet/internal/config" + "github.com/veteranbv/sysgreet/internal/terminal" ) // BenchmarkStartup is a high-level benchmark that builds a banner using stub collectors. @@ -26,7 +27,7 @@ func BenchmarkStartup(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - if _, _, err := nBanner.Build(ctx, cfg); err != nil { + if _, _, err := nBanner.Build(ctx, cfg, terminal.Env{}); err != nil { b.Fatalf("banner build failed: %v", err) } } diff --git a/test/integration/binary_test.go b/test/integration/binary_test.go index 3157968..e763445 100644 --- a/test/integration/binary_test.go +++ b/test/integration/binary_test.go @@ -1,9 +1,11 @@ package integration import ( + "encoding/json" "os" "os/exec" "path/filepath" + "strconv" "strings" "testing" "time" @@ -230,3 +232,168 @@ func TestBinaryMemoryFootprint(t *testing.T) { t.Logf("Binary size: %.2f MB", sizeMB) } + +// buildTestBinary compiles sysgreet into dir and returns its path. +func buildTestBinary(t *testing.T, dir string) string { + t.Helper() + binaryPath := filepath.Join(dir, "sysgreet") + buildCmd := exec.Command("go", "build", "-o", binaryPath, "../../cmd/sysgreet") + if output, err := buildCmd.CombinedOutput(); err != nil { + t.Fatalf("failed to build binary: %v\nOutput: %s", err, output) + } + return binaryPath +} + +func TestBinaryJSONOutput(t *testing.T) { + if testing.Short() { + t.Skip("skipping binary build in short mode") + } + tmpDir := t.TempDir() + binaryPath := buildTestBinary(t, tmpDir) + + cmd := exec.Command(binaryPath, "--json") + cmd.Env = append(os.Environ(), "SYSGREET_CONFIG="+filepath.Join(tmpDir, "config.yaml"), "CI=1") + output, err := cmd.Output() + if err != nil { + t.Fatalf("--json run failed: %v", err) + } + + var doc struct { + Hostname string `json:"hostname"` + Sections []struct { + Key string `json:"key"` + Lines []string `json:"lines"` + } `json:"sections"` + } + if err := json.Unmarshal(output, &doc); err != nil { + t.Fatalf("--json output is not valid JSON: %v\nOutput: %s", err, output) + } + if doc.Hostname == "" { + t.Errorf("JSON output missing hostname: %s", output) + } + if strings.Contains(string(output), "\033") { + t.Errorf("JSON output contains escape sequences") + } +} + +func TestBinaryWidthNeverOverflows(t *testing.T) { + if testing.Short() { + t.Skip("skipping binary build in short mode") + } + tmpDir := t.TempDir() + binaryPath := buildTestBinary(t, tmpDir) + + for _, width := range []string{"30", "50", "80"} { + cmd := exec.Command(binaryPath, "--text", "media-server-vault-01", "--width", width) + output, err := cmd.Output() + if err != nil { + t.Fatalf("--width %s run failed: %v", width, err) + } + limit, _ := strconv.Atoi(width) + for _, line := range strings.Split(string(output), "\n") { + if n := len([]rune(line)); n > limit { + t.Errorf("--width %s: line has %d columns: %q", width, n, line) + } + } + } +} + +func TestBinaryListFonts(t *testing.T) { + if testing.Short() { + t.Skip("skipping binary build in short mode") + } + tmpDir := t.TempDir() + binaryPath := buildTestBinary(t, tmpDir) + + output, err := exec.Command(binaryPath, "--list-fonts").Output() + if err != nil { + t.Fatalf("--list-fonts run failed: %v", err) + } + for _, want := range []string{"ANSI Regular", "standard", "Small"} { + if !strings.Contains(string(output), want) { + t.Errorf("--list-fonts missing %q\nGot: %s", want, output) + } + } +} + +func TestBinaryTextModeHonorsConfigFont(t *testing.T) { + if testing.Short() { + t.Skip("skipping binary build in short mode") + } + tmpDir := t.TempDir() + binaryPath := buildTestBinary(t, tmpDir) + + configPath := filepath.Join(tmpDir, "config.yaml") + config := "ascii:\n font: \"standard\"\n monochrome: true\n" + if err := os.WriteFile(configPath, []byte(config), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + cmd := exec.Command(binaryPath, "--text", "hi") + cmd.Env = append(os.Environ(), "SYSGREET_CONFIG="+configPath, "CI=1") + output, err := cmd.Output() + if err != nil { + t.Fatalf("--text run failed: %v", err) + } + // The standard font draws with slashes and underscores, not the solid + // blocks of the default ANSI Regular. + if strings.Contains(string(output), "█") { + t.Errorf("--text ignored the configured font\nGot: %s", output) + } + if !strings.Contains(string(output), "_") { + t.Errorf("expected standard-font glyphs in output\nGot: %s", output) + } +} + +func TestBinaryNoColorFlag(t *testing.T) { + if testing.Short() { + t.Skip("skipping binary build in short mode") + } + tmpDir := t.TempDir() + binaryPath := buildTestBinary(t, tmpDir) + + cmd := exec.Command(binaryPath, "--demo", "--no-color") + output, err := cmd.Output() + if err != nil { + t.Fatalf("--no-color run failed: %v", err) + } + if strings.Contains(string(output), "\033") { + t.Errorf("--no-color output contains escape sequences") + } +} + +func TestBinaryJSONIsWidthIndependent(t *testing.T) { + if testing.Short() { + t.Skip("skipping binary build in short mode") + } + tmpDir := t.TempDir() + binaryPath := buildTestBinary(t, tmpDir) + + headerFor := func(args ...string) []string { + t.Helper() + cmd := exec.Command(binaryPath, append([]string{"--json"}, args...)...) + cmd.Env = append(os.Environ(), "SYSGREET_CONFIG="+filepath.Join(tmpDir, "config.yaml"), "CI=1") + output, err := cmd.Output() + if err != nil { + t.Fatalf("--json %v run failed: %v", args, err) + } + var doc struct { + Header []string `json:"header"` + } + if err := json.Unmarshal(output, &doc); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + return doc.Header + } + + wide := headerFor("--width", "200") + narrow := headerFor("--width", "20") + if len(wide) != len(narrow) { + t.Fatalf("JSON header depends on terminal width: %v vs %v", wide, narrow) + } + for i := range wide { + if wide[i] != narrow[i] { + t.Fatalf("JSON header depends on terminal width: %v vs %v", wide, narrow) + } + } +} diff --git a/test/integration/linux/config_test.go b/test/integration/linux/config_test.go index 064d7fa..46ab4cc 100644 --- a/test/integration/linux/config_test.go +++ b/test/integration/linux/config_test.go @@ -12,6 +12,7 @@ import ( "github.com/veteranbv/sysgreet/internal/collectors" "github.com/veteranbv/sysgreet/internal/config" "github.com/veteranbv/sysgreet/internal/render" + "github.com/veteranbv/sysgreet/internal/terminal" ) func TestYAMLConfigDisablesNetworkSections(t *testing.T) { @@ -59,11 +60,11 @@ layout: if err != nil { t.Fatalf("banner.New: %v", err) } - out, _, err := b.Build(context.Background(), cfg) + out, _, err := b.Build(context.Background(), cfg, terminal.Env{}) if err != nil { t.Fatalf("Build: %v", err) } - txt := render.NewRenderer(true).Render(out, cfg) + txt := render.NewRenderer(terminal.Env{}).Render(out, cfg) if strings.Contains(txt, "System") { t.Fatalf("expected system section to be disabled, got %s", txt) } diff --git a/test/integration/windows/session_test.go b/test/integration/windows/session_test.go index 0db62d8..197cf83 100644 --- a/test/integration/windows/session_test.go +++ b/test/integration/windows/session_test.go @@ -13,6 +13,7 @@ import ( "github.com/veteranbv/sysgreet/internal/collectors" "github.com/veteranbv/sysgreet/internal/config" "github.com/veteranbv/sysgreet/internal/render" + "github.com/veteranbv/sysgreet/internal/terminal" ) func TestSessionCollectorFallsBackToSSHClient(t *testing.T) { @@ -103,11 +104,11 @@ layout.sections = ["network", "system"] if err != nil { t.Fatalf("banner.New: %v", err) } - out, _, err := b.Build(context.Background(), cfg) + out, _, err := b.Build(context.Background(), cfg, terminal.Env{}) if err != nil { t.Fatalf("Build: %v", err) } - result := render.NewRenderer(true).Render(out, cfg) + result := render.NewRenderer(terminal.Env{}).Render(out, cfg) if strings.Contains(result, "Mem:") { t.Fatalf("expected memory data suppressed, got %s", result) }