Skip to content

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 #9890

Description

@Linefate

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:

  1. In libnpmexec's exec(), flatOptions therefore carries global: true.
  2. 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.
  3. 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.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions