From 9fd00682dc5f9d9b600be32bbac92e3c08decde1 Mon Sep 17 00:00:00 2001 From: Jordan Harband Date: Thu, 15 Oct 2026 16:18:41 -0700 Subject: [PATCH] fix(libnpmexec): install to the npx cache even when dryRun or packageLockOnly is set When `npm exec`/`npx` or `npm init ` needs a package that is not installed, libnpmexec installs it to the npx cache and then runs its bin. If `dryRun` or `packageLockOnly` is set, Arborist skips that install, so the bin is missing and the command fails, even though the user never asked for the exec itself to be a dry run. This happens without anyone passing those flags to `npx` directly: lifecycle scripts inherit the parent command's config as `npm_config_*` environment variables, so a `prepublishOnly` script that runs `npx some-tool` fails under `npm publish --dry-run`, because it sees `npm_config_dry_run=true`. #4843 fixed this for `packageLockOnly` in `npm exec` only, by overriding it in `lib/commands/exec.js`. `npm init ` calls libnpmexec too, and still had the bug for both flags, and `npm exec` still had it for `dryRun`. This moves that override into libnpmexec, where the npx cache's Arborist is created, and extends it to `dryRun`, so every caller gets it. The existing `npm exec` `packageLockOnly` test still passes, and there are new tests in libnpmexec for both flags and in `npm exec` for `dry-run`. --- lib/commands/exec.js | 2 -- test/lib/commands/exec.js | 36 ++++++++++++++++++++++++++ workspaces/libnpmexec/lib/index.js | 4 +++ workspaces/libnpmexec/test/registry.js | 26 +++++++++++++++++++ 4 files changed, 66 insertions(+), 2 deletions(-) diff --git a/lib/commands/exec.js b/lib/commands/exec.js index 7b7d8ad30fcd5..930a68de0baae 100644 --- a/lib/commands/exec.js +++ b/lib/commands/exec.js @@ -88,8 +88,6 @@ class Exec extends BaseCommand { return libexec({ ...flatOptions, allowScripts: allowScriptsPolicy, - // we explicitly set packageLockOnly to false because if it's true when we try to install a missing package, we won't actually install it - packageLockOnly: false, // what the user asked to run args[0] is run by default args: [...args], // copy args so they don't get mutated // specify a custom command to be run instead of args[0] diff --git a/test/lib/commands/exec.js b/test/lib/commands/exec.js index 87422d1519a44..7abd17e31a8e6 100644 --- a/test/lib/commands/exec.js +++ b/test/lib/commands/exec.js @@ -52,6 +52,42 @@ t.test('registry package', async t => { t.ok(exists.isFile(), 'bin ran, creating file') }) +t.test('registry package with dry-run set', async t => { + const registry = new MockRegistry({ + tap: t, + registry: 'https://registry.npmjs.org/', + }) + + const manifest = registry.manifest({ name: '@npmcli/npx-dry-run-test' }) + manifest.versions['1.0.0'].bin = { 'npx-dry-run-test': 'index.js' } + + const { npm } = await loadMockNpm(t, { + config: { + audit: false, + yes: true, + 'dry-run': true, + }, + prefixDir: { + 'npm-exec-test': { + 'package.json': JSON.stringify(manifest), + 'index.js': `#!/usr/bin/env node + require('fs').writeFileSync('npm-exec-test-success', '')`, + }, + }, + }) + + await registry.package({ + times: 2, + manifest, + tarballs: { + '1.0.0': path.join(npm.prefix, 'npm-exec-test'), + } }) + + await npm.exec('exec', ['@npmcli/npx-dry-run-test']) + const exists = await fs.stat(path.join(npm.prefix, 'npm-exec-test-success')) + t.ok(exists.isFile(), 'package was installed and bin ran, creating file') +}) + t.test('--prefix', async t => { const registry = new MockRegistry({ tap: t, diff --git a/workspaces/libnpmexec/lib/index.js b/workspaces/libnpmexec/lib/index.js index 4d7c126654689..56529693a51b6 100644 --- a/workspaces/libnpmexec/lib/index.js +++ b/workspaces/libnpmexec/lib/index.js @@ -263,6 +263,10 @@ const exec = async (opts) => { await mkdir(installDir, { recursive: true }) const npxArb = new Arborist({ ...flatOptions, + // a missing package must actually be installed to the npx cache, so it can be run, + // even when these are set: eg, a lifecycle script inherits npm_config_dry_run from `npm publish --dry-run` + dryRun: false, + packageLockOnly: false, path: installDir, }) const lockPath = join(installDir, 'concurrency.lock') diff --git a/workspaces/libnpmexec/test/registry.js b/workspaces/libnpmexec/test/registry.js index adbf4116107b4..b2ea492f4359f 100644 --- a/workspaces/libnpmexec/test/registry.js +++ b/workspaces/libnpmexec/test/registry.js @@ -328,3 +328,29 @@ t.test('override save to true when installing to npx cache', async t => { value: 'packages-2.0.0', }) }) + +t.test('install to npx cache when dryRun or packageLockOnly is set', async t => { + for (const flag of ['dryRun', 'packageLockOnly']) { + t.test(flag, async t => { + const { fixtures, package } = createPkg({ versions: ['2.0.0'] }) + + const { exec, path, registry, readOutput } = setup(t, { + testdir: merge(fixtures, { + global: {}, + }), + }) + + await package({ registry, path }) + + await exec({ + args: ['@npmcli/create-index'], + globalPath: resolve(path, 'global'), + [flag]: true, + }) + + t.match(await readOutput('@npmcli-create-index'), { + value: 'packages-2.0.0', + }, 'package was installed and its bin ran') + }) + } +})