Skip to content

Commit ae60bc4

Browse files
Scope quote normalization to CLI input (#585)
1 parent 2822c28 commit ae60bc4

8 files changed

Lines changed: 101 additions & 66 deletions

File tree

bin/index.spec.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,15 @@ describe('exiting conditions', () => {
149149
expect(exit.code).toBe(0);
150150
});
151151

152+
it('strips outer CLI wrapper quotes before running a command', async () => {
153+
const child = run('"echo foo"');
154+
const lines = await child.getLogLines();
155+
const exit = await child.exit;
156+
157+
expect(lines).toContainEqual(expect.stringContaining('foo'));
158+
expect(exit.code).toBe(0);
159+
});
160+
152161
it('is of failure by default when one of the command fails', async () => {
153162
const exit = await run('"echo foo" "exit 1"').exit;
154163

bin/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { hideBin } from 'yargs/helpers';
77
import * as defaults from '../lib/defaults.js';
88
import { concurrently } from '../lib/index.js';
99
import { castArray, splitOutsideParens } from '../lib/utils.js';
10+
import { normalizeCliCommand } from './normalize-cli-command.js';
1011
import { readPackageJson } from './read-package-json.js';
1112

1213
const version = String(readPackageJson().version);
@@ -230,7 +231,7 @@ if (!commands.length) {
230231

231232
concurrently(
232233
commands.map((command, index) => ({
233-
command: String(command),
234+
command: normalizeCliCommand(String(command)),
234235
name: names[index],
235236
})),
236237
{

bin/normalize-cli-command.spec.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { expect, it } from 'vitest';
2+
3+
import { normalizeCliCommand } from './normalize-cli-command.js';
4+
5+
it('strips outer CLI wrapper double quotes', () => {
6+
expect(normalizeCliCommand('"echo foo"')).toBe('echo foo');
7+
});
8+
9+
it('strips outer CLI wrapper single quotes', () => {
10+
expect(normalizeCliCommand("'echo foo'")).toBe('echo foo');
11+
});
12+
13+
it('strips quotes around a single wrapped token', () => {
14+
expect(normalizeCliCommand('"echo"')).toBe('echo');
15+
expect(normalizeCliCommand("'echo'")).toBe('echo');
16+
});
17+
18+
it('preserves quotes in well-formed shell commands', () => {
19+
expect(normalizeCliCommand('"/usr/local/bin/mytool" --flag "some value"')).toBe(
20+
'"/usr/local/bin/mytool" --flag "some value"',
21+
);
22+
});
23+
24+
it('preserves well-formed shell commands with multiple quote sets', () => {
25+
expect(
26+
normalizeCliCommand('"/usr/local/bin/mytool" --flag "some value" --other "last arg"'),
27+
).toBe('"/usr/local/bin/mytool" --flag "some value" --other "last arg"');
28+
});
29+
30+
it('preserves single quotes in well-formed shell commands', () => {
31+
expect(normalizeCliCommand("'printf' '%s %s' foo bar")).toBe("'printf' '%s %s' foo bar");
32+
});
33+
34+
it('returns unquoted input unchanged', () => {
35+
expect(normalizeCliCommand('echo foo')).toBe('echo foo');
36+
});
37+
38+
it('returns an empty string unchanged', () => {
39+
expect(normalizeCliCommand('')).toBe('');
40+
});
41+
42+
it('leaves ambiguous input unchanged', () => {
43+
expect(normalizeCliCommand('"echo foo')).toBe('"echo foo');
44+
});
45+
46+
it('leaves input with an unclosed single quote unchanged', () => {
47+
expect(normalizeCliCommand("echo foo'")).toBe("echo foo'");
48+
});
49+
50+
it('leaves input with mismatched quote types unchanged', () => {
51+
expect(normalizeCliCommand('"echo foo\'')).toBe('"echo foo\'');
52+
});

bin/normalize-cli-command.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
export function normalizeCliCommand(command: string): string {
2+
if (command.length < 2) {
3+
return command;
4+
}
5+
6+
const quote = command[0];
7+
const last = command.at(-1);
8+
if ((quote !== '"' && quote !== "'") || last !== quote) {
9+
return command;
10+
}
11+
12+
const inner = command.slice(1, -1);
13+
if (inner.includes(quote)) {
14+
return command;
15+
}
16+
17+
return inner;
18+
}

lib/command-parser/strip-quotes.spec.ts

Lines changed: 0 additions & 37 deletions
This file was deleted.

lib/command-parser/strip-quotes.ts

Lines changed: 0 additions & 18 deletions
This file was deleted.

lib/concurrently.spec.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -136,13 +136,28 @@ it('does not spawn further commands on abort signal aborted', () => {
136136
expect(spawn).toHaveBeenCalledTimes(1);
137137
});
138138

139-
it('runs controllers with the commands', () => {
140-
create(['echo', '"echo wrapped"']);
139+
it('preserves quotes in well-formed shell commands in the library API', () => {
140+
create(['"/usr/local/bin/mytool" --flag "some value"']);
141141

142142
controllers.forEach((controller) => {
143143
expect(controller.handle).toHaveBeenCalledWith([
144-
expect.objectContaining({ command: 'echo', index: 0 }),
145-
expect.objectContaining({ command: 'echo wrapped', index: 1 }),
144+
expect.objectContaining({
145+
command: '"/usr/local/bin/mytool" --flag "some value"',
146+
index: 0,
147+
}),
148+
]);
149+
});
150+
});
151+
152+
it('passes commands with multiple quote sets through unchanged in the library API', () => {
153+
create(['"/usr/local/bin/mytool" --flag "some value" --other "last arg"']);
154+
155+
controllers.forEach((controller) => {
156+
expect(controller.handle).toHaveBeenCalledWith([
157+
expect.objectContaining({
158+
command: '"/usr/local/bin/mytool" --flag "some value" --other "last arg"',
159+
index: 0,
160+
}),
146161
]);
147162
});
148163
});

lib/concurrently.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import { CommandParser } from './command-parser/command-parser.js';
1717
import { ExpandArguments } from './command-parser/expand-arguments.js';
1818
import { ExpandShortcut } from './command-parser/expand-shortcut.js';
1919
import { ExpandWildcard } from './command-parser/expand-wildcard.js';
20-
import { StripQuotes } from './command-parser/strip-quotes.js';
2120
import { CompletionListener, SuccessCondition } from './completion-listener.js';
2221
import { FlowController } from './flow-control/flow-controller.js';
2322
import { Logger } from './logger.js';
@@ -170,11 +169,7 @@ export function concurrently(
170169

171170
const prefixColorSelector = new PrefixColorSelector(options.prefixColors || []);
172171

173-
const commandParsers: CommandParser[] = [
174-
new StripQuotes(),
175-
new ExpandShortcut(),
176-
new ExpandWildcard(),
177-
];
172+
const commandParsers: CommandParser[] = [new ExpandShortcut(), new ExpandWildcard()];
178173

179174
if (options.additionalArguments) {
180175
commandParsers.push(new ExpandArguments(options.additionalArguments));

0 commit comments

Comments
 (0)