nvm Keeps Eating My Global Packages: Building node-snapshot to Get Them Back

If you use nvm and you install your CLI tools globally, you have probably lived this moment: you bump Node to the latest patch, open a fresh terminal, type tsc (or eslint, or yarn, or whatever you live inside), and the shell just… shrugs.
$ tsc --version
zsh: command not found: tsc
It was there yesterday. You didn't uninstall anything. But nvm doesn't carry your globally installed npm packages across versions — each Node install gets its own lib/node_modules, and a new patch starts bare. The cruel part is that nvm use lts/jod always resolves to the newest patch of that line, which is exactly the one with none of your tools in it.
So you reinstall them. Again. And a month later, another patch lands, and you do it again. I got tired of playing whack-a-mole with my own toolchain, so I built a small CLI to make it stop: node-snapshot, distributed through my Homebrew tap. Here's the problem in full, the two traps I hit while solving it, and how the tool closes the loop.
The problem: nvm silently drops your globals
There's nothing broken about nvm here — this is just how per-version installs work. But the day-to-day effect is that your environment quietly erodes:
- Install
v22.22.0afterv22.21.1, and none ofv22.21.1's globals come along. - Over a few patches, every version accumulates a different subset of your tools.
- The newest patch — the one you actually run — tends to be the most bare.
The failure is silent. Nothing errors at update time. You only find out later, when the tool you reached for isn't there.
I wanted three things: a durable record of "these are the globals I expect for Node 22," a way to carry them forward on upgrade, and a way to recover the ones I'd already lost. That turned out to be three separate problems.
Round 1: just snapshot the global set (and the trap that hides here)
The obvious idea: dump the current global package list into a lock file per LTS line, commit it to git, and restore from it later.
node-snapshot snapshot jod # writes lts-jod.lock.json
This works right up until it quietly destroys the thing it's supposed to protect.
I know because I did it to myself, within days of shipping the tool in late June. node-snapshot shares a tap with its older sibling, brew-snapshot, whose one routine verb is — you guessed it — snapshot. So I ran the new tool on the old muscle memory: node-snapshot snapshot as my maintenance command, every time, when the command that actually carries packages across a version bump is upgrade. Then one day lts/jod came up missing @react-native-community/cli, eslint, rn-typed-assets, typescript, and yarn. Five tools, gone — and because I'd dutifully snapshotted the bare patch, my own lock file was now cheerfully certifying that this was how things had always been.
Remember that nvm use lts/jod resolves to the newest installed patch. Right after you install a fresh patch — and before its globals are migrated — that newest patch's live global set is empty by construction. If snapshot naively reads the live set at that exact moment, it writes {} over your populated lts-jod.lock.json, and the next git commit bakes the loss in permanently. A restore weeks later hands you an empty environment and no clue why.
An empty observation, in other words, is not automatically a valid new state. The snapshot tool has to know the difference between "I genuinely have no globals" and "I'm reading a transient empty window." That incident is why snapshot now refuses the wipe:
$ node-snapshot snapshot jod
✗ lts/jod: refusing to overwrite 5 tracked package(s) with an empty snapshot.
Restore them: node-snapshot migrate jod jod
Record empty anyway: node-snapshot snapshot --force jod
The guard has three tiers, keyed to the delta between the live set and the lock:
- Full wipe (live set empty, lock populated) → refuse, and print both what would be lost and how to recover.
- Partial drop (live set smaller but non-empty) → record, but warn, because a shrink is usually a real change you made — you just get to see it before you commit.
- Full or expanded set → record silently. Business as usual.
And when the empty set is genuinely what you want? --force is the escape hatch. It's deliberately not a global "skip all safety checks" flag — it only overrides the empty-wipe branch. The default refuses, the flagged path succeeds, and no code path ever silently throws your data away.
Round 2: carry packages forward on upgrade
The wipe-guard keeps a bad snapshot from recording the loss — but the cleaner fix is to not lose the packages in the first place. nvm already knows how to do this; you just have to ask. So node-snapshot upgrade bumps the LTS line and hands nvm the old version to copy from:
nvm install lts/jod --latest-npm --reinstall-packages-from=<old version>
The new patch inherits the old globals instead of starting bare. Upgrade through node-snapshot, and the "command not found" moment never happens — the migration is baked into the bump.
node-snapshot upgrade # all tracked LTS lines, migrate packages forward
node-snapshot upgrade jod # just this one
node-snapshot upgrade --check # tell me what's available, don't install
Round 3: recover the globals you already lost
Prevention only helps from today forward. If you're like me, by the time you build the tool you already have four patches of Node 22 on disk, each hoarding a different handful of packages. You want the union of all of them on the latest patch.
The naive way to gather that is to nvm use each version in turn and run npm ls -g. That's slow, and it thrashes your active Node — every activation changes the version under your running shell, your PATH, your long-lived processes. So consolidate reads straight from disk instead, never activating anything:
node-snapshot consolidate jod
→ lts/jod: consolidating v22.x.x → v22.22.2
scanning: v22.21.1 v22.22.0 v22.22.1 v22.22.2
6 unique package(s) found
✓ @openai/codex@0.80.0
npm install -g vercel@50.1.6
...
installed: 1, already present: 5
It scans each patch's ~/.nvm/versions/node/v22.*/lib/node_modules directory directly (handling the @scope/pkg layout correctly), takes the union across every installed patch of the major, installs whatever's missing into the current latest, and then refreshes the lock via snapshot. Each patch directory is a single filesystem read — no per-version activation cost, and your active Node never moves.
Those three behaviors close the loop: upgrade prevents the loss forward in time, the wipe-guard prevents the lock from silently recording a loss when one slips through, and consolidate recovers globals that already drifted apart. Prevention, protection, recovery.
The bonus: your shell picks the right Node on its own
While I was in there, I added the thing I actually wanted every day. node-snapshot init emits a zsh chpwd hook — one line in your .zshrc:
source <(node-snapshot init)
cd into a directory with a .nvmrc or .node-version, and it switches Node to match, then prints the active Node, npm, and package-manager versions so you can see the toolchain you just landed on. Once per session it also backgrounds an upgrade --check so update info is ready without slowing your shell startup.
Because the integration is emitted by the CLI rather than copy-pasted into your dotfiles, upgrading the formula updates the hook — you never re-edit .zshrc. The flip side is an honest, stated boundary: this is zsh-only. Hook registration uses add-zsh-hook, and the tool doesn't try to reimplement equivalent semantics for bash or fish. It also doesn't manage global npm packages without nvm — nvm is the whole premise.
Install it
node-snapshot ships from my tap. Homebrew adds the tap for you:
brew install AndrewDongminYoo/tap/node-snapshot
Then wire up the shell hook and create a config with the LTS lines you track (iron/jod/krypton are Node 20/22/24):
source <(node-snapshot init) # add to ~/.zshrc
mkdir -p ~/.local/share/node-snapshot
echo '{"tracked":["iron","jod","krypton"],"check_interval_days":7,"last_check_utc":""}' \
> ~/.local/share/node-snapshot/config.json
It depends on jq (Homebrew pulls it in) to read and mutate that config, and on nvm for the actual version work. The whole thing is shell scripts with a unit-test suite behind it, MIT-licensed, on GitHub: AndrewDongminYoo/homebrew-tap. (Versions and flags here are from node-snapshot 0.5.1, July 2026.)
Lessons Learned
1. An empty observation is not a valid new state
This is the whole ballgame. Any snapshot-and-restore tool whose durable artifact reflects a live reading has to treat a transient empty as suspect — a mid-migration window, a filesystem race, a permissions blip can all hand you nothing. Require an explicit signal (--force) before you let nothing overwrite something.
2. Prevention and recovery are different jobs — build both
--reinstall-packages-from stops the loss going forward; it does nothing for the four stale patches already on your disk. Recovery (consolidate) is a separate mechanism. A guard that only prevents future loss leaves your existing mess untouched, and a recovery tool with no prevention means you're always cleaning up. You want the pair.
3. Read from disk before you reach for the heavyweight API
Iterating nvm use per version was the first design that came to mind, and it was the wrong one — it mutates global shell state to gather read-only information. A directory listing gave me the same data with no side effects and linear cost. When the expensive operation is really just a fancy way to read a folder, read the folder.
4. Make the boundaries loud
zsh-only, nvm-required: I put both in the README's "what this does not do" section on purpose. A tool that's honest about its edges is easier to trust than one that pretends to cover everything and quietly fails at the corners.
If nvm has ever eaten a global you needed, give node-snapshot a try — and if you hit a case the wipe-guard mis-handles, or you want it to grow a bash hook, open an issue. I'd genuinely like to hear how your toolchain drifts, because mine clearly does.