`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
102 lines
4.4 KiB
TypeScript
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' })
|
|
}
|