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
20 changes: 20 additions & 0 deletions workspaces/libnpmexec/lib/run-script.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,26 @@
const { extname, resolve } = require('node:path')
const ciInfo = require('ci-info')
const runScript = require('@npmcli/run-script')
const pkgJson = require('@npmcli/package-json')
const { log, output } = require('proc-log')
const noTTY = require('./no-tty.js')
const isWindowsShell = require('./is-windows.js')
const { fileExists, localFileExists } = require('./file-exists.js')

// cmd.exe looks up a command that already has an extension by its exact name
// before it tries PATHEXT. For a bin with a dot in its name that finds the
// extensionless sh shim, and Windows opens it with whatever program is
// registered for that "extension" instead of running it. Point cmd.exe at the
// .cmd shim next to it instead.
const hasCmdShim = async (bin, binPaths, runPath) => {
const shim = `${bin}.cmd`
for (const binPath of binPaths) {
if (await fileExists(resolve(binPath, shim))) {
return true
}
}
return !!(await localFileExists(resolve(runPath), shim, '/'))
}

const run = async ({
args,
Expand All @@ -23,6 +40,9 @@ const run = async ({
// literally; double quotes still expand $(), backticks, $var and "
args[0] = `'${args[0].replace(/'/g, `'\\''`)}'`
}
} else if (args.length > 0 && extname(args[0]) &&
await hasCmdShim(args[0], binPaths, runPath)) {
args[0] = `${args[0]}.cmd`
}

// turn list of args into command string
Expand Down
30 changes: 30 additions & 0 deletions workspaces/libnpmexec/test/run-script.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
const { resolve } = require('node:path')
const t = require('tap')

const mockRunScript = async (t, mocks, { level = 0 } = {}) => {
Expand Down Expand Up @@ -130,6 +131,35 @@ t.test('isWindows', async t => {
await runScript()
})

t.test('isWindows runs the .cmd shim of a bin with a dot in its name', async t => {
const dir = t.testdir({
global: { 'dot.bin.cmd': '' },
project: {
node_modules: { '.bin': { 'local.bin.cmd': '' } },
nested: {},
},
})
let pkg
const { runScript } = await mockRunScript(t, {
'ci-info': { isCI: true },
'@npmcli/run-script': async (opts) => {
pkg = opts.pkg
},
'../lib/is-windows.js': true,
})
const binPaths = [resolve(dir, 'global')]
const runPath = resolve(dir, 'project', 'nested')

await runScript({ args: ['dot.bin', 'arg'], binPaths, runPath })
t.equal(pkg.scripts.npx, 'dot.bin.cmd', 'shim found in binPaths')

await runScript({ args: ['local.bin'], binPaths, runPath })
t.equal(pkg.scripts.npx, 'local.bin.cmd', 'shim found in a node_modules/.bin above runPath')

await runScript({ args: ['no.shim'], binPaths, runPath })
t.equal(pkg.scripts.npx, 'no.shim', 'left alone without a .cmd shim')
})

t.test('escapes executable name to neutralize shell metacharacters', async t => {
let pkg
const { runScript } = await mockRunScript(t, {
Expand Down