DEV Community

Alex Kolovskiy
Alex Kolovskiy

Posted on

nvm loses your global CLIs every time you switch. So I fixed it.

Every nvm user knows this loop. You install a CLI globally, it works, then a week later nvm use 18 for some 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, which is a sledgehammer and 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.

So I built nvmpin: pin each global package to a specific node version, and it keeps working no matter which version your shell is on.

$ nvm use 16
Now using node v16.20.2

$ which cowsay
/Users/alex/.nvmpin/bin/cowsay

$ cowsay ok
 ____
< ok >
 ----
Enter fullscreen mode Exit fullscreen mode

That's a real transcript from the pre-release smoke test — cowsay is pinned to node 22, the shell is on 16, nothing breaks.

"Isn't this just Volta?"

Mostly, yes — and if you're choosing a version manager from scratch, look at Volta first. It shims everything transparently and it's more complete than this will ever be.

nvmpin exists for one population: people already on nvm who aren't leaving. Volta and nvm fight over your PATH; migrating means unwinding years of muscle memory, dotfiles, CI scripts, and team conventions. nvmpin sits on top of an existing nvm install and changes nothing about how you use nvm. That's the whole pitch. If that's not you, close the tab.

How it works

No daemon, no wrapper around nvm, no PATH interception magic. Three parts:

  1. A registry at ~/.nvmpin/pins.json mapping package → node version.
  2. A shim directory ~/.nvmpin/bin, prepended to PATH after nvm's init.
  3. 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" "$@"
Enter fullscreen mode Exit fullscreen mode

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.

Simple idea. The interesting part is everything npm did to sabotage it.

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.

<target>/bin/node <target>/bin/npm i -g node-sass
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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, because it's the kind of bug that comes back.

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, and it's better than stripping: assert the correct value. npm's config precedence is env > userconfig > defaults, so setting npm_config_prefix=<versionDir> in the spawned env defeats every npmrc layer and every ambient variable at once.

Almost. While verifying this on npm 10.9, I found a behavior I haven't seen documented 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
Enter fullscreen mode Exit fullscreen mode

Which means a bare assignment can silently lose to a stale uppercase variable that happens to sit later in the environment. So the installer deletes every casing of the key first, then sets its own last. Deterministic, and yes, there's a test.

Sabotage #3: my own doctor called my 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. It's inherent nvm behavior — which means 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.

A diagnostic that fires in every normal shell trains you to ignore it right before the one time it matters. So 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.

The part nobody asked about: tests

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, not mine — 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, and it's wired into prepublishOnly — publishing without re-verifying the npm contract is impossible by default. It already paid for itself once: 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 the reasoning and, where it exists, the terminal receipt. Four real bugs got caught before a single user existed. I'd rather ship the bug list myself than read it in the issues.

Try it

npm i -g nvmpin
nvmpin setup
nvmpin add <your-most-missed-cli> --node 22
Enter fullscreen mode Exit fullscreen mode

POSIX only (macOS/Linux), node ≥ 18, MIT. If you're on an npm version I haven't verified, run NVMPIN_INTEGRATION=1 npm test in the repo and tell me what the diagnostic line says — that's free contract verification for everyone.

GitHub: github.com/alexkolovsky/nvmpin · npm: npmjs.com/package/nvmpin

Top comments (0)