Files
pnpm/pnpm11/exec/npm-lifecycle/test/scriptArgs.ts
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

76 lines
2.8 KiB
TypeScript

import fs from 'node:fs'
import path from 'node:path'
import { expect, test } from '@jest/globals'
import { temporaryDirectory } from 'tempy'
import { appendScriptArgs, quoteForCmd, showScriptWithArgs } from '../src/scriptArgs.js'
test.each([
['plain', 'plain'],
['', '""'],
['C:\\Program Files\\tool', '^"C:\\Program^ Files\\tool^"'],
['C:\\Program Files\\tool\\', '^"C:\\Program^ Files\\tool\\\\^"'],
['C:\\dir\\', 'C:\\dir\\'],
['%PATH%', '^%PATH^%'],
['a"b', '^"a\\^"b^"'],
['a\\"b', '^"a\\\\\\^"b^"'],
['tab\there', '^"tab\there^"'],
['^&|<>()!', '^^^&^|^<^>^(^)^!'],
['line\nbreak', 'line\\nbreak'],
])('quoteForCmd(%j)', (arg, expected) => {
expect(quoteForCmd(arg, false)).toBe(expected)
})
test('quoteForCmd() escapes twice for a batch file', () => {
expect(quoteForCmd('%PATH%', true)).toBe('^^^%PATH^^^%')
expect(quoteForCmd('a b', true)).toBe('^^^"a^^^ b^^^"')
})
test('appendScriptArgs() quotes for sh outside of cmd', () => {
const searchPath = () => {
throw new Error('searchPath is only read for cmd')
}
const opts = { wd: process.cwd(), searchPath }
expect(appendScriptArgs('node x.js', ['a b', '%PATH%'], { ...opts, platform: 'linux' })).toBe("node x.js 'a b' %PATH%")
expect(appendScriptArgs('node x.js', ['a b'], { ...opts, platform: 'win32', shellEmulator: true })).toBe("node x.js 'a b'")
expect(appendScriptArgs('node x.js', [], { ...opts, platform: 'win32' })).toBe('node x.js')
})
test.each([
['tool.cmd --flag', true],
['tool.cmd\t--flag', true],
['"<bin>/tool.cmd" --flag', true],
['bin/tool.cmd --flag', true],
['echo ready && tool.cmd', true],
['tool.cmd 2>&1', true],
['tool.cmd <&0', true],
['echo ^>& tool.cmd', true],
['echo ^<& tool.cmd', true],
['node x.js ^& tool.cmd', false],
['tool.cmd && node x.js', false],
['node tool.cmd', false],
])('appendScriptArgs() escapes twice only when %j ends with a batch file', (script, doubleEscape) => {
const wd = temporaryDirectory()
const binDir = path.join(wd, 'bin')
fs.mkdirSync(binDir)
fs.writeFileSync(path.join(binDir, 'tool.cmd'), '', { mode: 0o755 })
script = script.replace('<bin>', binDir)
expect(appendScriptArgs(script, ['%PATH%'], { platform: 'win32', wd, searchPath: () => binDir }))
.toBe(`${script} ${doubleEscape ? '^^^%PATH^^^%' : '^%PATH^%'}`)
})
test('appendScriptArgs() finds a batch file in the directory the script runs in', () => {
const wd = temporaryDirectory()
fs.writeFileSync(path.join(wd, 'tool.cmd'), '', { mode: 0o755 })
expect(appendScriptArgs('tool.cmd', ['%PATH%'], { platform: 'win32', wd, searchPath: () => '' }))
.toBe('tool.cmd ^^^%PATH^^^%')
})
test('showScriptWithArgs() quotes the POSIX way', () => {
expect(showScriptWithArgs('node x.js', ['a b', '%PATH%', 'C:\\dir\\'])).toBe("node x.js 'a b' %PATH% 'C:\\dir\\'")
expect(showScriptWithArgs('node x.js', [])).toBe('node x.js')
})