From c207a05170387c649c21b9b2fad005381bffad16 Mon Sep 17 00:00:00 2001 From: dipto0321 Date: Fri, 3 Jul 2026 13:39:40 +0600 Subject: [PATCH] feat(ui): scaffold internal/ui with Writer + theme + mode Establishes the single source of truth for user-facing output ahead of the larger #74 migration. Four files in a new internal/ui package: - mode.go: Mode type (PlainMode / FancyMode) and the single DecideMode decision point (--no-color, NO_COLOR env, TTY detection for stdout + stdin). Centralizing the TTY check here keeps lipgloss's color profile detection and our gating in sync. - theme.go: DefaultTheme with 6 lipgloss styles (Success/Error/Warning/Info/Dim/Heading). Muted colors so output reads as informational, not decorative. - writer.go: Writer interface (6 methods), NewWriter constructor, plain + fancy implementations. FancyMode forces termenv.TrueColor so ANSI escapes emit even when stdout is not a TTY (the test-only escape hatch). - writer_test.go: 5 tests pinning the routing rules (stdout vs stderr per level, trailing-newline contract, ANSI + glyph presence in FancyMode, NO_COLOR override, Mode.String). Root.go's PersistentPreRunE now resolves the Writer once per invocation and stashes it on cmd.Context() under an unexported key. warnInterruptedUpgrade takes the Writer and routes its hint lines through w.Warn. Subcommands read it back via writerFromCmd(cmd). nodeup version is the proof-of-concept migration: every fmt.Fprintf becomes w.Println(fmt.Sprintf(...)). The byte-level output shape is identical (Println renders raw bytes in both modes), so the existing TestVersionCmd_* tests pass unchanged. A new TestVersionCmd_UsesInjectedWriter exercises the NewRootCmd wiring end-to-end and pins the PlainMode byte contract (no ANSI, no glyphs). Closes #74 (PR1 of 4). --- CHANGELOG.md | 14 +++++ go.mod | 13 +++++ go.sum | 30 ++++++++++ internal/cli/root.go | 37 ++++++++---- internal/cli/version.go | 48 ++++++++++++--- internal/cli/version_test.go | 49 ++++++++++++++++ internal/ui/mode.go | 76 ++++++++++++++++++++++++ internal/ui/theme.go | 43 ++++++++++++++ internal/ui/writer.go | 110 +++++++++++++++++++++++++++++++++++ internal/ui/writer_test.go | 105 +++++++++++++++++++++++++++++++++ 10 files changed, 507 insertions(+), 18 deletions(-) create mode 100644 internal/ui/mode.go create mode 100644 internal/ui/theme.go create mode 100644 internal/ui/writer.go create mode 100644 internal/ui/writer_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 20a699f..b3de3b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- `internal/ui` package: the single source of truth for user-facing + output. Two implementations of a `Writer` interface — + `PlainWriter` (no ANSI, no decoration, used when stdout/stdin + aren't TTYs or when `--no-color`/`NO_COLOR` is set) and + `FancyWriter` (lipgloss-styled, used in real terminals). The mode + decision lives in `ui.DecideMode` and is computed once per + invocation in root.go's `PersistentPreRunE`, then stashed on + `cmd.Context()` so subcommands read it via a tiny `writerFromCmd` + helper rather than constructing their own. `nodeup version` is + migrated as the proof-of-concept for the larger migration; the + remaining commands (upgrade, check, list, packages, config) are + migrated in follow-up PRs per #74's phasing. Spinners and + interactive prompts (bubbletea + huh) ship in later PRs in the + same series. Closes #74 (PR1 of 4). - Post-upgrade cleanup prompt (`nodeup upgrade`): after a successful upgrade, nodeup asks whether to delete the old Node.js versions left behind. The prompt offers three options — `y` deletes every diff --git a/go.mod b/go.mod index a5071eb..4622c23 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,24 @@ go 1.24.0 require ( github.com/Masterminds/semver/v3 v3.5.0 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/muesli/termenv v0.16.0 github.com/spf13/cobra v1.10.2 gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.30.0 // indirect ) diff --git a/go.sum b/go.sum index 421b1d7..a34c242 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,44 @@ github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/cli/root.go b/internal/cli/root.go index 8a35771..da5d286 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -10,12 +10,13 @@ package cli import ( + "context" "fmt" - "os" "github.com/spf13/cobra" "github.com/dipto0321/nodeup/internal/packages" + "github.com/dipto0321/nodeup/internal/ui" ) // warnInterruptedUpgrade checks for an orphaned upgrade sentinel and @@ -28,15 +29,15 @@ import ( // "no sentinel" are deliberately swallowed: a corrupted sentinel file // is a cosmetic issue and should not prevent the user's actual command // from running. -func warnInterruptedUpgrade(_ *cobra.Command, _ []string) { +func warnInterruptedUpgrade(w ui.Writer) { s, err := packages.OrphanedSentinel() if err != nil || s == nil { return } - fmt.Fprintf(os.Stderr, "Detected an interrupted upgrade (snapshot: %s, started: %s).\n", - s.SnapshotPath, s.StartedAt.Format("2006-01-02T15:04:05Z07:00")) - fmt.Fprintf(os.Stderr, "To resume: `nodeup packages restore --from %s`\n", - s.SnapshotPath) + w.Warn(fmt.Sprintf("Detected an interrupted upgrade (snapshot: %s, started: %s).", + s.SnapshotPath, s.StartedAt.Format("2006-01-02T15:04:05Z07:00"))) + w.Warn(fmt.Sprintf("To resume: `nodeup packages restore --from %s`", + s.SnapshotPath)) } // NewRootCmd builds the root `nodeup` command with all subcommands attached. @@ -66,12 +67,26 @@ Common workflows: Docs: https://github.com/dipto0321/nodeup`, SilenceUsage: true, // don't dump --help on every error SilenceErrors: true, // we print errors ourselves in main() - // PersistentPreRunE runs before every subcommand. The - // interrupted-upgrade warning must run for the root command too - // (e.g., bare `nodeup` shows help), so we also attach it as - // PersistentPreRun below — cobra calls both. + // PersistentPreRunE runs before every subcommand. Two jobs: + // 1. Resolve the ui.Writer for this invocation (single + // DecideMode call, result cached on the cmd.Context()). + // 2. Fire the interrupted-upgrade warning. + // cobra's docs guarantee PersistentPreRunE runs for the root + // command too (e.g., bare `nodeup` showing help), so we get + // both behaviors for free without needing PersistentPreRun + // alongside. PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - warnInterruptedUpgrade(cmd, args) + noColor, _ := cmd.Flags().GetBool("no-color") + out := cmd.OutOrStdout() + errOut := cmd.ErrOrStderr() + if errOut == nil { + errOut = out + } + w := ui.NewWriter(ui.DecideMode(noColor), out, errOut) + ctx := context.WithValue(cmd.Context(), writerCtxKey{}, w) + cmd.SetContext(ctx) + + warnInterruptedUpgrade(w) return nil }, } diff --git a/internal/cli/version.go b/internal/cli/version.go index 884a360..eedbe16 100644 --- a/internal/cli/version.go +++ b/internal/cli/version.go @@ -5,6 +5,8 @@ import ( "runtime" "github.com/spf13/cobra" + + "github.com/dipto0321/nodeup/internal/ui" ) // newVersionCmd returns the `nodeup version` subcommand. @@ -12,6 +14,13 @@ import ( // It prints the version, git commit, build date, and runtime info (Go // version, OS, architecture). The --check flag is reserved for a future // self-update mechanism (out of scope for v1.0.0). +// +// This command is the PoC for the internal/ui migration (#74 PR1): it +// reads its ui.Writer out of cmd.Context() (stashed there by root.go's +// PersistentPreRunE) and routes every line through Writer.Println so +// the byte-level shape stays identical in both PlainMode and +// FancyMode. Future PRs (#74 PR2/3/4) will migrate the other +// commands. func newVersionCmd(version, commit, date string) *cobra.Command { var check bool @@ -24,20 +33,21 @@ Example: nodeup version nodeup version --check # check for a newer release (planned v2)`, RunE: func(cmd *cobra.Command, args []string) error { - out := cmd.OutOrStdout() + w := writerFromCmd(cmd) - fmt.Fprintf(out, "nodeup version %s\n", version) - fmt.Fprintf(out, " commit: %s\n", commit) - fmt.Fprintf(out, " built: %s\n", date) - fmt.Fprintf(out, " go version: %s\n", runtime.Version()) - fmt.Fprintf(out, " platform: %s/%s\n", runtime.GOOS, runtime.GOARCH) + w.Println(fmt.Sprintf("nodeup version %s", version)) + w.Println(fmt.Sprintf(" commit: %s", commit)) + w.Println(fmt.Sprintf(" built: %s", date)) + w.Println(fmt.Sprintf(" go version: %s", runtime.Version())) + w.Println(fmt.Sprintf(" platform: %s/%s", runtime.GOOS, runtime.GOARCH)) if check { // Self-update is a v2 feature — out of scope for v1. // We intentionally do nothing here but make the flag a // no-op rather than failing, so user scripts that pre-set // the flag don't break. - fmt.Fprintln(out, "\n(update check is not yet implemented)") + w.Println("") + w.Println("(update check is not yet implemented)") } return nil @@ -48,3 +58,27 @@ Example: return cmd } + +// writerFromCmd pulls the ui.Writer out of cobra's context. root.go's +// PersistentPreRunE stashes a Writer under writerCtxKey so subcommands +// don't have to know how DecideMode was resolved or how to construct a +// Writer themselves. If the key is missing (e.g., a test constructs a +// leaf command in isolation without going through NewRootCmd), we fall +// back to a PlainMode writer backed by cmd.OutOrStdout/ErrOrStderr so +// tests still get sensible output. +func writerFromCmd(cmd *cobra.Command) ui.Writer { + if v, ok := cmd.Context().Value(writerCtxKey{}).(ui.Writer); ok && v != nil { + return v + } + out := cmd.OutOrStdout() + errOut := cmd.ErrOrStderr() + if errOut == nil { + errOut = out + } + return ui.NewWriter(ui.PlainMode, out, errOut) +} + +// writerCtxKey is the unexported context.Context key used by +// root.go to stash the active ui.Writer for subcommands. The empty +// struct type guarantees no collisions with keys from other packages. +type writerCtxKey struct{} diff --git a/internal/cli/version_test.go b/internal/cli/version_test.go index 8be30f4..27f3e39 100644 --- a/internal/cli/version_test.go +++ b/internal/cli/version_test.go @@ -119,3 +119,52 @@ func TestVersionCmd_CheckFlagIsNoOpNotError(t *testing.T) { t.Errorf("expected --check to print the placeholder, got:\n%s", got) } } + +// TestVersionCmd_UsesInjectedWriter is the unit test for the #74 PR1 +// PoC migration: it constructs a root command via NewRootCmd (so +// PersistentPreRunE runs and the writerCtxKey gets populated), then +// runs `nodeup version` and confirms the output bytes came from the +// ui.Writer (not from fmt.Fprintf against cmd.OutOrStdout). The +// proof is that the bytes are routed to whichever sink we asked for +// — in PlainMode that's a direct passthrough, so the strings are +// byte-for-byte identical to the legacy test above. +func TestVersionCmd_UsesInjectedWriter(t *testing.T) { + out := &bytes.Buffer{} + errOut := &bytes.Buffer{} + + // NewRootCmd wires --no-color into a PersistentPreRunE that + // resolves DecideMode and stashes a ui.Writer on cmd.Context(). + // We want PlainMode (the default in `go test`), so we don't set + // the --no-color flag — the writer should be auto-detected as + // plain because the test's stdout isn't a TTY. + root := NewRootCmd("v1.2.3", "abc1234", "2026-01-02T03:04:05Z") + root.SetOut(out) + root.SetErr(errOut) + root.SetArgs([]string{"version"}) + if err := root.Execute(); err != nil { + t.Fatalf("root.Execute: %v", err) + } + + got := out.String() + for _, want := range []string{ + "nodeup version v1.2.3", + " commit: abc1234", + " built: 2026-01-02T03:04:05Z", + " go version: go", + " platform: ", + } { + if !strings.Contains(got, want) { + t.Errorf("expected output to contain %q, got:\n%s", want, got) + } + } + + // In PlainMode the bytes flow straight through to the buffer, so + // there are no glyphs / ANSI escapes to worry about — the byte + // shape must match what the legacy test pins. + if strings.Contains(got, "\x1b[") { + t.Errorf("PlainMode output should not contain ANSI escapes, got:\n%s", got) + } + if strings.Contains(got, "✓") { + t.Errorf("PlainMode output should not contain glyphs, got:\n%s", got) + } +} diff --git a/internal/ui/mode.go b/internal/ui/mode.go new file mode 100644 index 0000000..7a32242 --- /dev/null +++ b/internal/ui/mode.go @@ -0,0 +1,76 @@ +// Package ui is the single source of truth for user-facing output +// in nodeup. Every cli/*.go command talks to internal/ui's Writer +// interface instead of touching cmd.Printf / fmt.Fprintf directly. +// This keeps the Charm stack dependencies contained to one package +// and makes the plain-vs-fancy switch a single decision point. +// +// The architectural rule (CLAUDE.md, "Output routing"): nothing in +// internal/ or cmd/ prints directly. Violation = anything that +// calls fmt.Fprintln / cmd.Printf outside this package. +package ui + +import ( + "os" +) + +// Mode describes how the UI layer renders. CLI commands call +// ui.Plain() / ui.Fancy() to construct the right Writer; the rest +// of the codebase never branches on Mode itself. +type Mode int + +const ( + // PlainMode emits undecorated text with no ANSI codes. Used + // when stdout isn't a real terminal (piped, redirected, CI) + // or when --json / --yes was passed. + PlainMode Mode = iota + // FancyMode uses the full Charm stack (lipgloss + bubbletea + + // huh) for colored output, spinners, and interactive prompts. + FancyMode +) + +// DecideMode is the single decision point for plain-vs-fancy. The +// rule, in order: if NO_COLOR env var is set OR the user passed +// --no-color OR stdout isn't a TTY OR stdin isn't a TTY (interactive +// prompts would deadlock), fall back to PlainMode. Otherwise +// FancyMode. +// +// The --no-color flag is plumbed through from cobra in root.go's +// NewRootCmd and read here via the noColor parameter. +func DecideMode(noColor bool) Mode { + if noColor { + return PlainMode + } + if os.Getenv("NO_COLOR") != "" { + return PlainMode + } + if !isTerminal(os.Stdout) || !isTerminal(os.Stdin) { + return PlainMode + } + return FancyMode +} + +// isTerminal reports whether the given file descriptor is connected +// to a real terminal (vs a pipe / redirect / file). Used by +// DecideMode; centralizing it here means lipgloss's color profile +// detection and our TTY gating never disagree. +func isTerminal(f *os.File) bool { + // lipgloss itself does its own TTY detection, but we need the + // answer earlier (before the Writer is constructed) and for + // stdin (which lipgloss doesn't check). A simple os.Stat on + // the device-mode bit is portable across mac/linux/windows. + stat, err := f.Stat() + if err != nil { + return false + } + return (stat.Mode() & os.ModeCharDevice) != 0 +} + +// String renders Mode for debugging (e.g., --verbose logs). +func (m Mode) String() string { + switch m { + case FancyMode: + return "fancy" + default: + return "plain" + } +} diff --git a/internal/ui/theme.go b/internal/ui/theme.go new file mode 100644 index 0000000..fa87c15 --- /dev/null +++ b/internal/ui/theme.go @@ -0,0 +1,43 @@ +package ui + +import "github.com/charmbracelet/lipgloss" + +// Theme is the shared palette + style definitions used by the +// FancyMode renderer. Constructed once per Writer; cheap to pass +// by value (lipgloss.Style is itself a value type). +// +// The colors here are deliberately muted — nodeup output is +// informational, not decorative. The accent colors (success green, +// error red, warning amber) are AA-readable against the standard +// dark terminal background. Light-background terminals fall back +// gracefully (lipgloss auto-dims). +type Theme struct { + // Success styles a positive confirmation ("✓ upgraded to v22"). + Success lipgloss.Style + // Error styles an error message header. + Error lipgloss.Style + // Warning styles a non-fatal warning ("⚠ cleanup skipped"). + Warning lipgloss.Style + // Info styles an informational prefix ("Using manager: fnm"). + Info lipgloss.Style + // Dim is for secondary text (timestamps, file paths in + // machine-readable contexts). + Dim lipgloss.Style + // Heading styles section headings in the final report. + Heading lipgloss.Style +} + +// DefaultTheme returns the standard nodeup theme. Future PRs +// (per #74's phasing: 4. report.go + remaining migrations) can +// swap this for a `--theme=dark|light|...` flag without touching +// any call-site. +func DefaultTheme() Theme { + return Theme{ + Success: lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true), + Error: lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true), + Warning: lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true), + Info: lipgloss.NewStyle().Foreground(lipgloss.Color("39")), + Dim: lipgloss.NewStyle().Foreground(lipgloss.Color("245")), + Heading: lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Bold(true).Underline(true), + } +} diff --git a/internal/ui/writer.go b/internal/ui/writer.go new file mode 100644 index 0000000..2974c0e --- /dev/null +++ b/internal/ui/writer.go @@ -0,0 +1,110 @@ +package ui + +import ( + "io" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" +) + +// Writer is the interface every cli/*.go command talks to instead +// of touching cmd.Printf / fmt.Fprintf directly. The two +// implementations are PlainWriter (no ANSI, no spinners, no +// interactive prompts) and FancyWriter (lipgloss-styled output; +// spinners + prompts added in follow-up PRs per #74's phasing). +// +// Construction: +// +// ui.NewWriter(ui.DecideMode(noColor), os.Stdout, os.Stderr) +// +// Then call sites do: +// +// ui.Out(w).Success("Upgraded to v22.11.0") +// ui.Out(w).Info("Using manager: fnm") +// ui.Out(w).Warn("cleanup skipped") +// ui.Out(w).Error("snapshot failed: %w") +// +// The out/err writers are passed at construction time (not +// hard-coded to os.Stdout/Stderr) so tests can inject bytes.Buffers. +type Writer interface { + // Success prints a positive confirmation to stdout. + Success(msg string) + // Info prints an informational line to stdout. + Info(msg string) + // Warn prints a non-fatal warning to stderr. + Warn(msg string) + // Error prints an error line to stderr. + Error(msg string) + // Println writes a plain line to stdout with no decoration. + // For machine-readable output (JSON envelopes, table rows + // whose format is part of the API). + Println(msg string) + // Mode returns the writer's render mode (PlainMode / + // FancyMode). Useful for tests asserting on the decision. + Mode() Mode +} + +// NewWriter returns the right Writer for the given Mode. +// +// In PlainMode the underlying Writer wraps the supplied io.Writer +// directly — every Success / Info / Println becomes a single +// fmt.Fprintln with no ANSI codes. Error / Warn route to errOut +// (also no decoration). +// +// In FancyMode the Writer applies lipgloss styles from +// DefaultTheme() to the same io.Writers. Spinners and interactive +// prompts are wired in by follow-up PRs (#74 phasing). +func NewWriter(mode Mode, out, errOut io.Writer) Writer { + if mode == FancyMode { + // Force TrueColor so ANSI escapes are emitted regardless of + // what lipgloss's own TTY detection thinks. The whole point + // of FancyMode is "the user wants color" — by the time + // DecideMode returned FancyMode we've already confirmed both + // stdout and stdin are TTYs. Re-probing here would only ever + // downgrade to Ascii in piped unit-test scenarios where the + // test is explicitly checking that the escapes get emitted. + lipgloss.SetColorProfile(termenv.TrueColor) + return &fancyWriter{out: out, errOut: errOut, theme: DefaultTheme()} + } + return &plainWriter{out: out, errOut: errOut} +} + +// plainWriter is the PlainMode implementation. Every method writes +// a single line to the appropriate sink with no styling. +type plainWriter struct { + out io.Writer + errOut io.Writer +} + +func (p *plainWriter) Success(msg string) { _, _ = io.WriteString(p.out, msg+"\n") } +func (p *plainWriter) Info(msg string) { _, _ = io.WriteString(p.out, msg+"\n") } +func (p *plainWriter) Warn(msg string) { _, _ = io.WriteString(p.errOut, msg+"\n") } +func (p *plainWriter) Error(msg string) { _, _ = io.WriteString(p.errOut, msg+"\n") } +func (p *plainWriter) Println(msg string) { _, _ = io.WriteString(p.out, msg+"\n") } +func (p *plainWriter) Mode() Mode { return PlainMode } + +// fancyWriter is the FancyMode implementation. Each method +// applies a lipgloss style from the theme. Spinner / prompt +// methods are added in follow-up PRs per #74's phasing; this PR +// just establishes the Writer surface so cli/*.go has one +// well-defined dependency to migrate to. +type fancyWriter struct { + out io.Writer + errOut io.Writer + theme Theme +} + +func (f *fancyWriter) Success(msg string) { + _, _ = io.WriteString(f.out, f.theme.Success.Render("✓ "+msg)+"\n") +} +func (f *fancyWriter) Info(msg string) { + _, _ = io.WriteString(f.out, f.theme.Info.Render("• "+msg)+"\n") +} +func (f *fancyWriter) Warn(msg string) { + _, _ = io.WriteString(f.errOut, f.theme.Warning.Render("⚠ "+msg)+"\n") +} +func (f *fancyWriter) Error(msg string) { + _, _ = io.WriteString(f.errOut, f.theme.Error.Render("✗ "+msg)+"\n") +} +func (f *fancyWriter) Println(msg string) { _, _ = io.WriteString(f.out, msg+"\n") } +func (f *fancyWriter) Mode() Mode { return FancyMode } diff --git a/internal/ui/writer_test.go b/internal/ui/writer_test.go new file mode 100644 index 0000000..69a6dd8 --- /dev/null +++ b/internal/ui/writer_test.go @@ -0,0 +1,105 @@ +package ui + +import ( + "bytes" + "strings" + "testing" +) + +// TestPlainWriter_RoutesByLevel pins the basic invariant: Success / +// Info / Println go to stdout; Warn / Error go to stderr. The +// plain writer never decorates, so the bytes the caller passes are +// the bytes the caller gets back (with a trailing newline). +func TestPlainWriter_RoutesByLevel(t *testing.T) { + var out, errOut bytes.Buffer + w := NewWriter(PlainMode, &out, &errOut) + + w.Success("upgraded") + w.Info("using fnm") + w.Println("plain row") + w.Warn("skipped") + w.Error("snapshot failed") + + if got := out.String(); !strings.Contains(got, "upgraded") || + !strings.Contains(got, "using fnm") || + !strings.Contains(got, "plain row") { + t.Errorf("stdout missing expected lines, got: %q", got) + } + if got := errOut.String(); !strings.Contains(got, "skipped") || + !strings.Contains(got, "snapshot failed") { + t.Errorf("stderr missing expected lines, got: %q", got) + } + // Cross-contamination check: stderr must not have success/info + // lines, stdout must not have warn/error lines. + if strings.Contains(out.String(), "skipped") { + t.Errorf("stdout received a Warn line: %q", out.String()) + } + if strings.Contains(errOut.String(), "upgraded") { + t.Errorf("stderr received a Success line: %q", errOut.String()) + } +} + +// TestPlainWriter_AddsNewlines pins the trailing-newline contract. +// Plain output goes line-by-line so a downstream `nodeup ... | jq` +// (or just a `nodeup version | head`) sees one record per line. +func TestPlainWriter_AddsNewlines(t *testing.T) { + var out bytes.Buffer + w := NewWriter(PlainMode, &out, nil) + w.Info("hello") + if got := out.String(); got != "hello\n" { + t.Errorf("got %q, want %q", got, "hello\n") + } +} + +// TestFancyWriter_EmitsANSI pins that FancyMode writes ANSI escape +// sequences for the colored prefixes (✓ / • / ⚠ / ✗). This is the +// minimum-viable proof that the lipgloss styles are wired in. +func TestFancyWriter_EmitsANSI(t *testing.T) { + var out bytes.Buffer + w := NewWriter(FancyMode, &out, &out) + w.Success("upgraded") + w.Info("using fnm") + w.Warn("skipped") + w.Error("failed") + + got := out.String() + if !strings.Contains(got, "\x1b[") { + t.Errorf("fancy output missing ANSI escape sequences: %q", got) + } + for _, prefix := range []string{"✓", "•", "⚠", "✗"} { + if !strings.Contains(got, prefix) { + t.Errorf("fancy output missing glyph %q: %q", prefix, got) + } + } +} + +// TestDecideMode_PinTTYDetection pins the rules documented on +// DecideMode: NO_COLOR → PlainMode, otherwise the test runner's +// stdio (which in `go test` is a pipe) wins. We can't easily fake +// a TTY from inside `go test`, so this test just checks the env-var +// branch (the dominant case in CI / piped scripts). +func TestDecideMode_PinTTYDetection(t *testing.T) { + t.Setenv("NO_COLOR", "1") + if got := DecideMode(false); got != PlainMode { + t.Errorf("NO_COLOR=1 should force PlainMode, got %v", got) + } + + // Without NO_COLOR, the answer depends on whether stdout is a + // TTY — under `go test` it isn't, so PlainMode is the expected + // outcome. The point of the test is that noColor=true always + // wins (user override beats env, beats detection). + if got := DecideMode(true); got != PlainMode { + t.Errorf("noColor=true should force PlainMode, got %v", got) + } +} + +// TestMode_String pins the debug-string rendering. Cosmetic, but +// used in --verbose logs. +func TestMode_String(t *testing.T) { + if PlainMode.String() != "plain" { + t.Errorf("PlainMode.String() = %q, want %q", PlainMode.String(), "plain") + } + if FancyMode.String() != "fancy" { + t.Errorf("FancyMode.String() = %q, want %q", FancyMode.String(), "fancy") + } +}