diff --git a/bin/index.spec.ts b/bin/index.spec.ts index 74612bc0..02cc884c 100644 --- a/bin/index.spec.ts +++ b/bin/index.spec.ts @@ -149,6 +149,15 @@ describe('exiting conditions', () => { expect(exit.code).toBe(0); }); + it('strips outer CLI wrapper quotes before running a command', async () => { + const child = run('"echo foo"'); + const lines = await child.getLogLines(); + const exit = await child.exit; + + expect(lines).toContainEqual(expect.stringContaining('foo')); + expect(exit.code).toBe(0); + }); + it('is of failure by default when one of the command fails', async () => { const exit = await run('"echo foo" "exit 1"').exit; diff --git a/bin/index.ts b/bin/index.ts index 3c055cf9..cfa5b836 100755 --- a/bin/index.ts +++ b/bin/index.ts @@ -7,6 +7,7 @@ import { hideBin } from 'yargs/helpers'; import * as defaults from '../lib/defaults.js'; import { concurrently } from '../lib/index.js'; import { castArray, splitOutsideParens } from '../lib/utils.js'; +import { normalizeCliCommand } from './normalize-cli-command.js'; import { readPackageJson } from './read-package-json.js'; const version = String(readPackageJson().version); @@ -230,7 +231,7 @@ if (!commands.length) { concurrently( commands.map((command, index) => ({ - command: String(command), + command: normalizeCliCommand(String(command)), name: names[index], })), { diff --git a/bin/normalize-cli-command.spec.ts b/bin/normalize-cli-command.spec.ts new file mode 100644 index 00000000..aa53407b --- /dev/null +++ b/bin/normalize-cli-command.spec.ts @@ -0,0 +1,52 @@ +import { expect, it } from 'vitest'; + +import { normalizeCliCommand } from './normalize-cli-command.js'; + +it('strips outer CLI wrapper double quotes', () => { + expect(normalizeCliCommand('"echo foo"')).toBe('echo foo'); +}); + +it('strips outer CLI wrapper single quotes', () => { + expect(normalizeCliCommand("'echo foo'")).toBe('echo foo'); +}); + +it('strips quotes around a single wrapped token', () => { + expect(normalizeCliCommand('"echo"')).toBe('echo'); + expect(normalizeCliCommand("'echo'")).toBe('echo'); +}); + +it('preserves quotes in well-formed shell commands', () => { + expect(normalizeCliCommand('"/usr/local/bin/mytool" --flag "some value"')).toBe( + '"/usr/local/bin/mytool" --flag "some value"', + ); +}); + +it('preserves well-formed shell commands with multiple quote sets', () => { + expect( + normalizeCliCommand('"/usr/local/bin/mytool" --flag "some value" --other "last arg"'), + ).toBe('"/usr/local/bin/mytool" --flag "some value" --other "last arg"'); +}); + +it('preserves single quotes in well-formed shell commands', () => { + expect(normalizeCliCommand("'printf' '%s %s' foo bar")).toBe("'printf' '%s %s' foo bar"); +}); + +it('returns unquoted input unchanged', () => { + expect(normalizeCliCommand('echo foo')).toBe('echo foo'); +}); + +it('returns an empty string unchanged', () => { + expect(normalizeCliCommand('')).toBe(''); +}); + +it('leaves ambiguous input unchanged', () => { + expect(normalizeCliCommand('"echo foo')).toBe('"echo foo'); +}); + +it('leaves input with an unclosed single quote unchanged', () => { + expect(normalizeCliCommand("echo foo'")).toBe("echo foo'"); +}); + +it('leaves input with mismatched quote types unchanged', () => { + expect(normalizeCliCommand('"echo foo\'')).toBe('"echo foo\''); +}); diff --git a/bin/normalize-cli-command.ts b/bin/normalize-cli-command.ts new file mode 100644 index 00000000..f7075bf4 --- /dev/null +++ b/bin/normalize-cli-command.ts @@ -0,0 +1,18 @@ +export function normalizeCliCommand(command: string): string { + if (command.length < 2) { + return command; + } + + const quote = command[0]; + const last = command.at(-1); + if ((quote !== '"' && quote !== "'") || last !== quote) { + return command; + } + + const inner = command.slice(1, -1); + if (inner.includes(quote)) { + return command; + } + + return inner; +} diff --git a/lib/command-parser/strip-quotes.spec.ts b/lib/command-parser/strip-quotes.spec.ts deleted file mode 100644 index 714178a7..00000000 --- a/lib/command-parser/strip-quotes.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { expect, it } from 'vitest'; - -import { CommandInfo } from '../command.js'; -import { StripQuotes } from './strip-quotes.js'; - -const parser = new StripQuotes(); - -const createCommandInfo = (command: string): CommandInfo => ({ - command, - name: '', -}); - -it('returns command as is if no single/double quote at the beginning', () => { - const commandInfo = createCommandInfo('echo foo'); - expect(parser.parse(commandInfo)).toEqual(commandInfo); -}); - -it('strips single quotes', () => { - const commandInfo = createCommandInfo("'echo foo'"); - expect(parser.parse(commandInfo)).toEqual({ ...commandInfo, command: 'echo foo' }); -}); - -it('strips double quotes', () => { - const commandInfo = createCommandInfo('"echo foo"'); - expect(parser.parse(commandInfo)).toEqual({ ...commandInfo, command: 'echo foo' }); -}); - -it('does not remove quotes if they are unbalanced', () => { - let commandInfo = createCommandInfo('"echo foo'); - expect(parser.parse(commandInfo)).toEqual(commandInfo); - - commandInfo = createCommandInfo("echo foo'"); - expect(parser.parse(commandInfo)).toEqual(commandInfo); - - commandInfo = createCommandInfo('"echo foo\''); - expect(parser.parse(commandInfo)).toEqual(commandInfo); -}); diff --git a/lib/command-parser/strip-quotes.ts b/lib/command-parser/strip-quotes.ts deleted file mode 100644 index 097f29cb..00000000 --- a/lib/command-parser/strip-quotes.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { CommandInfo } from '../command.js'; -import { CommandParser } from './command-parser.js'; - -/** - * Strips quotes around commands so that they can run on the current shell. - */ -export class StripQuotes implements CommandParser { - parse(commandInfo: CommandInfo) { - let { command } = commandInfo; - - // Removes the quotes surrounding a command. - if (/^".+?"$/.test(command) || /^'.+?'$/.test(command)) { - command = command.slice(1, command.length - 1); - } - - return { ...commandInfo, command }; - } -} diff --git a/lib/concurrently.spec.ts b/lib/concurrently.spec.ts index 621cc1ba..cd237b40 100644 --- a/lib/concurrently.spec.ts +++ b/lib/concurrently.spec.ts @@ -136,13 +136,28 @@ it('does not spawn further commands on abort signal aborted', () => { expect(spawn).toHaveBeenCalledTimes(1); }); -it('runs controllers with the commands', () => { - create(['echo', '"echo wrapped"']); +it('preserves quotes in well-formed shell commands in the library API', () => { + create(['"/usr/local/bin/mytool" --flag "some value"']); controllers.forEach((controller) => { expect(controller.handle).toHaveBeenCalledWith([ - expect.objectContaining({ command: 'echo', index: 0 }), - expect.objectContaining({ command: 'echo wrapped', index: 1 }), + expect.objectContaining({ + command: '"/usr/local/bin/mytool" --flag "some value"', + index: 0, + }), + ]); + }); +}); + +it('passes commands with multiple quote sets through unchanged in the library API', () => { + create(['"/usr/local/bin/mytool" --flag "some value" --other "last arg"']); + + controllers.forEach((controller) => { + expect(controller.handle).toHaveBeenCalledWith([ + expect.objectContaining({ + command: '"/usr/local/bin/mytool" --flag "some value" --other "last arg"', + index: 0, + }), ]); }); }); diff --git a/lib/concurrently.ts b/lib/concurrently.ts index 973bc276..fa62d197 100644 --- a/lib/concurrently.ts +++ b/lib/concurrently.ts @@ -17,7 +17,6 @@ import { CommandParser } from './command-parser/command-parser.js'; import { ExpandArguments } from './command-parser/expand-arguments.js'; import { ExpandShortcut } from './command-parser/expand-shortcut.js'; import { ExpandWildcard } from './command-parser/expand-wildcard.js'; -import { StripQuotes } from './command-parser/strip-quotes.js'; import { CompletionListener, SuccessCondition } from './completion-listener.js'; import { FlowController } from './flow-control/flow-controller.js'; import { Logger } from './logger.js'; @@ -170,11 +169,7 @@ export function concurrently( const prefixColorSelector = new PrefixColorSelector(options.prefixColors || []); - const commandParsers: CommandParser[] = [ - new StripQuotes(), - new ExpandShortcut(), - new ExpandWildcard(), - ]; + const commandParsers: CommandParser[] = [new ExpandShortcut(), new ExpandWildcard()]; if (options.additionalArguments) { commandParsers.push(new ExpandArguments(options.additionalArguments));