Environment: npm 10.9.8 / node v22.23.1 / macOS arm64 (nvm). I also verified the
relevant code is unchanged in libnpmexec@11.0.2 (latest), so this affects npm 11 as well.
Expected behavior: npx --yes cowsay hello spawned from a postinstall script of a
package being installed with npm install -g should work the same as in a normal shell.
package being installed with npm install -g should work the same as in a normal shell.
Actual behavior: it fails with sh: cowsay: command not found (exit 127), and npm
rolls back the entire global install. This is 100% reproducible on a cold npx cache.
Minimal repro:
mkdir npx-repro && cd npx-repro
cat > package.json <<'EOF'
{ "name": "npx-repro", "version": "1.0.0", "scripts": { "postinstall": "node postinstall.js" } }
EOF
cat > postinstall.js <<'EOF'
const { spawn } = require('child_process')
const child = spawn('npx', ['--yes', 'cowsay', 'hello'], { stdio: 'inherit' })
child.once('close', (s) => { console.log('npx exit code:', s); process.exit(s ?? 1) })
EOF
npm install -g .
Root cause analysis:
During a global install, npm injects npm_config_global=true and npm_config_prefix
into the lifecycle script environment. An npx spawned from that script inherits them:
- In
libnpmexec's exec(), flatOptions therefore carries global: true.
- The npx cache Arborist is created with those options:
const npxArb = new Arborist({ ...flatOptions, path: installDir })
so the npx cache tree root is flagged as global.
- During reify,
Node#globalTop is true for the installed package, so bin-links
uses its global target: bin-target.js returns dirname(getPrefix(path)) + '/bin',
which resolves to <npxCache>/bin (one level above the per-hash installDir)
instead of <installDir>/node_modules/.bin.
- But
exec() later looks up the bin at the hardcoded path:
binPaths.push(resolve(installDir, 'node_modules/.bin')) — which is empty.
Observed on disk after a failure: package files present in
~/.npm/_npx/<hash>/node_modules/cowsay and symlinks present in ~/.npm/_npx/bin/,
but ~/.npm/_npx/<hash>/node_modules/.bin does not exist.
Note: a warm npx cache (package previously installed from a normal shell) masks the
bug because reify is skipped, which makes this intermittent-looking in the wild.
Suggested fix: isolate the npx cache install from inherited global config, e.g.
new Arborist({ ...flatOptions, path: installDir, global: false }) so bin links land
where binPaths expects them.
Environment: npm 10.9.8 / node v22.23.1 / macOS arm64 (nvm). I also verified the
relevant code is unchanged in libnpmexec@11.0.2 (latest), so this affects npm 11 as well.
Expected behavior:
npx --yes cowsay hellospawned from apostinstallscript of apackage being installed with
npm install -gshould work the same as in a normal shell.package being installed with
npm install -gshould work the same as in a normal shell.Actual behavior: it fails with
sh: cowsay: command not found(exit 127), and npmrolls back the entire global install. This is 100% reproducible on a cold npx cache.
Minimal repro:
Root cause analysis:
During a global install, npm injects
npm_config_global=trueandnpm_config_prefixinto the lifecycle script environment. An
npxspawned from that script inherits them:libnpmexec'sexec(),flatOptionstherefore carriesglobal: true.const npxArb = new Arborist({ ...flatOptions, path: installDir })so the npx cache tree root is flagged as global.
Node#globalTopis true for the installed package, sobin-linksuses its global target:
bin-target.jsreturnsdirname(getPrefix(path)) + '/bin',which resolves to
<npxCache>/bin(one level above the per-hashinstallDir)instead of
<installDir>/node_modules/.bin.exec()later looks up the bin at the hardcoded path:binPaths.push(resolve(installDir, 'node_modules/.bin'))— which is empty.Observed on disk after a failure: package files present in
~/.npm/_npx/<hash>/node_modules/cowsayand symlinks present in~/.npm/_npx/bin/,but
~/.npm/_npx/<hash>/node_modules/.bindoes not exist.Note: a warm npx cache (package previously installed from a normal shell) masks the
bug because reify is skipped, which makes this intermittent-looking in the wild.
Suggested fix: isolate the npx cache install from inherited global config, e.g.
new Arborist({ ...flatOptions, path: installDir, global: false })so bin links landwhere
binPathsexpects them.