diff --git a/lib/commands/exec.js b/lib/commands/exec.js index 7b7d8ad30fcd5..0e76384669a69 100644 --- a/lib/commands/exec.js +++ b/lib/commands/exec.js @@ -29,7 +29,7 @@ class Exec extends BaseCommand { static isShellout = true async exec (args) { - return this.callExec(args) + return this.callExec(args, { replaceProcess: true }) } async execWorkspaces (args) { @@ -42,7 +42,7 @@ class Exec extends BaseCommand { } } - async callExec (args, { locationMsg, runPath } = {}) { + async callExec (args, { locationMsg, runPath, replaceProcess = false }) { let localBin = this.npm.localBin let pkgPath = this.npm.localPrefix @@ -104,6 +104,7 @@ class Exec extends BaseCommand { locationMsg, // packages that need to be installed packages, + replaceProcess, // path where node_modules is path: this.npm.localPrefix, // where to look for package.json#bin entries first diff --git a/test/lib/commands/exec.js b/test/lib/commands/exec.js index 87422d1519a44..f34927bfcc482 100644 --- a/test/lib/commands/exec.js +++ b/test/lib/commands/exec.js @@ -4,6 +4,14 @@ const path = require('node:path') const { load: loadMockNpm } = require('../../fixtures/mock-npm.js') const MockRegistry = require('@npmcli/mock-registry') +// npm exec replaces its own process with the command when it can, which would +// replace this test runner, so force the spawn path here. +const { execve } = process +process.execve = undefined +t.teardown(() => { + process.execve = execve +}) + t.test('call with args', async t => { const { npm } = await loadMockNpm(t, { config: { @@ -404,3 +412,37 @@ t.test('exec reads .npmrc policy even when project package.json has a different await npm.exec('exec', ['some-pkg']) t.strictSame(capturedOpts.allowScripts, { canvas: true }) }) + +t.test('replaces the npm process for a single command', async t => { + let capturedOpts + const { npm } = await loadMockNpm(t, { + mocks: { + libnpmexec: async (opts) => { + capturedOpts = opts + }, + }, + }) + await npm.exec('exec', ['some-pkg']) + t.equal(capturedOpts.replaceProcess, true) +}) + +t.test('keeps the npm process when running in workspaces', async t => { + const captured = [] + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ name: 'root', workspaces: ['packages/*'] }), + packages: { + a: { 'package.json': JSON.stringify({ name: 'a', version: '1.0.0' }) }, + b: { 'package.json': JSON.stringify({ name: 'b', version: '1.0.0' }) }, + }, + }, + config: { workspaces: true }, + mocks: { + libnpmexec: async (opts) => { + captured.push(opts.replaceProcess) + }, + }, + }) + await npm.exec('exec', ['some-pkg']) + t.strictSame(captured, [false, false]) +}) diff --git a/workspaces/libnpmexec/lib/run-script.js b/workspaces/libnpmexec/lib/run-script.js index 2de40454f9dcf..7cae37424c0f2 100644 --- a/workspaces/libnpmexec/lib/run-script.js +++ b/workspaces/libnpmexec/lib/run-script.js @@ -27,6 +27,7 @@ const run = async ({ // turn list of args into command string const script = call || args.shift() || scriptShell + const replaceProcess = !!flatOptions.replaceProcess && script !== scriptShell // do the fakey runScript dance // still should work if no package.json in cwd @@ -38,7 +39,7 @@ const run = async ({ ...realPkg, scripts: { ...(realPkg.scripts || {}), - npx: script, + npx: replaceProcess && !call && scriptShell === 'sh' ? `exec ${script}` : script, }, } @@ -69,6 +70,7 @@ const run = async ({ args, stdio: 'inherit', scriptShell, + replaceProcess, }) } diff --git a/workspaces/libnpmexec/test/run-script.js b/workspaces/libnpmexec/test/run-script.js index 5c92ce19397f6..5c15a805d0967 100644 --- a/workspaces/libnpmexec/test/run-script.js +++ b/workspaces/libnpmexec/test/run-script.js @@ -157,3 +157,57 @@ t.test('isNotWindows', async t => { // need both arguments and no arguments for code coverage await runScript() }) + +t.test('replaceProcess', async t => { + const runWith = async (t, opts) => { + let received + const runScript = t.mock('../lib/run-script.js', { + 'ci-info': { isCI: true }, + '@npmcli/run-script': async (o) => { + received = o + }, + '../lib/is-windows.js': false, + }) + await runScript({ + args: [], + call: '', + path: '', + runPath: '', + scriptShell: 'sh', + ...opts, + flatOptions: { replaceProcess: true, ...opts.flatOptions }, + }) + return received + } + + t.test('execs the bin through the shell', async t => { + const o = await runWith(t, { args: ['foo', 'bar'] }) + t.equal(o.replaceProcess, true) + t.equal(o.pkg.scripts.npx, `exec 'foo'`) + t.strictSame(o.args, ['bar']) + }) + + t.test('keeps --call scripts as they are', async t => { + const o = await runWith(t, { call: 'foo && bar' }) + t.equal(o.replaceProcess, true) + t.equal(o.pkg.scripts.npx, 'foo && bar') + }) + + t.test('does not prefix exec for a custom script-shell', async t => { + const o = await runWith(t, { args: ['foo'], scriptShell: 'bash' }) + t.equal(o.replaceProcess, true) + t.equal(o.pkg.scripts.npx, `'foo'`) + }) + + t.test('never replaces the process in interactive mode', async t => { + const o = await runWith(t, {}) + t.equal(o.replaceProcess, false) + t.equal(o.pkg.scripts.npx, 'sh') + }) + + t.test('is off unless requested', async t => { + const o = await runWith(t, { args: ['foo'], flatOptions: { replaceProcess: false } }) + t.equal(o.replaceProcess, false) + t.equal(o.pkg.scripts.npx, `'foo'`) + }) +})