diff --git a/src/spawn.ts b/src/spawn.ts index 27464995..3aa0bdeb 100644 --- a/src/spawn.ts +++ b/src/spawn.ts @@ -17,8 +17,33 @@ export type Output = { const debug = makeDebug('@oclif/plugin-plugins:spawn') +/** + * With `windowsVerbatimArguments`, Node joins the arguments with spaces and does no quoting, so an + * argument containing whitespace reaches the child as several. Quote those the way + * `CommandLineToArgvW` expects, which is what libuv does when arguments are not verbatim. + */ +export function quoteWindowsArg(arg: string): string { + if ([...arg].every((char) => char !== '"' && char.trim() !== '')) return arg + + // Backslashes are literal except before a quote, so double the ones that end up before one: an + // escaped quote inside the argument, or the closing quote. + let quoted = '"' + let backslashes = 0 + for (const char of arg) { + if (char === '\\') { + backslashes++ + } else { + quoted += '\\'.repeat(char === '"' ? backslashes * 2 + 1 : backslashes) + char + backslashes = 0 + } + } + + return `${quoted}${'\\'.repeat(backslashes * 2)}"` +} + export async function spawn(modulePath: string, args: string[] = [], {cwd, logLevel}: ExecOptions): Promise { return new Promise((resolve, reject) => { + if (process.platform === 'win32') args = args.map((arg) => quoteWindowsArg(arg)) let argv0: string | undefined if (modulePath.endsWith('.js')) { const quote = process.platform === 'win32' ? `"${modulePath}"` : modulePath diff --git a/test/spawn.test.ts b/test/spawn.test.ts index 7754ca19..4f1875c3 100644 --- a/test/spawn.test.ts +++ b/test/spawn.test.ts @@ -4,7 +4,7 @@ import {tmpdir} from 'node:os' import path from 'node:path' import {fileURLToPath} from 'node:url' -import {spawn} from '../src/spawn.js' +import {quoteWindowsArg, spawn} from '../src/spawn.js' describe('spawn', () => { let tempDir: string @@ -96,6 +96,26 @@ describe('spawn', () => { expect(output).to.include('|calc.exe') }) + it('should pass an argument containing spaces as one argument', async () => { + // e.g. a version range: `npm install pkg@>=1.0.0 <2.0.0` must not also install `<2.0.0` + const script = path.join(tempDir, 'echo-args.js') + writeFileSync(script, 'console.log(JSON.stringify(process.argv.slice(2)))\n') + chmodSync(script, '755') + const args = ['@scope/pkg@>=1.0.0 <2.0.0', 'say "hi"', String.raw`C:\dir with space\\`, '--flag'] + + const result = await spawn(script, [...args], {cwd: tempDir, logLevel: 'silent'}) + + expect(JSON.parse(result.stdout.join(''))).to.deep.equal(args) + }) + + it('should quote Windows arguments only when they need it', () => { + expect(quoteWindowsArg('--flag')).to.equal('--flag') + expect(quoteWindowsArg(String.raw`C:\no\spaces`)).to.equal(String.raw`C:\no\spaces`) + expect(quoteWindowsArg('a b')).to.equal('"a b"') + expect(quoteWindowsArg('say "hi"')).to.equal(String.raw`"say \"hi\""`) + expect(quoteWindowsArg(String.raw`dir with space\\`)).to.equal(String.raw`"dir with space\\\\"`) + }) + it('should not modify non-.js module paths', async function () { if (process.platform === 'win32') return this.skip() const script = path.join(tempDir, 'test-bin')