Skip to content

fix(packages): stop the snapshot leak — auto-reclaim + nodeup packages cleanup - #130

Merged
dipto0321 merged 4 commits into
mainfrom
fix/packages/snapshot-cleanup
Nov 1, 2026
Merged

dipto0321 merged 4 commits into
mainfrom
fix/packages/snapshot-cleanup

Conversation

@dipto0321

Copy link
Copy Markdown
Owner

Summary

Snapshot JSON files leaked forever. nodeup upgrade wrote one snapshot per
installed Node version, and nothing in the codebase ever deleted one — the set
only grew, one file per (manager, node version) pair the user had ever had
installed, plus the orphan left behind when an upgrade failed partway.

This PR fixes the leak at the source and gives the user explicit control
over whatever is left on disk.

  • Auto-reclaim. The upgrade now records every snapshot path it writes and
    deletes them in a deferred cleanup — but only once they have been
    consumed. A snapshot whose contents were never replayed survives, because on
    the failure path it is the only record of the user's globals and the
    "interrupted upgrade" warning points straight at it.
  • nodeup packages cleanup. A new subcommand that lists the snapshot and
    migration-report files on disk and lets the user delete them one by one
    (multi-select) or all at once. --list shows what would go without deleting
    anything, --reports targets the report files instead, --yes skips the
    confirmation.
  • --keep-snapshots / packages.keep_snapshots /
    NODEUP_PACKAGES_KEEP_SNAPSHOTS.
    An opt-out from the automatic deletion,
    for users who want the snapshots kept for their own rollback tooling.

Closes #128
Closes #129

Adjacent bugs found during the audit

Three more leaks/bugs surfaced while tracing the snapshot lifecycle. All are
fixed here:

  1. Phantom "interrupted upgrade" sentinel. The sentinel
    (<DataDir>/upgrade-in-progress.json) was armed even when there was
    nothing to install. restoreSucceeded then never became true, so the
    sentinel was never cleared — and every later nodeup invocation warned
    about an interrupted upgrade that never happened. The sentinel is now
    armed only when there is actually something to install. (The live machine
    had a stale sentinel from 2026-10-22 warning on every run.)

  2. getCurrentVersion captured the wrong version. It returned
    versions[0] — the lowest installed version, because ListInstalled
    sorts ascending. On a machine with Node 18 and Node 20, nodeup packages snapshot captured the (usually empty) 18 install and filed it under the
    20.x filename, producing a snapshot useless as a restore source. It now
    prefers the manager's active version (Manager.Current) and falls back
    to the highest installed version for managers that can't report one
    (nvm-windows returns "not implemented").

  3. Non-atomic snapshot writes. A crash mid-write left a truncated file
    that ListSnapshots silently skipped — an invisible orphan that could
    never be cleaned up. Writes now go through a .tmp file plus a rename.

Design notes

  • The cleanup listing is file-based, not snapshot-based. It walks
    <DataDir>/snapshots directly instead of going through ListSnapshots,
    so corrupt files and leftover .json.tmp siblings are visible and
    deletable too — those are exactly the orphans most likely to be missed.
  • Deletion is confined to the target directory. DeleteSnapshotFile
    resolves both the directory and the path through filepath.Abs +
    filepath.EvalSymlinks, then uses a filepath.Rel-based containment
    check. A strings.HasPrefix check would wrongly treat /tmp/ab as inside
    /tmp/a; the same guard is used for the tar-extraction path (issue bug(npm-wrapper): no zip-slip guard, Windows/ARM64 passes preinstall but 404s at download, partial state on failure #65).
    Symlinks and non-regular files are refused.
  • Multi-select lives in internal/ui. The ui.Prompt interface gained
    MultiSelect, with a plain-mode implementation (comma/space-separated
    numbers or labels, plus an all keyword) and a huh-backed fancy-mode
    one. internal/cli never imports huh directly, and a user abort in fancy
    mode degrades to "nothing selected" rather than an error.

Test plan

  • go build ./...
  • go vet ./...
  • gofmt -l .
  • go test ./...
  • New unit tests:
    • internal/packages/cleanup_test.go — listing (valid, corrupt, .tmp,
      non-JSON), deletion (removes, missing-is-success, refuses paths
      outside the dir, refuses traversal, refuses symlinks), isInsideDir,
      filename parsing, report-file listing/deletion, atomic write.
    • internal/cli/cleanup_snapshots_test.go — --list deletes nothing,
      --all --yes deletes everything, interactive selection deletes only
      the picked files, blank selection and declined confirmation are
      no-ops, --reports targets the other directory, empty-dir case,
      formatBytes.
    • internal/cli/upgrade_snapshots_test.go — reclaim removes the files it
      is handed, one undeletable file doesn't stop the rest, the
      --keep-snapshots flag exists and defaults to false, packages cleanup is wired into the command tree, a snapshot whose contents
      were not replayed survives, and the snapshots and reports directories
      are genuinely distinct.
    • internal/cli/packages_test.go — getCurrentVersion prefers the active
      version, falls back to the highest installed, errors when there are
      none.
    • internal/ui/prompt_test.go — plain-mode multi-select: by number, by
      label, all, blank, EOF, presentation ordering, no options, rendering.
    • internal/config/*_test.go — packages.keep_snapshots through
      SetByKey, defaults, full-file override, partial-file preservation.
  • Manual run against the live data dir: --list showed all three file
    kinds; multi-selecting by number deleted exactly the picked file;
    all deleted the rest.

dipto0321 and others added 4 commits November 1, 2026 21:19
GoReleaser applies its own default ldflags only when builds[].ldflags
is empty. Passing our ldflags through builds[].flags instead produced
two -ldflags arguments on the go build command line, and Go takes the
last one — so ours was silently dropped and the shipped binary reported
GoReleaser's defaults (a full 40-char commit and the build date)
instead of the short commit and commit date we wanted.

Move the ldflags into the dedicated builds[].ldflags key, which keeps
a single explicit -ldflags argument and preserves the metadata.

Also clear the three deprecation warnings:

- archives[].format -> archives[].formats (list form, v2.6)
- archives[].format_overrides[].format -> .formats
- brews: -> homebrew_casks: (v2.10)

The archive names are unchanged, so the npm wrapper's archiveName()
and the Homebrew/Scoop manifests still match.

Closes #129
…nup`

Nothing in nodeup ever deleted a snapshot file. The set only grew: one
file per (manager, Node version) pair the user had ever installed, plus
the orphan left behind by an upgrade that failed partway. The migration
reports in <DataDir>/reports/ leaked the same way, with unbounded
random-suffixed filenames.

Two fixes.

1. Stop the leak automatically. The upgrade flow now records the paths
   it wrote and deletes them once the snapshots have been consumed —
   either because the migration replayed them onto the new Node
   version, or because there was nothing to migrate at all (every
   target was already installed, so the snapshots were redundant
   copies of the current state).

   A snapshot whose contents were NOT replayed survives: on the
   failure path it is the only record of the user's globals and the
   sentinel points straight at it. `--keep-snapshots` (or
   `packages.keep_snapshots` in the config) opts out of the automatic
   deletion entirely.

2. Give the user a manual lever: `nodeup packages cleanup`. It lists
   what's on disk with size and mtime, and lets the user pick
   individual files, several files, or all of them. `--list` shows
   what would be deleted and deletes nothing; `--reports` targets the
   migration reports instead; `--all --yes` deletes everything without
   prompting.

   The listing is deliberately file-based rather than built on
   ListSnapshots: that function skips files whose JSON body doesn't
   parse, so a snapshot truncated by a crash mid-write is invisible to
   it — and a cleanup command that can't see a file can't delete it.
   The new listing surfaces corrupt files, plus the ".json.tmp"
   siblings an interrupted atomic write leaves behind.

Deletion is confined to its own directory. The target must resolve
*inside* the snapshots (or reports) directory after symlink evaluation
and must be a regular file, so a ".." path or a symlink pointing
elsewhere is refused rather than followed.

Three adjacent bugs fixed along the way:

- The sentinel was armed even when there was nothing to install. With
  no install step and no restore step, restoreSucceeded never became
  true, so the sentinel was never cleared and every later `nodeup`
  invocation warned about a phantom "interrupted upgrade".
- `nodeup packages snapshot` captured the LOWEST installed version's
  globals, not the active one — getCurrentVersion returned
  versions[0], and ListInstalled sorts ascending. On a machine with
  Node 18 and Node 20 that captured the (usually empty) 18 install
  under the 20.x filename, producing a snapshot useless as a restore
  source. It now asks Manager.Current() first and falls back to the
  highest installed version.
- Snapshot writes were not atomic. A plain os.WriteFile truncates the
  destination first, so a crash mid-write left a truncated JSON file
  that no reader could parse — invisible to every code path, but still
  occupying disk. Writes now go through a sibling temp file and a
  rename.

Also adds ui.Prompt.MultiSelect: huh's MultiSelect in FancyMode, and a
line-based comma/space-separated number-or-name picker in PlainMode.
Both degrade to "nothing selected" on EOF or a blank answer — the safe
direction for a destructive prompt.

Closes #128
README gains a "Reclaiming snapshot space" section covering
`nodeup packages cleanup` and the `--keep-snapshots` opt-out, and the
config example gains packages.keep_snapshots.

SPEC records the new interface surface (`nodeup packages cleanup`, the
--keep-snapshots flag, NODEUP_PACKAGES_KEEP_SNAPSHOTS) and four new
invariants:

- V22: an upgrade's snapshots are deleted iff the migration replayed
  them successfully (or the user opted out). A snapshot whose restore
  failed is never deleted — it is the rollback payload.
- V23: snapshot writes are atomic.
- V24: file deletion is confined to its own directory, after symlink
  evaluation.

CLAUDE.md's file map gains cleanup.go and cleanup_snapshots.go.

Part of #128.
Two lint failures on the new cleanup command:

- `delete` shadowed the predeclared builtin (gocritic builtinShadow).
  Renamed to `removeFile`.
- "cancelled" is a misspelling of the US spelling "canceled"
  (misspell linter).

Co-Authored-By: puku-ai-2.8 <noreply@puku.sh>
@cocogitto-bot

cocogitto-bot Bot commented Nov 1, 2026

Copy link
Copy Markdown

✔️ 75e873c...091fb18 - Conventional commits check succeeded.

@dipto0321
dipto0321 merged commit 2a6a8ee into main Nov 1, 2026
11 checks passed
@dipto0321
dipto0321 deleted the fix/packages/snapshot-cleanup branch November 1, 2026 15:37
dipto0321 added a commit that referenced this pull request Nov 2, 2026
…ever (#142)

The "Detected an interrupted upgrade" warning could fire on every
nodeup invocation, pointing at a file that no longer existed and
telling the user to run a command that was guaranteed to fail.

The marker (<DataDir>/upgrade-in-progress.json) is deliberately left
behind when a migration fails, so the user can replay it — that is the
#46 contract. But the snapshot it points at does not necessarily
survive: v1.2.2's auto-reclaim (#130) deletes it once the migration
has replayed the packages, and `nodeup packages cleanup` lets the user
delete any snapshot by hand. When that happened the marker outlived
its payload, and the marker lives outside the snapshots directory, so
`nodeup packages cleanup` never touched it and the user had no way to
dismiss it.

The warning now validates its own advice:

  - snapshot still present → genuine interrupted upgrade. Warning and
    resume hint unchanged; the marker is NOT cleared.
  - snapshot gone → the marker is stale. nodeup says so and clears it
    instead of printing a hint that cannot work.

Clearing is race-safe. The marker is CLAIMED with an atomic rename
before it is inspected, so a concurrent `nodeup upgrade` that arms a
fresh marker in the window can never have it deleted out from under
it — the fresh marker is put back untouched. Compare-then-delete would
have a TOCTOU window there; the claim closes it.

`nodeup packages cleanup --stale-sentinel` clears a stale marker on
demand. It refuses to clear one whose snapshot still exists: that
represents a genuine interrupted migration, and discarding it would
lose the only breadcrumb telling the user how to replay their globals.

Also fixes a test-hygiene wart this change exposed. A CLI test that
built a root command ran against the developer's real $HOME, so it
probed the real marker — and could now delete it. Running the suite
silently wiped the marker on this machine. The test now redirects the
data dir, and a new AST-based guard test fails any future test that
constructs a root command without doing so.

Closes #141

Co-authored-by: dipto0321 <dipto@local>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant