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") + } +}