From 01a4eaa435d7193a9e861607f84c8fa29da43187 Mon Sep 17 00:00:00 2001 From: ClayChipps Date: Tue, 20 Oct 2026 11:05:15 -0400 Subject: [PATCH] fix: quote Windows arguments that contain spaces spawn() uses windowsVerbatimArguments, so Node joins the arguments with spaces and does no quoting. An argument containing whitespace reached npm as several: installing `pkg@>=1.0.0 <2.0.0` also installed `<2.0.0`, which npm-package-arg reads as a nameless range and resolves to the unrelated package `undefined`. JIT plugins with a spaced range in oclif.jitPlugins hit this on every install and `plugins update`. Quote such arguments the way CommandLineToArgvW expects, which is what libuv does when arguments are not verbatim. Arguments without whitespace or quotes are passed through unchanged, so the verbatim behaviour that keeps shell metacharacters literal is untouched. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/spawn.ts | 25 +++++++++++++++++++++++++ test/spawn.test.ts | 22 +++++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) 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')