From 3323deb0c33fa78ac1088952dc36398e2df4ab5d Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Thu, 25 Jun 2026 01:17:56 +0530 Subject: [PATCH] fix(exec): resolve workspace-local bin under the linked install strategy (#9639) In continuation of our exploration of using `install-strategy=linked` in the [Gutenberg monorepo](https://github.com/WordPress/gutenberg/pull/75814), which powers the WordPress Block Editor. Under `install-strategy=linked`, `npm exec -w -- ` ignored a workspace-local bin (provided by a sibling workspace dependency) and fell through to the registry, producing a spurious `E404`. The hoisted strategy ran the local bin correctly. ## Why For a workspace exec, the command computed the local bin directory as `resolve(this.npm.localDir, name, 'node_modules', '.bin')`, i.e. `/node_modules//node_modules/.bin`. That path only resolves when the workspace is symlinked into the root `node_modules` as ``, which is how the hoisted strategy lays workspaces out. The linked strategy does not hoist workspaces into the root `node_modules`; the workspace's real bin lives at `/node_modules/.bin`. libnpmexec walks up from the given bin directory looking for `node_modules/.bin/`, so starting from the nonexistent hoisted path never reached the workspace's actual bin and the lookup fell back to the registry. ## How Base the local bin directory on the workspace's own path (`runPath`) instead of the hoisted `localDir/` location. This is correct under both strategies: linked finds the bin in the workspace's `node_modules/.bin`, and hoisted still finds the root-hoisted bin because the walk-up continues from the workspace directory to the root `node_modules/.bin`. ## References Fixes #9616 (cherry picked from commit d6fbb55991cf92e8c9a904c88b9b7736cf3a1861) --- lib/commands/exec.js | 6 +++--- test/lib/commands/exec.js | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/lib/commands/exec.js b/lib/commands/exec.js index 23c47a0cc1ad7..7b7d8ad30fcd5 100644 --- a/lib/commands/exec.js +++ b/lib/commands/exec.js @@ -42,7 +42,7 @@ class Exec extends BaseCommand { } } - async callExec (args, { name, locationMsg, runPath } = {}) { + async callExec (args, { locationMsg, runPath } = {}) { let localBin = this.npm.localBin let pkgPath = this.npm.localPrefix @@ -50,8 +50,8 @@ class Exec extends BaseCommand { if (!runPath) { runPath = process.cwd() } else { - // We have to consider if the workspace has its own separate versions libnpmexec will walk up to localDir after looking here - localBin = resolve(this.npm.localDir, name, 'node_modules', '.bin') + // Use the workspace's own node_modules/.bin, not localDir/, since the linked strategy does not symlink workspaces into the root node_modules. + localBin = resolve(runPath, 'node_modules', '.bin') // We also need to look for `bin` entries in the workspace package.json // libnpmexec will NOT look in the project root for the bin entry pkgPath = runPath diff --git a/test/lib/commands/exec.js b/test/lib/commands/exec.js index 92ea993e3edfb..45e7634da5e10 100644 --- a/test/lib/commands/exec.js +++ b/test/lib/commands/exec.js @@ -220,6 +220,41 @@ t.test('finds workspace dep first', async t => { t.ok(exists.isFile(), 'bin ran, creating file') }) +t.test('finds workspace dep bin under linked install strategy', async t => { + const { npm } = await loadMockNpm(t, { + config: { + 'install-strategy': 'linked', + }, + prefixDir: { + 'package.json': JSON.stringify({ + name: '@npmcli/npx-workspace-root-test', + workspaces: ['workspace-a', 'tool'], + }), + 'workspace-a': { + 'package.json': JSON.stringify({ + name: 'workspace-a', + dependencies: { tool: '*' }, + }), + }, + tool: { + 'package.json': JSON.stringify({ + name: 'tool', + version: '1.0.0', + bin: { 'npx-test': 'index.js' }, + }), + 'index.js': `#!/usr/bin/env node + require('fs').writeFileSync('npm-exec-test-success', '')`, + }, + }, + }) + + await npm.exec('install', []) + npm.config.set('workspace', ['workspace-a']) + await npm.exec('exec', ['npx-test']) + const exists = await fs.stat(path.join(npm.prefix, 'workspace-a', 'npm-exec-test-success')) + t.ok(exists.isFile(), 'workspace-local bin ran instead of falling back to the registry') +}) + t.test('npx --no-install @npmcli/npx-test', async t => { const registry = new MockRegistry({ tap: t,