Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/spawn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Output> {
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
Expand Down
22 changes: 21 additions & 1 deletion test/spawn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand Down