<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Alex Kolovskiy — Blog</title><description>Notes on full-stack engineering, e-commerce, and the craft of shipping web products.</description><link>https://kolovsky.dev/</link><item><title>nvm loses your global CLIs every time you switch. So I fixed it.</title><link>https://kolovsky.dev/blog/nvm-loses-your-global-clis/</link><guid isPermaLink="true">https://kolovsky.dev/blog/nvm-loses-your-global-clis/</guid><description>nvm silos global packages per node version, so every switch makes your CLIs vanish. The standard fixes are all bad in different ways — so I built nvmpin, and npm sabotaged it three times along the way.</description><pubDate>Wed, 15 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Every nvm user knows this loop. You install a CLI globally, it works, then a week later &lt;code&gt;nvm use 18&lt;/code&gt; for some legacy project and the command is gone. Not broken — gone. &lt;code&gt;command not found&lt;/code&gt;. Because nvm doesn’t have global packages; each node version has its own &lt;code&gt;lib/node_modules&lt;/code&gt;, and switching versions swaps the entire world out from under you.&lt;/p&gt;
&lt;p&gt;The standard answers are all bad. &lt;code&gt;nvm reinstall-packages&lt;/code&gt; copies everything between versions, which is a sledgehammer and reinstalls things into versions where they don’t belong. &lt;code&gt;--default-packages&lt;/code&gt; only helps at install time. And “just use npx” means cold-starting your tools through the network.&lt;/p&gt;
&lt;p&gt;So I built &lt;a href=&quot;https://github.com/alexkolovsky/nvmpin&quot;&gt;nvmpin&lt;/a&gt;: pin each global package to a specific node version, and it keeps working no matter which version your shell is on.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark-default&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#0d1117;color:#24292e;--shiki-dark:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;console&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E6EDF3&quot;&gt;$ nvm use 16&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79C0FF&quot;&gt;Now using node v16.20.2&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E6EDF3&quot;&gt;$ which cowsay&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79C0FF&quot;&gt;/Users/alex/.nvmpin/bin/cowsay&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E6EDF3&quot;&gt;$ cowsay ok&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79C0FF&quot;&gt; ____&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79C0FF&quot;&gt;&amp;#x3C; ok &gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79C0FF&quot;&gt; ----&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That’s a real transcript from the pre-release smoke test — cowsay is pinned to node 22, the shell is on 16, nothing breaks.&lt;/p&gt;
&lt;h2 id=&quot;isnt-this-just-volta&quot;&gt;“Isn’t this just Volta?”&lt;/h2&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;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 &lt;em&gt;on top&lt;/em&gt; 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.&lt;/p&gt;
&lt;h2 id=&quot;how-it-works&quot;&gt;How it works&lt;/h2&gt;
&lt;p&gt;No daemon, no wrapper around nvm, no PATH interception magic. Three parts:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;A registry at &lt;code&gt;~/.nvmpin/pins.json&lt;/code&gt; mapping package → node version.&lt;/li&gt;
&lt;li&gt;A shim directory &lt;code&gt;~/.nvmpin/bin&lt;/code&gt;, prepended to PATH after nvm’s init.&lt;/li&gt;
&lt;li&gt;Generated shims — three-line bash scripts with absolute paths baked in:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark-default&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#0d1117;color:#24292e;--shiki-dark:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#8B949E&quot;&gt;#!/usr/bin/env bash&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#8B949E&quot;&gt;# nvmpin shim for cowsay -&gt; v22.21.1 (do not edit)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79C0FF&quot;&gt;exec&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#A5D6FF&quot;&gt; &quot;/Users/alex/.nvm/versions/node/v22.21.1/bin/node&quot;&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#FF7B72&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#A5D6FF&quot;&gt;  &quot;/Users/alex/.nvm/versions/node/v22.21.1/lib/node_modules/cowsay/cli.js&quot;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#A5D6FF&quot;&gt; &quot;&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79C0FF&quot;&gt;$@&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#A5D6FF&quot;&gt;&quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;nvmpin add cowsay --node 22&lt;/code&gt; installs it into node 22’s tree and writes the shim. &lt;code&gt;nvmpin move pkg --node 18&lt;/code&gt; reinstalls into 18 — a real reinstall, never a re-point, because native modules are compiled per node ABI. &lt;code&gt;nvmpin scan&lt;/code&gt; shows which globals live in which versions. &lt;code&gt;nvmpin doctor&lt;/code&gt; tells you when something’s broken. Zero dependencies, POSIX only for now.&lt;/p&gt;
&lt;p&gt;Simple idea. The interesting part is everything npm did to sabotage it.&lt;/p&gt;
&lt;h2 id=&quot;sabotage-1-lifecycle-scripts-compile-against-the-wrong-node&quot;&gt;Sabotage #1: lifecycle scripts compile against the wrong node&lt;/h2&gt;
&lt;p&gt;First naive version of the installer: run the target version’s npm with the target version’s node.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark-default&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#0d1117;color:#24292e;--shiki-dark:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;console&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79C0FF&quot;&gt;&amp;#x3C;target&gt;/bin/node &amp;#x3C;target&gt;/bin/npm i -g node-sass&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Looks airtight. It isn’t. On npm 10, lifecycle scripts — postinstall, node-gyp — resolve &lt;code&gt;node&lt;/code&gt; from the &lt;em&gt;ambient&lt;/em&gt; 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:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark-default&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#0d1117;color:#24292e;--shiki-dark:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;console&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79C0FF&quot;&gt;Error: The module was compiled against a different Node.js version using&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79C0FF&quot;&gt;NODE_MODULE_VERSION 127. This version of Node.js requires NODE_MODULE_VERSION 108.&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h2 id=&quot;sabotage-2-one-stray-prefix-setting-redirects-every-install&quot;&gt;Sabotage #2: one stray prefix setting redirects every install&lt;/h2&gt;
&lt;p&gt;If you have &lt;code&gt;NPM_CONFIG_PREFIX&lt;/code&gt; exported — or &lt;code&gt;prefix=&lt;/code&gt; sitting in &lt;code&gt;~/.npmrc&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;First instinct: strip the bad env vars. Wrong, twice. Stripping doesn’t touch the npmrc form, and over-stripping &lt;code&gt;npm_config_*&lt;/code&gt; would eat registry URLs, proxies, and auth tokens — breaking everyone behind a corporate proxy.&lt;/p&gt;
&lt;p&gt;The actual fix is one line, and it’s better than stripping: &lt;em&gt;assert&lt;/em&gt; the correct value. npm’s config precedence is env &amp;gt; userconfig &amp;gt; defaults, so setting &lt;code&gt;npm_config_prefix=&amp;lt;versionDir&amp;gt;&lt;/code&gt; in the spawned env defeats every npmrc layer and every ambient variable at once.&lt;/p&gt;
&lt;p&gt;Almost. While verifying this on npm 10.9, I found a behavior I haven’t seen documented anywhere: npm matches the &lt;code&gt;npm_config_&lt;/code&gt; env prefix case-insensitively, and on casing conflicts, &lt;strong&gt;last in env order wins&lt;/strong&gt;. Try it:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark-default&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#0d1117;color:#24292e;--shiki-dark:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;console&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E6EDF3&quot;&gt;$ npm_config_prefix&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#FF7B72&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#A5D6FF&quot;&gt;/tmp/lower&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E6EDF3&quot;&gt; NPM_CONFIG_PREFIX&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#FF7B72&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#A5D6FF&quot;&gt;/tmp/upper&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#FFA657&quot;&gt; npm&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#A5D6FF&quot;&gt; prefix&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79C0FF&quot;&gt; -g&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79C0FF&quot;&gt;/tmp/upper&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h2 id=&quot;sabotage-3-my-own-doctor-called-my-healthy-system-broken&quot;&gt;Sabotage #3: my own doctor called my healthy system broken&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;nvmpin doctor&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;The cause: &lt;code&gt;nvm use&lt;/code&gt; &lt;em&gt;prepends&lt;/em&gt; 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.&lt;/p&gt;
&lt;p&gt;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 &lt;em&gt;actually contains&lt;/em&gt; 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 &lt;code&gt;nvm use&lt;/code&gt; is a note, exit 0.&lt;/p&gt;
&lt;h2 id=&quot;the-part-nobody-asked-about-tests&quot;&gt;The part nobody asked about: tests&lt;/h2&gt;
&lt;p&gt;79 tests, zero dependencies, all &lt;code&gt;node:test&lt;/code&gt;. The unit suite runs against fixture nvm trees and a stub npm. But sabotages #1 and #2 are claims about &lt;em&gt;npm’s&lt;/em&gt; behavior, not mine — a stub that simulates npm honoring &lt;code&gt;$npm_config_prefix&lt;/code&gt; proves nothing if npm 11 changes the rules. So there’s a gated integration test (&lt;code&gt;NVMPIN_INTEGRATION=1&lt;/code&gt;) that runs real npm against a sandbox under a three-way prefix conflict, and it’s wired into &lt;code&gt;prepublishOnly&lt;/code&gt; — 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.&lt;/p&gt;
&lt;p&gt;Every non-obvious call — all eighteen of them — is written down in &lt;a href=&quot;https://github.com/alexkolovsky/nvmpin/blob/main/DECISIONS.md&quot;&gt;DECISIONS.md&lt;/a&gt; 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.&lt;/p&gt;
&lt;h2 id=&quot;try-it&quot;&gt;Try it&lt;/h2&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark-default&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#0d1117;color:#24292e;--shiki-dark:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;console&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79C0FF&quot;&gt;npm i -g nvmpin&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79C0FF&quot;&gt;nvmpin setup&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79C0FF&quot;&gt;nvmpin add &amp;#x3C;your-most-missed-cli&gt; --node 22&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;POSIX only (macOS/Linux), node ≥ 18, MIT. If you’re on an npm version I haven’t verified, run &lt;code&gt;NVMPIN_INTEGRATION=1 npm test&lt;/code&gt; in the repo and tell me what the diagnostic line says — that’s free contract verification for everyone.&lt;/p&gt;
&lt;p&gt;GitHub: &lt;a href=&quot;https://github.com/alexkolovsky/nvmpin&quot;&gt;github.com/alexkolovsky/nvmpin&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;npm: &lt;a href=&quot;https://www.npmjs.com/package/nvmpin&quot;&gt;npmjs.com/package/nvmpin&lt;/a&gt;&lt;/p&gt;
</content:encoded><category>node</category><category>nvm</category><category>npm</category><category>cli</category><category>open-source</category></item></channel></rss>