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
10 changes: 8 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.24.0

require (
github.com/Masterminds/semver/v3 v3.5.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/muesli/termenv v0.16.0
github.com/spf13/cobra v1.10.2
Expand All @@ -13,15 +14,20 @@ require (
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/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // 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-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // 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
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.3.8 // indirect
)
21 changes: 17 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,35 @@ github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAw
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/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
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/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
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-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
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/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
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=
Expand All @@ -36,9 +46,12 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu
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.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
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
115 changes: 101 additions & 14 deletions internal/cli/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/dipto0321/nodeup/internal/node"
"github.com/dipto0321/nodeup/internal/packages"
"github.com/dipto0321/nodeup/internal/platform"
"github.com/dipto0321/nodeup/internal/ui"
)

// newUpgradeCmd implements `nodeup upgrade` — upgrade LTS and/or Current versions.
Expand Down Expand Up @@ -132,19 +133,34 @@ func runUpgrade(cmd *cobra.Command, args []string) error {
// JSON / table output.
warnSystemNodeIfNeeded(cmd.Context(), m, os.Stderr)

// Fetch versions
// Fetch versions. In PlainMode (CI, piped output, --no-color),
// the spinner is a one-shot "Fetching versions ..." line so log
// aggregators see one record per operation. In FancyMode (real
// terminal), bubbletea animates a braille-frame spinner while
// nodejs.org responds. The spinner Stop() call after work returns
// is idempotent in PlainMode; in FancyMode it asks tea to drain
// and exits.
var manifest node.Manifest
if offline {
manifest, err = node.LoadCached()
if err != nil {
return fmt.Errorf("load cached manifest: %w", err)
}
} else {
// Use the ctx-aware variant so Ctrl-C cancels an in-flight
// nodejs.org fetch. The package-level httpClient.Timeout (30s,
// set in dist.go) covers the case where nodejs.org simply
// hangs without ever acknowledging cancellation. See #48.
manifest, err = node.FetchManifestCtx(cmd.Context())
}
if err != nil {
return fmt.Errorf("fetch versions: %w", err)
fetch := func() error {
m, ferr := node.FetchManifestCtx(cmd.Context())
if ferr == nil {
manifest = m
}
return ferr
}
if serr := ui.WaitWithSpinner(cmd.Context(), spinnerFor(cmd, "Fetching versions"), fetch); serr != nil {
return fmt.Errorf("fetch versions: %w", serr)
}
}

var targetVersions []*node.ManifestVersion
Expand Down Expand Up @@ -237,13 +253,23 @@ func runUpgrade(cmd *cobra.Command, args []string) error {
// the latest installed version — this is the path both the
// sentinel arms and the restore step reads from, so we compute it
// once and pass it down.
//
// Each per-version snapshot runs under its own spinner — the user
// sees one "Snapshotting globals (v22.10.0) ..." line per installed
// version. For users with many installed versions this keeps the
// progress legible without re-architecting the loop.
var restoreSnapshotPath string
var oldVersion string
if !skipMigrate {
ctx := cmd.Context()
for _, v := range installedVersions {
if err := packages.Snapshot(ctx, m.Name(), v); err != nil {
cmd.Printf("Warning: snapshot failed for %s: %v\n", v, err)
ver := v
take := func() error {
return packages.Snapshot(ctx, m.Name(), ver)
}
label := fmt.Sprintf("Snapshotting globals (%s)", ver)
if serr := ui.WaitWithSpinner(ctx, spinnerFor(cmd, label), take); serr != nil {
cmd.Printf("Warning: snapshot failed for %s: %v\n", ver, serr)
}
}
if len(installedVersions) > 0 {
Expand Down Expand Up @@ -285,19 +311,31 @@ func runUpgrade(cmd *cobra.Command, args []string) error {
}
}

// Install new versions
// Install new versions. Each version gets its own spinner line
// — the manager's Install call can take 10-60s per version (it's
// downloading a Node tarball and unpacking into the manager dir),
// so this is the main place the user feels the wait. Per-version
// scoped labels make the progress visible in plain log output too.
for _, v := range toInstall {
cmd.Printf("Installing %s...\n", v)
if err := m.Install(*v); err != nil {
return fmt.Errorf("install %s: %w", v, err)
install := func() error {
return m.Install(*v)
}
label := fmt.Sprintf("Installing %s", v)
if serr := ui.WaitWithSpinner(cmd.Context(), spinnerFor(cmd, label), install); serr != nil {
return fmt.Errorf("install %s: %w", v, serr)
}
}

// Set default
if len(toInstall) > 0 {
latest := toInstall[len(toInstall)-1]
if err := m.SetDefault(*latest); err != nil {
return fmt.Errorf("set default: %w", err)
ver := latest
setDefault := func() error {
return m.SetDefault(*ver)
}
label := fmt.Sprintf("Setting default to %s", ver)
if serr := ui.WaitWithSpinner(cmd.Context(), spinnerFor(cmd, label), setDefault); serr != nil {
return fmt.Errorf("set default: %w", serr)
}
}

Expand Down Expand Up @@ -327,7 +365,31 @@ func runUpgrade(cmd *cobra.Command, args []string) error {
if restoreSnapshotPath == "" {
cmd.Printf("Warning: no snapshot path available to restore from; skipping package migration\n")
} else {
outcome, rerr := packages.RestoreFromSnapshot(cmd.Context(), restoreSnapshotPath)
// Snapshot path → restore. The restore step runs `npm
// install -g <pkg>` per captured global, so for users with
// many globals this is the second longest step after the
// initial install. The spinner here wraps the entire
// restore call (loop-all, see #103) — finer-grained
// per-package progress is in the MigrationReport rather
// than the spinner.
var outcome packages.RestoreOutcome
restore := func() error {
// RestoreFromSnapshot populates outcome (with
// partial results) even when it returns an error —
// we want those for the MigrationReport below.
// The contract on RestoreFromSnapshot (see packages/
// snapshot.go) is to return whatever it managed to
// install before failing, so always capture outcome
// regardless of rerr.
o, rerr := packages.RestoreFromSnapshot(cmd.Context(), restoreSnapshotPath)
outcome = o
return rerr
}
rerr := ui.WaitWithSpinner(
cmd.Context(),
spinnerFor(cmd, "Migrating global packages"),
restore,
)

// Write a MigrationReport whenever we actually attempted at
// least one package. Skipping the write on empty results
Expand Down Expand Up @@ -444,6 +506,31 @@ func parseVersion(s string) (*semver.Version, error) {
return semver.NewVersion(s)
}

// spinnerFor constructs a Spinner matching the active ui.Writer's
// mode, rendered to cmd's stderr. We deliberately route the spinner
// to stderr (not stdout) so plain-mode users can pipe `nodeup upgrade
// | jq` for machine-readable progress tracking and the spinner line
// still appears on the terminal — the human-side channel.
//
// In FancyMode, the bubbletea program gets its own pipe so its frames
// don't interleave with whatever the caller writes via Writer.Success
// during the same operation. See internal/ui/spinner.go for the
// pipe-isolation rationale.
//
// Per CLAUDE.md / #105 invariants, this is the only call site in
// internal/cli that knows about the spinner interface; the rest of
// the codebase talks to ui.WaitWithSpinner. Future PRs (PR3, PR4) can
// thread spinners into other long-running commands without revisiting
// this file.
func spinnerFor(cmd *cobra.Command, label string) ui.Spinner {
w := writerFromCmd(cmd)
stderr := cmd.ErrOrStderr()
if stderr == nil {
stderr = cmd.OutOrStdout()
}
return ui.NewSpinner(w.Mode(), label, stderr)
}

// warnSystemNodeIfNeeded is the upgrade-command hook for the
// system-node classifier. It calls ResolveSystemNode with the
// resolved manager, then prints the warning to w (typically
Expand Down
Loading
Loading