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

    Bugthing that needs fixingcmd:execrelated to `npx`config:globalws:libnpmexecRelated to the libnpmexec workspace

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions