diff --git a/internal/cli/packages.go b/internal/cli/packages.go index b003dd7..ade2d48 100644 --- a/internal/cli/packages.go +++ b/internal/cli/packages.go @@ -100,15 +100,60 @@ func runPackagesList(cmd *cobra.Command, args []string) error { } func newRestoreCmd() *cobra.Command { - return &cobra.Command{ - Use: "restore ", + cmd := &cobra.Command{ + // Two ways to invoke restore: + // + // 1. `nodeup packages restore ` — look up + // /snapshots/-.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 ` — 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 [ ] [--from ]", Short: "Re-install packages from a snapshot", - Args: cobra.ExactArgs(2), - RunE: runRestore, + Long: `Re-install global npm packages from a snapshot. + +Either pass to look up /snapshots/-.json, +or pass --from 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 and ") + } + 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 , look up the + // conventional /snapshots/-.json, and + // re-install its packages onto the currently active Node. managerName := args[0] versionStr := args[1] @@ -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) } diff --git a/internal/cli/root.go b/internal/cli/root.go index a60c285..8a35771 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -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) @@ -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 diff --git a/internal/cli/upgrade.go b/internal/cli/upgrade.go index 72597cb..3c47e2d 100644 --- a/internal/cli/upgrade.go +++ b/internal/cli/upgrade.go @@ -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) @@ -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) diff --git a/internal/packages/sentinel.go b/internal/packages/sentinel.go new file mode 100644 index 0000000..ff65809 --- /dev/null +++ b/internal/packages/sentinel.go @@ -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: +// +// /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 -.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 +} diff --git a/internal/packages/sentinel_test.go b/internal/packages/sentinel_test.go new file mode 100644 index 0000000..8d13b04 --- /dev/null +++ b/internal/packages/sentinel_test.go @@ -0,0 +1,349 @@ +package packages + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +// redirectDataDir steers internal/platform's DataDir() to a per-test +// tempdir. We do this by setting HOME (unix) or USERPROFILE (windows), +// which os.UserHomeDir() honors. APPDATA / XDG_DATA_HOME must also be +// cleared so they don't override HOME on darwin/linux. +// +// Why this matters: the sentinel helpers go through DataDir, which goes +// through os.UserHomeDir. If a developer runs `go test` with an existing +// sentinel in their real $HOME, the test would see it. t.Setenv + a +// fresh tempdir per test keeps state hermetic. +func redirectDataDir(t *testing.T) string { + t.Helper() + tmp := t.TempDir() + + // Platform-specific env vars that platform.DataDir checks FIRST + // before falling back to HOME/USERPROFILE. Clearing them forces + // the HOME/USERPROFILE path to win. + t.Setenv("XDG_DATA_HOME", "") + t.Setenv("APPDATA", "") + + if runtime.GOOS == "windows" { + t.Setenv("USERPROFILE", tmp) + // HOME is read on Windows too, but only as a last resort; + // setting it makes the test more portable across shells. + t.Setenv("HOME", tmp) + } else { + t.Setenv("HOME", tmp) + } + return tmp +} + +// TestOrphanedSentinel_NoneWhenAbsent verifies the "no upgrade was in +// progress" path: with no sentinel file, OrphanedSentinel must return +// (nil, nil) — not an error — so the CLI can silently skip the warning. +func TestOrphanedSentinel_NoneWhenAbsent(t *testing.T) { + redirectDataDir(t) + + s, err := OrphanedSentinel() + if err != nil { + t.Fatalf("OrphanedSentinel: unexpected error %v", err) + } + if s != nil { + t.Errorf("OrphanedSentinel = %+v, want nil", s) + } +} + +// TestLoadSentinel_ErrNoSentinelWhenAbsent checks the lower-level +// LoadSentinel returns ErrNoSentinel (wrapped) when the file is +// missing — this is what powers the "missing file is not an error" +// semantics above. +func TestLoadSentinel_ErrNoSentinelWhenAbsent(t *testing.T) { + redirectDataDir(t) + + _, err := LoadSentinel() + if err == nil { + t.Fatal("LoadSentinel: expected error for missing file, got nil") + } + if !errors.Is(err, ErrNoSentinel) { + t.Errorf("LoadSentinel: error %v does not wrap ErrNoSentinel", err) + } +} + +// TestWriteThenOrphan is the issue's first required test: "Write a +// sentinel in a tempdir, invoke the detector, verify the warning +// message contains the snapshot path." +// +// The "warning message" lives in internal/cli/root.go and is hard to +// unit-test without spinning up cobra. Instead, we verify the data +// the warning would print: the SnapshotPath field populated by the +// CLI's sentinel-writing code must survive a Write → Orphan cycle. +func TestWriteThenOrphan(t *testing.T) { + redirectDataDir(t) + + now := time.Now().UTC() + snapPath := "/var/lib/nodeup/snapshots/fnm-20.10.0.json" + written := UpgradeSentinel{ + StartedAt: now, + Manager: "fnm", + OldVersion: "20.9.0", + NewVersion: "20.10.0", + SnapshotPath: snapPath, + } + if err := WriteSentinel(written); err != nil { + t.Fatalf("WriteSentinel: %v", err) + } + + got, err := OrphanedSentinel() + if err != nil { + t.Fatalf("OrphanedSentinel after write: %v", err) + } + if got == nil { + t.Fatal("OrphanedSentinel returned nil after a sentinel was written") + } + + // Every field of the round-trip must match — this is what the + // CLI's warning message would print, so a regression here means + // the user would see a garbled warning on next run. + if !got.StartedAt.Equal(now) { + t.Errorf("StartedAt: got %v, want %v", got.StartedAt, now) + } + if got.Manager != "fnm" { + t.Errorf("Manager: got %q, want fnm", got.Manager) + } + if got.OldVersion != "20.9.0" { + t.Errorf("OldVersion: got %q, want 20.9.0", got.OldVersion) + } + if got.NewVersion != "20.10.0" { + t.Errorf("NewVersion: got %q, want 20.10.0", got.NewVersion) + } + if got.SnapshotPath != snapPath { + t.Errorf("SnapshotPath: got %q, want %q", got.SnapshotPath, snapPath) + } +} + +// TestRemoveSentinel_Idempotent verifies RemoveSentinel: +// - removes an existing sentinel +// - does not error when the sentinel is already gone (so the deferred +// cleanup in runUpgrade never panics on the happy path) +func TestRemoveSentinel_Idempotent(t *testing.T) { + redirectDataDir(t) + + // First call: file does not exist. Must succeed silently. + if err := RemoveSentinel(); err != nil { + t.Errorf("RemoveSentinel on missing file: %v", err) + } + + // Write + remove cycle must leave no trace. + if err := WriteSentinel(UpgradeSentinel{Manager: "fnm"}); err != nil { + t.Fatalf("WriteSentinel: %v", err) + } + if err := RemoveSentinel(); err != nil { + t.Errorf("RemoveSentinel after write: %v", err) + } + + // And a second remove after the first one must still succeed. + if err := RemoveSentinel(); err != nil { + t.Errorf("RemoveSentinel on already-removed file: %v", err) + } + + // And OrphanedSentinel must now report nothing. + s, err := OrphanedSentinel() + if err != nil { + t.Fatalf("OrphanedSentinel after cleanup: %v", err) + } + if s != nil { + t.Errorf("OrphanedSentinel = %+v, want nil after RemoveSentinel", s) + } +} + +// TestRemoveSentinel_CleansUpTmpFile is a regression test for the +// "rename failed → tmp file leaked" branch in WriteSentinel. We can't +// easily force a rename failure, so instead we verify the happy path +// never leaves a .tmp file behind — if it ever does, that's a bug. +func TestRemoveSentinel_CleansUpTmpFile(t *testing.T) { + redirectDataDir(t) + + if err := WriteSentinel(UpgradeSentinel{Manager: "nvm"}); err != nil { + t.Fatalf("WriteSentinel: %v", err) + } + + dir, err := dataDirForTest() + if err != nil { + t.Fatalf("dataDirForTest: %v", err) + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("ReadDir(%s): %v", dir, err) + } + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".tmp") { + t.Errorf("leftover tmp file in DataDir: %s", e.Name()) + } + } +} + +// TestWriteSentinel_DefaultsStartedAt documents that WriteSentinel +// fills in StartedAt if the caller leaves it zero, so the sentinel +// always has a timestamp. +func TestWriteSentinel_DefaultsStartedAt(t *testing.T) { + redirectDataDir(t) + + before := time.Now() + if err := WriteSentinel(UpgradeSentinel{Manager: "fnm"}); err != nil { + t.Fatalf("WriteSentinel: %v", err) + } + after := time.Now() + + s, err := LoadSentinel() + if err != nil { + t.Fatalf("LoadSentinel: %v", err) + } + if s.StartedAt.Before(before) || s.StartedAt.After(after.Add(time.Second)) { + t.Errorf("StartedAt = %v, want between %v and %v", s.StartedAt, before, after) + } +} + +// TestWriteSentinel_OverwritesExisting exercises the "atomic rename +// replaces the destination" property. The second WriteSentinel call +// must fully replace the first; partial-merge bugs would be obvious. +func TestWriteSentinel_OverwritesExisting(t *testing.T) { + redirectDataDir(t) + + first := UpgradeSentinel{Manager: "fnm", OldVersion: "20.9.0", SnapshotPath: "/a.json"} + if err := WriteSentinel(first); err != nil { + t.Fatalf("WriteSentinel #1: %v", err) + } + second := UpgradeSentinel{Manager: "nvm", OldVersion: "18.0.0", SnapshotPath: "/b.json"} + if err := WriteSentinel(second); err != nil { + t.Fatalf("WriteSentinel #2: %v", err) + } + + got, err := OrphanedSentinel() + if err != nil { + t.Fatalf("OrphanedSentinel: %v", err) + } + if got.Manager != "nvm" || got.OldVersion != "18.0.0" || got.SnapshotPath != "/b.json" { + t.Errorf("after overwrite, got %+v, want fields from second sentinel", got) + } +} + +// TestLoadSentinel_ParseError is the "corrupted sentinel" branch: a +// file exists but doesn't parse as our schema. OrphanedSentinel must +// surface the error so the CLI can at least log it (we deliberately +// don't want to silently swallow schema corruption — that's a real +// signal something went wrong). +func TestLoadSentinel_ParseError(t *testing.T) { + redirectDataDir(t) + + p, err := SentinelPath() + if err != nil { + t.Fatalf("SentinelPath: %v", err) + } + if err := os.WriteFile(p, []byte("{this is not json"), 0o644); err != nil { + t.Fatalf("seed corrupted sentinel: %v", err) + } + + _, lerr := LoadSentinel() + if lerr == nil { + t.Fatal("LoadSentinel on corrupted file: expected error, got nil") + } + if errors.Is(lerr, ErrNoSentinel) { + t.Errorf("corrupted sentinel incorrectly mapped to ErrNoSentinel: %v", lerr) + } + + // OrphanedSentinel surfaces the parse error rather than + // swallowing it — this is the design choice that distinguishes + // "missing file" (silent) from "corrupted file" (loud). + _, oerr := OrphanedSentinel() + if oerr == nil { + t.Fatal("OrphanedSentinel on corrupted file: expected error, got nil") + } +} + +// dataDirForTest is a small helper that returns the redirected DataDir +// so cleanup / verification tests can introspect the filesystem. +func dataDirForTest() (string, error) { + home := os.Getenv("HOME") + if home == "" { + home = os.Getenv("USERPROFILE") + } + if home == "" { + return "", errors.New("no HOME/USERPROFILE set; redirectDataDir was not called") + } + // DataDir() resolves the same way regardless of OS; we duplicate + // the layout here to avoid an import cycle (platform imports us). + if runtime.GOOS == "darwin" { + return filepath.Join(home, "Library", "Application Support", "nodeup"), nil + } + if runtime.GOOS == "windows" { + return filepath.Join(home, "AppData", "Roaming", "nodeup"), nil + } + return filepath.Join(home, ".local", "share", "nodeup"), nil +} + +// TestRestoreFromSnapshot_ValidFile covers the "explicit replay" +// happy path: a snapshot file exists at an arbitrary path, and +// RestoreFromSnapshot reads + parses it. We don't run the actual +// `npm install` (that would require a working npm); we check that +// the function makes it past the read+parse stage before invoking +// installPackages. We do this by pointing the file at a syntactically +// valid snapshot but with zero packages — the install loop is a no-op. +func TestRestoreFromSnapshot_ValidFile(t *testing.T) { + tmp := t.TempDir() + snap := SnapshotData{ + Manager: "fnm", + NodeVersion: "20.10.0", + Packages: []Package{}, + } + data, err := json.MarshalIndent(snap, "", " ") + if err != nil { + t.Fatalf("marshal: %v", err) + } + snapPath := filepath.Join(tmp, "manual-snapshot.json") + if err := os.WriteFile(snapPath, data, 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + // With an empty Packages list, RestoreFromSnapshot runs the + // install loop zero times and returns nil — no need for a fake + // `npm install` binary on PATH. + if err := RestoreFromSnapshot(t.Context(), snapPath); err != nil { + t.Errorf("RestoreFromSnapshot: %v", err) + } +} + +// TestRestoreFromSnapshot_MissingFile verifies the user-facing error +// includes both the path they typed AND a "read snapshot" hint so they +// don't have to read source to figure out what went wrong. +func TestRestoreFromSnapshot_MissingFile(t *testing.T) { + err := RestoreFromSnapshot(t.Context(), "/nonexistent/snapshot.json") + if err == nil { + t.Fatal("expected error for missing snapshot file") + } + if !strings.Contains(err.Error(), "/nonexistent/snapshot.json") { + t.Errorf("error %q does not include the path the user gave", err) + } +} + +// TestRestoreFromSnapshot_CorruptFile covers the schema-corruption +// branch: a file exists but its JSON doesn't match SnapshotData. +// RestoreFromSnapshot must report a parse error rather than silently +// installing an empty package list (which would silently lose the +// user's packages). +func TestRestoreFromSnapshot_CorruptFile(t *testing.T) { + tmp := t.TempDir() + snapPath := filepath.Join(tmp, "bad.json") + if err := os.WriteFile(snapPath, []byte("{not valid json"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + err := RestoreFromSnapshot(t.Context(), snapPath) + if err == nil { + t.Fatal("expected error for corrupt JSON") + } + if !strings.Contains(err.Error(), "parse snapshot") { + t.Errorf("error %q does not mention parse failure", err) + } +} diff --git a/internal/packages/snapshot.go b/internal/packages/snapshot.go index 6973ede..ec8ca6d 100644 --- a/internal/packages/snapshot.go +++ b/internal/packages/snapshot.go @@ -86,6 +86,17 @@ func shouldSkipPackage(name string) bool { return skipPackages[name] || strings.HasPrefix(name, "@npm:") } +// SnapshotPath returns the conventional on-disk path of a snapshot +// file given the manager name and Node version, e.g. +// "/snapshots/fnm-20.10.0.json". It does not check that the +// file exists — it only computes the path. Exported so the upgrade +// command can record the snapshot path inside the upgrade-in-progress +// sentinel; the restore CLI likewise uses it to look up a snapshot by +// name. +func SnapshotPath(managerName, version string) (string, error) { + return snapshotPath(managerName, version) +} + func snapshotPath(managerName, version string) (string, error) { dir, err := platform.SnapshotsDir() if err != nil { @@ -129,6 +140,34 @@ func Restore(ctx context.Context, managerName string, version semver.Version) er return installPackages(ctx, s.Packages) } +// RestoreFromSnapshot reinstalls the packages contained in an arbitrary +// snapshot file on disk. Unlike Restore, it does not look the snapshot up +// by name in /snapshots/ — it reads exactly the path given. +// +// This is the explicit-replay entrypoint used by `nodeup packages restore +// --from ` after an interrupted upgrade has been detected via the +// sentinel file. The path can be: +// +// - the snapshot the upgrade wrote (its absolute path is recorded in +// the sentinel under snapshot_path), or +// - any user-provided snapshot file the user wants to replay. +// +// We deliberately use os.ReadFile directly instead of LoadSnapshot so +// the path does not have to live under /snapshots. +func RestoreFromSnapshot(ctx context.Context, path string) error { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read snapshot %s: %w", path, err) + } + + var s SnapshotData + if err := json.Unmarshal(data, &s); err != nil { + return fmt.Errorf("parse snapshot %s: %w", path, err) + } + + return installPackages(ctx, s.Packages) +} + func installPackages(ctx context.Context, pkgs []Package) error { for _, pkg := range pkgs { _, err := platform.RunShell(ctx, "npm", "install", "-g", pkgSpec(pkg))