diff --git a/lib/command-parser/expand-arguments.spec.ts b/lib/command-parser/expand-arguments.spec.ts index 16bad309..bad49158 100644 --- a/lib/command-parser/expand-arguments.spec.ts +++ b/lib/command-parser/expand-arguments.spec.ts @@ -1,8 +1,11 @@ import { expect, it } from 'vitest'; import { CommandInfo } from '../command.js'; +import { getDefaultShellKind, isWinShellKind } from '../spawn.js'; import { ExpandArguments } from './expand-arguments.js'; +const isWinShell = isWinShellKind(getDefaultShellKind()); + const createCommandInfo = (command: string): CommandInfo => ({ command, name: '', @@ -23,7 +26,10 @@ it('single argument placeholder is replaced', () => { it('argument placeholder is replaced and quoted properly', () => { const parser = new ExpandArguments(['foo bar']); const commandInfo = createCommandInfo('echo {1}'); - expect(parser.parse(commandInfo)).toEqual({ ...commandInfo, command: "echo 'foo bar'" }); + expect(parser.parse(commandInfo)).toEqual({ + ...commandInfo, + command: isWinShell ? 'echo "foo bar"' : "echo 'foo bar'", + }); }); it('argument placeholder with single quote and exclamation mark is quoted properly', () => { @@ -31,7 +37,7 @@ it('argument placeholder with single quote and exclamation mark is quoted proper const commandInfo = createCommandInfo('echo {1}'); expect(parser.parse(commandInfo)).toEqual({ ...commandInfo, - command: `echo 'it'"'"'s great!'`, + command: isWinShell ? 'echo "it\'s great!"' : `echo 'it'"'"'s great!'`, }); }); @@ -68,7 +74,10 @@ it('all arguments placeholder is replaced', () => { it('combined arguments placeholder is replaced', () => { const parser = new ExpandArguments(['foo', 'bar']); const commandInfo = createCommandInfo('echo {*}'); - expect(parser.parse(commandInfo)).toEqual({ ...commandInfo, command: "echo 'foo bar'" }); + expect(parser.parse(commandInfo)).toEqual({ + ...commandInfo, + command: isWinShell ? 'echo "foo bar"' : "echo 'foo bar'", + }); }); it('escaped argument placeholders are not replaced', () => { diff --git a/lib/command-parser/expand-arguments.ts b/lib/command-parser/expand-arguments.ts index 44c516ea..d4988208 100644 --- a/lib/command-parser/expand-arguments.ts +++ b/lib/command-parser/expand-arguments.ts @@ -1,13 +1,19 @@ -import { quote } from 'shell-quote'; - import { CommandInfo } from '../command.js'; +import { getDefaultShellKind, ShellKind } from '../spawn.js'; import { CommandParser } from './command-parser.js'; +import { createShellQuote } from './shell-quote.js'; /** * Replace placeholders with additional arguments. */ export class ExpandArguments implements CommandParser { - constructor(private readonly additionalArguments: string[]) {} + private readonly shellKind: ShellKind; + constructor( + private readonly additionalArguments: string[], + shellKind?: ShellKind, + ) { + this.shellKind = shellKind || getDefaultShellKind(); + } parse(commandInfo: CommandInfo) { const command = commandInfo.command.replace( @@ -19,6 +25,7 @@ export class ExpandArguments implements CommandParser { } if (this.additionalArguments.length > 0) { + const quote = createShellQuote(this.shellKind); // Replace numeric placeholder if value exists in additional arguments. if (+placeholderTarget <= this.additionalArguments.length) { return quote([this.additionalArguments[+placeholderTarget - 1]]); diff --git a/lib/command-parser/shell-quote.spec.ts b/lib/command-parser/shell-quote.spec.ts new file mode 100644 index 00000000..b84d5442 --- /dev/null +++ b/lib/command-parser/shell-quote.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import { ShellKind } from '../spawn.js'; +import { createShellQuote } from './shell-quote.js'; + +describe('on POSIX', () => { + const quote = createShellQuote('posix'); + it('should handle arguments with spaces', () => { + expect(quote(['foo bar'])).toBe("'foo bar'"); + }); +}); + +describe.each(['cmd', 'powershell'] as ShellKind[])('on %s', (shellKind: ShellKind) => { + const quote = createShellQuote(shellKind); + it('should wrap arguments containing spaces in double quotes', () => { + const escaped = quote(['foo bar']); + expect(escaped).toBe('"foo bar"'); + }); + + it('should escape internal double quotes', () => { + const escaped = quote(['--json={"key": "value"}']); + expect(escaped).toBe('"--json={\\"key\\": \\"value\\"}"'); + }); + + it('should escape internal trailing backslashes when arguments containing spaces', () => { + const escaped = quote(['C:\\path to file\\']); + expect(escaped).toBe('"C:\\path to file\\\\"'); + }); + + it('should not escape safe special characters', () => { + const escaped = quote(['--tags=@foo']); + expect(escaped).toBe('--tags=@foo'); + }); +}); diff --git a/lib/command-parser/shell-quote.ts b/lib/command-parser/shell-quote.ts new file mode 100644 index 00000000..3ebdc3f3 --- /dev/null +++ b/lib/command-parser/shell-quote.ts @@ -0,0 +1,38 @@ +import { quote as bashShellQuote } from 'shell-quote'; + +import { isWinShellKind, ShellKind } from '../spawn.js'; + +function winShellQuote(args: string[]) { + return args + .map((arg) => { + // Only process arguments containing double quotes or spaces. + if (!/["\s]/.test(arg)) { + return arg; + } + + // Escape internal double quotes like " \" \\" + const escaped: string = arg.replace(/\\*"/g, (match) => { + const count: number = match.length - 1; + return '\\'.repeat(count * 2 + 1) + '"'; + }); + + // Wrap arguments containing spaces in double quotes + if (/[\s]/.test(arg)) { + // Escape trailing backslashes before the closing quote + const match = escaped.match(/\\+$/); + if (match && match[0].length % 2 === 1) { + return '"' + escaped + '\\"'; + } + return '"' + escaped + '"'; + } + return escaped; + }) + .join(' '); +} + +export const createShellQuote = (shellKind: ShellKind) => { + if (isWinShellKind(shellKind)) { + return winShellQuote; + } + return bashShellQuote; +}; diff --git a/lib/concurrently.spec.ts b/lib/concurrently.spec.ts index cd237b40..6ac0786a 100644 --- a/lib/concurrently.spec.ts +++ b/lib/concurrently.spec.ts @@ -10,6 +10,7 @@ import { ChildProcess, KillProcess, SpawnCommand } from './command.js'; import { concurrently, ConcurrentlyCommandInput, ConcurrentlyOptions } from './concurrently.js'; import { FlowController } from './flow-control/flow-controller.js'; import { Logger } from './logger.js'; +import { getDefaultShellKind, isWinShellKind } from './spawn.js'; let spawn: SpawnCommand; let kill: KillProcess; @@ -390,7 +391,10 @@ it('argument placeholders are properly replaced when additional arguments are pa expect(spawn).toHaveBeenCalledTimes(4); expect(spawn).toHaveBeenCalledWith('echo foo', expect.objectContaining({})); expect(spawn).toHaveBeenCalledWith('echo foo bar', expect.objectContaining({})); - expect(spawn).toHaveBeenCalledWith("echo 'foo bar'", expect.objectContaining({})); + const combinedCommand = isWinShellKind(getDefaultShellKind()) + ? 'echo "foo bar"' + : "echo 'foo bar'"; + expect(spawn).toHaveBeenCalledWith(combinedCommand, expect.objectContaining({})); expect(spawn).toHaveBeenCalledWith('echo {@}', expect.objectContaining({})); }); diff --git a/lib/concurrently.ts b/lib/concurrently.ts index fa62d197..ec209353 100644 --- a/lib/concurrently.ts +++ b/lib/concurrently.ts @@ -22,7 +22,7 @@ import { FlowController } from './flow-control/flow-controller.js'; import { Logger } from './logger.js'; import { OutputWriter } from './output-writer.js'; import { PrefixColorSelector } from './prefix-color-selector.js'; -import { createSpawn, getSpawnOpts } from './spawn.js'; +import { createSpawn, getDefaultShellKind, getSpawnOpts, ShellKind } from './spawn.js'; import { castArray } from './utils.js'; const defaults: ConcurrentlyOptions = { @@ -31,6 +31,7 @@ const defaults: ConcurrentlyOptions = { raw: false, controllers: [], cwd: undefined, + shellKind: getDefaultShellKind(), }; /** @@ -150,6 +151,11 @@ export interface ConcurrentlyOptions { * @see ExpandArguments */ additionalArguments?: string[]; + + /** + * The type of shell to use for spawning commands + */ + shellKind: ShellKind; } /** @@ -172,7 +178,7 @@ export function concurrently( const commandParsers: CommandParser[] = [new ExpandShortcut(), new ExpandWildcard()]; if (options.additionalArguments) { - commandParsers.push(new ExpandArguments(options.additionalArguments)); + commandParsers.push(new ExpandArguments(options.additionalArguments, options.shellKind)); } const hide = (options.hide || []).map(String); diff --git a/lib/index.ts b/lib/index.ts index 03bb2c9d..00bcfdf9 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -22,7 +22,7 @@ import { OutputErrorHandler } from './flow-control/output-error-handler.js'; import { RestartDelay, RestartProcess } from './flow-control/restart-process.js'; import { Teardown } from './flow-control/teardown.js'; import { Logger } from './logger.js'; -import { createSpawn } from './spawn.js'; +import { createSpawn, detectShellKind, resolveShell } from './spawn.js'; import { castArray } from './utils.js'; export type ConcurrentlyOptions = Omit< @@ -161,7 +161,7 @@ export function concurrently( const abortController = new AbortController(); const outputStream = options.outputStream || process.stdout; - + const shellKind = detectShellKind(resolveShell(options.shell)); const spawn = createSpawn(options.shell); return createConcurrently(commands, { maxProcesses: options.maxProcesses, @@ -209,6 +209,7 @@ export function concurrently( ], prefixColors: options.prefixColors || [], additionalArguments: options.additionalArguments, + shellKind, }); } diff --git a/lib/spawn.ts b/lib/spawn.ts index 257c28fd..2785a2b1 100644 --- a/lib/spawn.ts +++ b/lib/spawn.ts @@ -37,7 +37,7 @@ const NPM_SCRIPT_SHELL_ENV = 'npm_config_script_shell'; * Resolves which shell executable to use when spawning commands. * @see {@link createSpawn()} */ -function resolveShell( +export function resolveShell( shell?: string, process: Pick = nodeProcess, ): string { @@ -92,7 +92,7 @@ export type ShellKind = 'cmd' | 'posix' | 'powershell'; /** * Detects which argument style to use when spawning the given shell executable. */ -function detectShellKind(shellPath: string): ShellKind { +export function detectShellKind(shellPath: string): ShellKind { const normalized = shellPath.replace(/\\/g, '/'); const base = path .basename(normalized) @@ -108,6 +108,13 @@ function detectShellKind(shellPath: string): ShellKind { return 'posix'; } +export function getDefaultShellKind(): ShellKind { + return process.platform === 'win32' ? 'cmd' : 'posix'; +} + +export const isWinShellKind = (shellKind: ShellKind) => + shellKind === 'cmd' || shellKind === 'powershell'; + export const getSpawnOpts = ({ colorSupport = supportsColor.stdout, cwd,