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
15 changes: 12 additions & 3 deletions lib/command-parser/expand-arguments.spec.ts
Original file line number Diff line number Diff line change
@@ -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: '',
Expand All @@ -23,15 +26,18 @@ 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', () => {
const parser = new ExpandArguments(["it's great!"]);
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!'`,
});
});

Expand Down Expand Up @@ -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', () => {
Expand Down
13 changes: 10 additions & 3 deletions lib/command-parser/expand-arguments.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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]]);
Expand Down
34 changes: 34 additions & 0 deletions lib/command-parser/shell-quote.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
38 changes: 38 additions & 0 deletions lib/command-parser/shell-quote.ts
Original file line number Diff line number Diff line change
@@ -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;
};
6 changes: 5 additions & 1 deletion lib/concurrently.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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({}));
});

Expand Down
10 changes: 8 additions & 2 deletions lib/concurrently.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -31,6 +31,7 @@ const defaults: ConcurrentlyOptions = {
raw: false,
controllers: [],
cwd: undefined,
shellKind: getDefaultShellKind(),
};

/**
Expand Down Expand Up @@ -150,6 +151,11 @@ export interface ConcurrentlyOptions {
* @see ExpandArguments
*/
additionalArguments?: string[];

/**
* The type of shell to use for spawning commands
*/
shellKind: ShellKind;
}

/**
Expand All @@ -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);
Expand Down
5 changes: 3 additions & 2 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -209,6 +209,7 @@ export function concurrently(
],
prefixColors: options.prefixColors || [],
additionalArguments: options.additionalArguments,
shellKind,
});
}

Expand Down
11 changes: 9 additions & 2 deletions lib/spawn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NodeJS.Process, 'platform' | 'env'> = nodeProcess,
): string {
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down
Loading