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
55 changes: 50 additions & 5 deletions internal/cli/packages.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,15 +100,60 @@ func runPackagesList(cmd *cobra.Command, args []string) error {
}

func newRestoreCmd() *cobra.Command {
return &cobra.Command{
Use: "restore <manager> <version>",
cmd := &cobra.Command{
// Two ways to invoke restore:
//
// 1. `nodeup packages restore <manager> <version>` — look up
// <DataDir>/snapshots/<manager>-<version>.json by name. This
// is the path users hit when they deliberately migrate by
// saying "give me the packages from fnm 20.10.0".
//
// 2. `nodeup packages restore --from <path>` — restore from an
// explicit snapshot file. This is the "interrupted-upgrade
// replay" path: when `nodeup` detects an orphaned sentinel it
// prints the snapshot path verbatim in the warning, so the
// user can copy-paste it back into `restore --from`.
Use: "restore [<manager> <version>] [--from <path>]",
Short: "Re-install packages from a snapshot",
Args: cobra.ExactArgs(2),
RunE: runRestore,
Long: `Re-install global npm packages from a snapshot.

Either pass <manager> <version> to look up <DataDir>/snapshots/<manager>-<version>.json,
or pass --from <path> to restore from an arbitrary snapshot file (the path printed by
the "interrupted upgrade" warning).`,
Args: func(cmd *cobra.Command, args []string) error {
fromPath, _ := cmd.Flags().GetString("from")
if fromPath != "" {
// --from is mutually exclusive with positional args.
if len(args) != 0 {
return fmt.Errorf("--from is mutually exclusive with <manager> and <version>")
}
return nil
}
return cobra.ExactArgs(2)(cmd, args)
},
RunE: runRestore,
}
cmd.Flags().String("from", "", "restore from an explicit snapshot file path")
return cmd
}

func runRestore(cmd *cobra.Command, args []string) error {
ctx := context.Background()
fromPath, _ := cmd.Flags().GetString("from")

// --from branch: read the path straight off disk, no manager or
// version parsing required.
if fromPath != "" {
if err := packages.RestoreFromSnapshot(ctx, fromPath); err != nil {
return fmt.Errorf("restore failed: %w", err)
}
cmd.Printf("Restored packages from %s\n", fromPath)
return nil
}

// Positional-arg branch: parse <manager> <version>, look up the
// conventional <DataDir>/snapshots/<manager>-<version>.json, and
// re-install its packages onto the currently active Node.
managerName := args[0]
versionStr := args[1]

Expand All @@ -117,7 +162,7 @@ func runRestore(cmd *cobra.Command, args []string) error {
return fmt.Errorf("invalid version: %w", err)
}

if err := packages.Restore(context.Background(), managerName, *v); err != nil {
if err := packages.Restore(ctx, managerName, *v); err != nil {
return fmt.Errorf("restore failed: %w", err)
}

Expand Down
34 changes: 34 additions & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,35 @@
package cli

import (
"fmt"
"os"

"github.com/spf13/cobra"

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

// warnInterruptedUpgrade checks for an orphaned upgrade sentinel and
// prints a hint to stderr if one is found. We use PersistentPreRunE so
// this fires on EVERY subcommand invocation — including `nodeup version`
// or `nodeup packages list` — without each leaf having to remember.
//
// Output goes to stderr so it does not pollute machine-readable stdout
// (e.g., `nodeup list | jq .`). Errors from OrphanedSentinel other than
// "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) {
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)
}

// NewRootCmd builds the root `nodeup` command with all subcommands attached.
//
// version, commit, and date are build-time variables (see cmd/nodeup/main.go)
Expand Down Expand Up @@ -40,6 +66,14 @@ 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: func(cmd *cobra.Command, args []string) error {
warnInterruptedUpgrade(cmd, args)
return nil
},
}

// Persistent flags shared by every subcommand. These are intentionally
Expand Down
54 changes: 54 additions & 0 deletions internal/cli/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,19 @@ func runUpgrade(cmd *cobra.Command, args []string) error {
skipMigrate := noMigrate || !cfg.Packages.Migrate
_ = skipMigrate // referenced below in snapshot/restore sections

// From here on, anything that mutates disk in a way that needs
// replay-by-sentinel bookkeeping is wrapped in a sentinel lifecycle.
// We use a flag (rather than os.Exit) so deferred cleanup runs even
// on error paths.
sentinelArmed := false
defer func() {
if sentinelArmed {
if err := packages.RemoveSentinel(); err != nil {
cmd.Printf("Warning: failed to remove upgrade sentinel: %v\n", err)
}
}
}()

// Detect managers
installed := detector.DetectAll()
m, err := detector.ResolveManager(installed, managerPref)
Expand Down Expand Up @@ -147,6 +160,47 @@ func runUpgrade(cmd *cobra.Command, args []string) error {
}
}

// Arm the sentinel AFTER snapshots are on disk but BEFORE any
// install mutation. If we crash between here and the deferred
// cleanup, the sentinel is the "this upgrade was interrupted"
// breadcrumb that the next `nodeup` invocation will pick up.
//
// We point the sentinel at the latest installed version's snapshot
// since that is the most likely one we want to replay against —
// it contains the package set the user had right before we started
// installing the new versions.
if !skipMigrate {
newVersion := ""
if len(toInstall) > 0 {
newVersion = toInstall[len(toInstall)-1].String()
}
oldVersion := ""
var snapshotPath string
if len(installedVersions) > 0 {
last := installedVersions[len(installedVersions)-1]
oldVersion = last.String()
// Resolve the snapshot path the conventional way so the
// warning message can hand it to the user verbatim. We
// tolerate a failure here — without a snapshot path the
// sentinel is still useful (it tells the user a migration
// was in flight) and we don't want to abort the upgrade
// over a path-resolution glitch.
if p, perr := packages.SnapshotPath(m.Name(), oldVersion); perr == nil {
snapshotPath = p
}
}
if err := packages.WriteSentinel(packages.UpgradeSentinel{
Manager: m.Name(),
OldVersion: oldVersion,
NewVersion: newVersion,
SnapshotPath: snapshotPath,
}); err != nil {
cmd.Printf("Warning: failed to write upgrade sentinel: %v\n", err)
} else {
sentinelArmed = true
}
}

// Install new versions
for _, v := range toInstall {
cmd.Printf("Installing %s...\n", v)
Expand Down
181 changes: 181 additions & 0 deletions internal/packages/sentinel.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
// Package sentinel implements the upgrade-in-progress sentinel file.
//
// When `nodeup upgrade` starts a migration that involves snapshotting global
// npm packages, installing new Node versions, and restoring packages onto
// the new versions, an interrupted run (Ctrl-C, power loss, OOM kill) leaves
// the user with installed Node versions but no migrated packages. The
// sentinel is the "we were in the middle of an upgrade" marker that lets
// the next `nodeup` invocation detect the interruption and prompt for replay.
//
// File layout:
//
// <DataDir>/upgrade-in-progress.json
//
// The file is small (a few hundred bytes) and only ever present while an
// upgrade is running or after one was interrupted. Successful upgrades
// always remove it via the deferred cleanup in internal/cli/upgrade.go.
package packages

import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"

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

// SentinelFile is the canonical filename for the in-progress marker.
// Exported so tests and CLI helpers can refer to it without a magic string.
const SentinelFile = "upgrade-in-progress.json"

// UpgradeSentinel is the on-disk schema for upgrade-in-progress.json.
//
// It records enough context for the next `nodeup` invocation to print a
// useful "looks like your last upgrade was interrupted, here's how to
// resume it" message and to drive `nodeup packages restore` without
// forcing the user to look anything up.
type UpgradeSentinel struct {
// StartedAt is when the upgrade began. Rendered as RFC3339 in the
// warning message so users can correlate it with their shell history.
StartedAt time.Time `json:"started_at"`

// Manager is the Node version manager that was driving the upgrade
// (fnm, nvm, volta, ...). Needed because the restore CLI needs it.
Manager string `json:"manager"`

// OldVersion is the Node version packages were snapshotted from. May
// be empty if the upgrade started on a clean install.
OldVersion string `json:"old_version,omitempty"`

// NewVersion is the version being installed (or already installed,
// if we crashed mid-install). What `packages restore` targets.
NewVersion string `json:"new_version,omitempty"`

// SnapshotPath is the absolute path of the snapshot that `packages
// restore --from` should replay against. We record it explicitly so
// the user does not have to know the <manager>-<version>.json naming
// convention — we just print the path in the warning message.
SnapshotPath string `json:"snapshot_path,omitempty"`
}

// ErrNoSentinel is returned by LoadSentinel when no sentinel file exists.
// Distinct from a parse error so callers can treat "no upgrade was in
// progress" as a normal state, not an error condition.
var ErrNoSentinel = errors.New("no upgrade-in-progress sentinel")

// SentinelPath returns the absolute path of the sentinel file. The
// parent directory is created on demand (matching the pattern of the
// other DataDir helpers in internal/platform).
func SentinelPath() (string, error) {
d, err := platform.DataDir()
if err != nil {
return "", err
}
// MkdirAll is a no-op if the directory already exists. We do not
// pre-create the file — WriteSentinel handles that.
if err := os.MkdirAll(d, 0o755); err != nil {
return "", err
}
return filepath.Join(d, SentinelFile), nil
}

// WriteSentinel atomically replaces any existing sentinel file with a
// fresh one describing the upgrade that is about to begin.
//
// We write to a tempfile in the same directory and os.Rename into place
// so a concurrent reader (e.g., a second nodeup invocation that lost the
// race to acquire the lock) never sees a half-written file. On POSIX,
// rename within a directory is atomic. On Windows, os.Rename replaces
// the destination if it exists — also fine for our use case.
func WriteSentinel(s UpgradeSentinel) error {
path, err := SentinelPath()
if err != nil {
return err
}

// Default StartedAt to now if the caller did not set it, so the
// schema is always populated with a timestamp.
if s.StartedAt.IsZero() {
s.StartedAt = time.Now()
}

data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return fmt.Errorf("marshal sentinel: %w", err)
}

tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return fmt.Errorf("write sentinel tempfile: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
// Best-effort cleanup; the tmp file in DataDir is harmless
// but we don't want to leak it across many interrupted runs.
_ = os.Remove(tmp)
return fmt.Errorf("rename sentinel into place: %w", err)
}
return nil
}

// LoadSentinel reads and parses the sentinel file. Returns ErrNoSentinel
// (wrapped) when the file does not exist — callers should treat this as
// "no interrupted upgrade" and proceed silently.
func LoadSentinel() (*UpgradeSentinel, error) {
path, err := SentinelPath()
if err != nil {
return nil, err
}

data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("%w: %s", ErrNoSentinel, path)
}
return nil, fmt.Errorf("read sentinel: %w", err)
}

var s UpgradeSentinel
if err := json.Unmarshal(data, &s); err != nil {
return nil, fmt.Errorf("parse sentinel: %w", err)
}
return &s, nil
}

// RemoveSentinel deletes the sentinel file. Safe to call when the file
// does not exist — we treat ErrNotExist as success so the deferred
// cleanup in runUpgrade never panics on the happy path.
//
// We deliberately swallow other errors and return them rather than
// panicking: a stale sentinel left on disk is harmless (the next run
// will warn and offer to remove it), whereas a panic during deferred
// cleanup would mask the original error.
func RemoveSentinel() error {
path, err := SentinelPath()
if err != nil {
return err
}
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}

// OrphanedSentinel returns the parsed sentinel if one exists, or nil if
// not. This is the "did the last run get interrupted?" check that
// PersistentPreRunE uses on every `nodeup` invocation. Errors other than
// "file missing" are returned so the caller can decide whether to log
// them (e.g., a parse error means the file is corrupted — we should at
// least surface that rather than silently swallowing it).
func OrphanedSentinel() (*UpgradeSentinel, error) {
s, err := LoadSentinel()
if err != nil {
if errors.Is(err, ErrNoSentinel) {
return nil, nil
}
return nil, err
}
return s, nil
}
Loading
Loading