npm exec (npx) links bins to the wrong directory when run from a global install's lifecycle script, causing "command not found" and install rollback
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 10.1k
- Forks
- 4.7k
- Avg merge
- 2d 2h
- Merged PRs (30d)
- 19
Description
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'sexec(),flatOptionstherefore carriesglobal: 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#globalTopis true for the installed package, sobin-links
uses 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. - 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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by running the minimal reproduction, then inspect libnpmexec's exec() and bin-target.js, focusing on the inherited global option and the installDir/binPaths mismatch. Verify the npx cache install uses the expected local bin directory, and confirm that the postinstall command succeeds without rolling back the global install.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, nodejs
- Domain
- cli, tooling
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100