The Problem: command not found After Every nvm use
Every nvm user knows the loop: install a CLI globally, it works, then a week later nvm use 18 for a legacy project and the command is gone. Not broken — gone. command not found. Because nvm doesn't have global packages; each Node version has its own lib/node_modules, and switching versions swaps the entire world out from under you.
The standard answers are all bad. nvm reinstall-packages copies everything between versions — a sledgehammer that reinstalls things into versions where they don't belong. --default-packages only helps at install time. And "just use npx" means cold-starting your tools through the network.
The Solution: nvmpin — Pin Packages to a Specific Node Version
Alex Kolovsky built nvmpin: pin each global package to a specific Node version, and it keeps working no matter which version your shell is on. Here's a real transcript from the pre-release smoke test:
$ nvm use 16
Now using node v16.20.2
$ which cowsay
/Users/alex/.nvmpin/bin/cowsay
$ cowsay ok
____
< ok >
----
cowsay is pinned to Node 22, the shell is on 16, nothing breaks.
How nvmpin Works
No daemon, no wrapper around nvm, no PATH interception magic. Three parts:
- A registry at
~/.nvmpin/pins.jsonmapping package → Node version. - A shim directory
~/.nvmpin/bin, prepended to PATH after nvm's init. - Generated shims — three-line bash scripts with absolute paths baked in:
#!/usr/bin/env bash
# nvmpin shim for cowsay -> v22.21.1 (do not edit)
exec "/Users/alex/.nvm/versions/node/v22.21.1/bin/node" \
"/Users/alex/.nvm/versions/node/v22.21.1/lib/node_modules/cowsay/cli.js" "$@"
nvmpin add cowsay --node 22 installs it into Node 22's tree and writes the shim. nvmpin move pkg --node 18 reinstalls into 18 — a real reinstall, never a re-point, because native modules are compiled per Node ABI. nvmpin scan shows which globals live in which versions. nvmpin doctor tells you when something's broken. Zero dependencies, POSIX only for now.
The Three Sabotages npm Threw at nvmpin
Sabotage #1: Lifecycle Scripts Compile Against the Wrong Node
First naive version of the installer: run the target version's npm with the target version's node.
/bin/node /bin/npm i -g node-sass
Looks airtight. It isn't. On npm 10, lifecycle scripts — postinstall, node-gyp — resolve node from the ambient PATH, not from the node that's running npm. So installing node-sass "into Node 18" from a shell where Node 22 was active produced a binding compiled for ABI 127 — Node 22's ABI — sitting inside Node 18's tree. Loading it:
Error: The module was compiled against a different Node.js version using
NODE_MODULE_VERSION 127. This version of Node.js requires NODE_MODULE_VERSION 108.
The fix: the installer prepends the target version's bin dir to the spawned PATH, so gyp finds the right node. There's now a regression test that asserts exactly this.
Sabotage #2: One Stray Prefix Setting Redirects Every Install
If you have NPM_CONFIG_PREFIX exported — or prefix= sitting in ~/.npmrc from some tutorial five years ago — npm ignores the version tree entirely and installs into that prefix. Your shims then point at paths that don't exist. nvm users are exactly the people likely to have this half-configured; nvm itself refuses to run when it's set.
First instinct: strip the bad env vars. Wrong, twice. Stripping doesn't touch the npmrc form, and over-stripping npm_config_* would eat registry URLs, proxies, and auth tokens — breaking everyone behind a corporate proxy.
The actual fix is one line: set npm_config_prefix= in the spawned environment. npm's config precedence is env > userconfig > defaults, so this defeats every npmrc layer and every ambient variable at once.
Almost. While verifying this on npm 10.9, Kolovsky found a behavior undocumented anywhere: npm matches the npm_config_ env prefix case-insensitively, and on casing conflicts, last in env order wins. Try it:
$ npm_config_prefix=/tmp/lower NPM_CONFIG_PREFIX=/tmp/upper npm prefix -g
/tmp/upper
So the installer deletes every casing of the key first, then sets its own last. Deterministic, and yes, there's a test.
Sabotage #3: The Doctor Called a Healthy System Broken
nvmpin doctor originally checked that the shim dir precedes every nvm bin dir in PATH — reasonable, since rc misordering means shims permanently lose. Then, during the real-machine smoke test, doctor exited 2 on a system where everything demonstrably worked.
The cause: nvm use prepends the selected version's bin dir to PATH in the live shell. Every time. So the "shims must be first" invariant is violated in any shell where you've ever switched versions, while the shims keep resolving fine as long as no same-named bin exists in the prepended dir.
The fix: the check is now collision-aware. It only errors when an earlier nvm dir actually contains a bin that shadows one of your pins (and even then, not if it's the pin's own target version — identical resolution either way). Bare ordering after nvm use is a note, exit 0.
Testing: 79 Tests, Zero Dependencies
79 tests, zero dependencies, all node:test. The unit suite runs against fixture nvm trees and a stub npm. But sabotages #1 and #2 are claims about npm's behavior — a stub that simulates npm honoring $npm_config_prefix proves nothing if npm 11 changes the rules. So there's a gated integration test (NVMPIN_INTEGRATION=1) that runs real npm against a sandbox under a three-way prefix conflict, wired into prepublishOnly. It already paid for itself: the claims were derived on npm 10.8.2 and the test confirmed they hold on 10.9.4.
Every non-obvious call — all eighteen of them — is written down in DECISIONS.md with reasoning and terminal receipts. Four real bugs got caught before a single user existed.
Try It
npm i -g nvmpin
nvmpin setup
nvmpin add --node 22
POSIX only (macOS/Linux), Node ≥ 18, MIT. If you're on an npm version Kolovsky hasn't verified, run NVMPIN_INTEGRATION=1 npm test in the repo and report the diagnostic line.
GitHub: github.com/alexkolovsky/nvmpin · npm: npmjs.com/package/nvmpin



