Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
30 changes: 30 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down
37 changes: 26 additions & 11 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
},
}
Expand Down
48 changes: 41 additions & 7 deletions internal/cli/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,22 @@ import (
"runtime"

"github.com/spf13/cobra"

"github.com/dipto0321/nodeup/internal/ui"
)

// newVersionCmd returns the `nodeup version` subcommand.
//
// 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

Expand All @@ -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
Expand All @@ -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{}
49 changes: 49 additions & 0 deletions internal/cli/version_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
76 changes: 76 additions & 0 deletions internal/ui/mode.go
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Loading