Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions lib/commands/exec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions test/lib/commands/exec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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])
})
4 changes: 3 additions & 1 deletion workspaces/libnpmexec/lib/run-script.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -38,7 +39,7 @@ const run = async ({
...realPkg,
scripts: {
...(realPkg.scripts || {}),
npx: script,
npx: replaceProcess && !call && scriptShell === 'sh' ? `exec ${script}` : script,
},
}

Expand Down Expand Up @@ -69,6 +70,7 @@ const run = async ({
args,
stdio: 'inherit',
scriptShell,
replaceProcess,
})
}

Expand Down
54 changes: 54 additions & 0 deletions workspaces/libnpmexec/test/run-script.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'`)
})
})