Files
Zoltan Kochan 266cbd9615 fix(run): pass script arguments through cmd unchanged on Windows (#16262)
`pnpm run` appended the arguments after the script name with JSON string
quoting when cmd parsed the script. cmd then expanded `%VAR%`, and the
target program's C runtime received doubled backslashes and `\t` for a tab.

Both CLIs now quote each argument the way npm does: quoted for the C
runtime, then with every cmd metacharacter escaped with `^`. When the
script's first word resolves to a .cmd or .bat file on the script's PATH,
such as a node_modules/.bin shim, the arguments are escaped a second time
because the batch file parses them again. The command that receives the
arguments is the last one of a chain, and the lookup searches the script
directory before PATH, as cmd does. Line breaks, which cmd cannot carry,
are still passed as the two characters `\n` / `\r`.

pnpm prints the script with its arguments quoted the POSIX way on every
platform, so the `^` escapes stay out of the `$` line, the lifecycle log
event and error messages. npm_lifecycle_script keeps the command that
runs.

Closes pnpm/pnpm#16257
2026-09-27 13:57:54 +02:00

102 lines
4.4 KiB
TypeScript

import { spawnSync } from 'node:child_process'
import { expect, test } from '@jest/globals'
import { commandParsedByCmd, scriptBody, selectShell, useShellEmulator } from '../src/selectShell.js'
test('uses sh -c by default on POSIX', () => {
expect(selectShell(undefined, 'linux', undefined)).toEqual({ sh: 'sh', shFlag: '-c', windowsVerbatimArguments: false })
})
test('uses ComSpec with /d /s /c by default on Windows', () => {
expect(selectShell(undefined, 'win32', 'C:\\Windows\\system32\\cmd.exe')).toEqual({
sh: 'C:\\Windows\\system32\\cmd.exe',
shFlag: '/d /s /c',
windowsVerbatimArguments: true,
})
})
test.each([
'C:\\Windows\\System32\\cmd.exe',
'C:/Windows/System32/CMD.EXE',
'cmd.exe',
'cmd',
'Cmd',
])('passes /d /s /c to a cmd.exe scriptShell on Windows (%s)', (scriptShell) => {
expect(selectShell(scriptShell, 'win32', undefined)).toEqual({ sh: scriptShell, shFlag: '/d /s /c', windowsVerbatimArguments: true })
})
test('passes -c to a non-cmd scriptShell on Windows', () => {
const scriptShell = 'C:\\Program Files\\Git\\bin\\bash.exe'
expect(selectShell(scriptShell, 'win32', undefined)).toEqual({ sh: scriptShell, shFlag: '-c', windowsVerbatimArguments: false })
})
test('passes -c to a scriptShell named cmd on POSIX', () => {
expect(selectShell('/usr/local/bin/cmd', 'linux', undefined)).toEqual({ sh: '/usr/local/bin/cmd', shFlag: '-c', windowsVerbatimArguments: false })
})
const gitBash = 'C:\\Program Files\\Git\\bin\\bash.exe'
test('a blank scriptShell falls back like an unset one', () => {
expect(selectShell('', 'linux', undefined)).toEqual(selectShell(undefined, 'linux', undefined))
expect(selectShell('', 'win32', 'C:\\Windows\\system32\\cmd.exe')).toEqual(
selectShell(undefined, 'win32', 'C:\\Windows\\system32\\cmd.exe')
)
expect(commandParsedByCmd('', 'win32', false)).toBe(true)
expect(commandParsedByCmd('', 'win32', true)).toBe(false)
})
test('shellEmulator applies only when scriptShell is unset', () => {
expect(useShellEmulator(true, undefined)).toBe(true)
expect(useShellEmulator(true, '')).toBe(true)
expect(useShellEmulator(true, gitBash)).toBe(false)
expect(useShellEmulator(false, gitBash)).toBe(false)
expect(useShellEmulator(false, undefined)).toBe(false)
})
test('extra arguments are quoted for cmd only when cmd will parse them', () => {
expect(commandParsedByCmd(gitBash, 'win32', true)).toBe(false)
expect(commandParsedByCmd(undefined, 'win32', false)).toBe(true)
expect(commandParsedByCmd(undefined, 'win32', true)).toBe(false)
expect(commandParsedByCmd('C:\\Windows\\System32\\cmd.exe', 'win32', false)).toBe(true)
expect(commandParsedByCmd('C:\\Windows\\System32\\cmd.exe', 'win32', true)).toBe(true)
expect(commandParsedByCmd(gitBash, 'linux', false)).toBe(false)
})
test('a Bourne shell runs the command behind the interrupt trap', () => {
expect(scriptBody(selectShell(undefined, 'linux', undefined), 'node dev.js')).toMatch(/^trap .*node dev\.js$/)
expect(scriptBody(selectShell('/usr/bin/bash.exe', 'win32', undefined), 'node dev.js')).toMatch(/^trap .*node dev\.js$/)
})
const skipOnWindows = process.platform === 'win32' ? test.skip : test
// A shell's own `kill` stands in for a terminal interrupt that the foreground
// command handled: the trap then sees that command's status, not 130.
skipOnWindows('a handled interrupt lets the rest of the script run', () => {
const { stdout, status } = runInSh('kill -s INT $$; echo after; exit 3')
expect(stdout).toBe('after\n')
expect(status).toBe(3)
})
skipOnWindows('a command killed by the interrupt ends the script with SIGINT', () => {
const { stdout, signal } = runInSh("sh -c 'kill -s INT $PPID; kill -s INT $$'; echo after")
expect(stdout).toBe('')
expect(signal).toBe('SIGINT')
})
skipOnWindows('a second interrupt ends the script with SIGINT', () => {
const { stdout, signal } = runInSh('kill -s INT $$; kill -s INT $$; echo after')
expect(stdout).toBe('')
expect(signal).toBe('SIGINT')
})
test('a non-Bourne shell runs the command unchanged', () => {
expect(scriptBody(selectShell('/usr/bin/fish', 'linux', undefined), 'node dev.js')).toBe('node dev.js')
expect(scriptBody(selectShell('cmd.exe', 'win32', undefined), 'node dev.js')).toBe('node dev.js')
})
function runInSh (command: string) {
const shell = selectShell(undefined, process.platform, undefined)
return spawnSync(shell.sh, [shell.shFlag, scriptBody(shell, command)], { encoding: 'utf8' })
}