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
6 changes: 6 additions & 0 deletions docs/cli/shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ $ concurrently 'yarn:lint:*(!fix)'
$ concurrently -n js,ts 'yarn run lint:js' 'yarn run lint:ts'
```

Wildcard expansion replaces the script pattern and preserves surrounding commands and arguments. The full command runs once per matching script:

```bash
$ concurrently 'npm run lint:*(!fix) && echo done'
```

> [!NOTE]
> If you use this syntax with double quotes (`"`), bash and other shells might fail
> parsing it. You'll need to escape the `!`, or use single quote (`'`) instead.<br/>
Expand Down
88 changes: 88 additions & 0 deletions lib/command-parser/expand-wildcard-parser.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';

import { ExpandWildcard } from './expand-wildcard.js';

const createParser = (scripts: Record<string, string> = { 'build:app': '', 'build:lib': '' }) =>
new ExpandWildcard(
() => ({}),
() => ({ scripts }),
);

describe('parser-based wildcard expansion', () => {
it.each([
['npm run build:*; echo done', 'npm run build:app; echo done'],
['npm run build:* | cat', 'npm run build:app | cat'],
[' \tnpm run build:*', ' \tnpm run build:app'],
["npm run build:* -- don't", "npm run build:app -- don't"],
['npm run build:* ; Write-Output `"done`"', 'npm run build:app ; Write-Output `"done`"'],
])('keeps the source around %s', (command, expected) => {
expect(createParser({ 'build:app': '' }).parse({ command, name: '' })).toEqual([
{ name: 'app', command: expected },
]);
});

it('expands only the first eligible runner', () => {
expect(
createParser().parse({ command: 'npm run build:* && npm run test:*', name: '' }),
).toEqual([
{ name: 'app', command: 'npm run build:app && npm run test:*' },
{ name: 'lib', command: 'npm run build:lib && npm run test:*' },
]);
});

it('quotes special characters in matched script names', () => {
const parser = createParser({
'build:with space': '',
"build:it's": '',
'build:$HOME': '',
"build:it's!": '',
});
expect(parser.parse({ command: 'npm run "build:*"', name: '' })).toEqual([
{ name: 'with space', command: "npm run 'build:with space'" },
{ name: "it's", command: "npm run 'build:it'\\''s'" },
{ name: '$HOME', command: "npm run 'build:$HOME'" },
{ name: "it's!", command: "npm run 'build:it'\\''s!'" },
]);
});

it('rejects NUL in a matched script name', () => {
expect(() =>
createParser({ 'build:\0': '' }).parse({ command: 'npm run build:*', name: '' }),
).toThrow(new TypeError('Arguments cannot contain NUL'));
});

it.each([
'test:*-unit(!slow|integration)',
'test:*(!slow|integration)-unit',
'"test:*-unit(!slow|integration)"',
])('preserves a chain around omission pattern %s', (pattern) => {
const parser = createParser({
'test:fast-unit': '',
'test:slow-unit': '',
'test:integration-unit': '',
});
expect(
parser.parse({ command: `cd app && npm run ${pattern} && echo done`, name: '' }),
).toEqual([{ name: 'fast', command: 'cd app && npm run test:fast-unit && echo done' }]);
});

it.each(["'test:*-unit(!slow\\.case)'", 'test:*-unit(!slow\\.case)'])(
'preserves regex escapes in %s',
(pattern) => {
const parser = createParser({ 'test:slow.case-unit': '', 'test:slowXcase-unit': '' });
expect(parser.parse({ command: `npm run ${pattern} && echo done`, name: '' })).toEqual([
{ name: 'slowXcase', command: 'npm run test:slowXcase-unit && echo done' },
]);
},
);

it.each([
'build() { npm run build:*; }',
'echo $(npm run build:*)',
'echo `npm run build:*`',
'(npm run build:*)',
])('does not traverse a nested invocation: %s', (command) => {
const input = { command, name: '' };
expect(createParser().parse(input)).toBe(input);
});
});
44 changes: 44 additions & 0 deletions lib/command-parser/expand-wildcard-preservation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';

import { ExpandWildcard } from './expand-wildcard.js';

const createParser = (scripts: Record<string, string> = { 'build:app': '' }) =>
new ExpandWildcard(
() => ({}),
() => ({ scripts }),
);

describe('preserving commands around wildcard scripts', () => {
it.each([
['npm run build:* && echo done', 'npm run build:app && echo done'],
['cd app && npm run build:*', 'cd app && npm run build:app'],
['npm run build:* -- --grep "a & b"', 'npm run build:app -- --grep "a & b"'],
['cross-env NODE_ENV=test npm run build:*', 'cross-env NODE_ENV=test npm run build:app'],
['npx pnpm run build:*', 'npx pnpm run build:app'],
['npm run clean && npm run build:*', 'npm run clean && npm run build:app'],
['npm run "build:*"', 'npm run build:app'],
['npm run build:* && echo "unfinished', 'npm run build:app && echo "unfinished'],
['npm run build:* && && echo done', 'npm run build:app && && echo done'],
])('replaces just the script in %s', (command, expected) => {
expect(createParser().parse({ command, name: '' })).toEqual([
{ command: expected, name: 'app' },
]);
});

it('does not expand a runner inside one quoted argument', () => {
const input = { command: 'echo "npm run build:*"', name: '' };
expect(createParser().parse(input)).toBe(input);
});

it('preserves chained commands with the existing omission syntax', () => {
const parser = createParser({ 'lint:js': '', 'lint:fix:js': '' });
expect(parser.parse({ command: 'npm run lint:*(!fix) && echo done', name: '' })).toEqual([
{ command: 'npm run lint:js && echo done', name: 'js' },
]);
});

it('does not repair an unterminated script word', () => {
const input = { command: 'npm run "build:*', name: '' };
expect(createParser().parse(input)).toBe(input);
});
});
107 changes: 93 additions & 14 deletions lib/command-parser/expand-wildcard.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import fs from 'node:fs';

import { type Node, parse as parseShell, type Script, type Word } from 'unbash';

import { CommandInfo } from '../command.js';
import JSONC from '../jsonc.js';
import { escapeRegExp } from '../utils.js';
Expand All @@ -8,6 +10,90 @@ import { CommandParser } from './command-parser.js';
// Matches a negative filter surrounded by '(!' and ')'.
const OMISSION = /\(!([^)]+)\)/;

const RUN_SUBCOMMANDS: Record<string, string> = {
npm: 'run',
yarn: 'run',
pnpm: 'run',
bun: 'run',
node: '--run',
deno: 'task',
};

type WildcardCommand = {
command: string;
scriptGlob: string;
replace: (script: string) => string;
};

function findRunner(node: Node | Script): { command: string; glob: Word } | undefined {
switch (node.type) {
case 'Command': {
const words = [node.name, ...node.suffix];
for (let index = 0; index + 2 < words.length; index++) {
const name = words[index]?.value;
const subcommand = words[index + 1];
const glob = words[index + 2];
if (
name &&
glob &&
subcommand?.value === RUN_SUBCOMMANDS[name] &&
glob.value.includes('*')
) {
return {
command: `${name} ${subcommand.value}`,
glob,
};
}
}
return undefined;
}
case 'Statement':
return findRunner(node.command);
case 'Script':
case 'Pipeline':
case 'AndOr':
for (const command of node.commands) {
const runner = findRunner(command);
if (runner) {
return runner;
}
}
return undefined;
default:
return undefined;
}
}

function quoteScript(script: string): string {
if (script.includes('\0')) {
throw new TypeError('Arguments cannot contain NUL');
}
return /^[\p{L}\p{N}_:./+-]+$/u.test(script)
? script
: "'" + script.split("'").join("'\\''") + "'";
}

function parseCommand(commandLine: string): WildcardCommand | undefined {
const parsed = parseShell(commandLine);
const runner = findRunner(parsed);
if (!runner) {
return undefined;
}
const { command, glob } = runner;
if (parsed.errors?.some((error) => error.pos >= glob.pos && error.pos < glob.end)) {
return undefined;
}
// A suffix omission can sit outside the Bash word, e.g. test:*-unit(!slow).
const omission = OMISSION.exec(commandLine.slice(glob.end));
const end = omission?.index === 0 ? glob.end + omission[0].length : glob.end;
return {
command,
scriptGlob: glob.value + commandLine.slice(glob.end, end),
replace: (script) =>
commandLine.slice(0, glob.pos) + quoteScript(script) + commandLine.slice(end),
};
}

/**
* Finds wildcards in 'npm/yarn/pnpm/bun run', 'node --run' and 'deno task'
* commands and replaces them with all matching scripts in the NodeJS and Deno
Expand Down Expand Up @@ -70,19 +156,12 @@ export class ExpandWildcard implements CommandParser {
}

parse(commandInfo: CommandInfo) {
// We expect one of the following patterns:
// - <npm|yarn|pnpm|bun> run <script> [args]
// - node --run <script> [args]
// - deno task <script> [args]
const [, command, scriptGlob, args] =
/((?:npm|yarn|pnpm|bun) run|node --run|deno task) (\S+)([^&]*)/.exec(
commandInfo.command,
) || [];

const wildcardPosition = (scriptGlob || '').indexOf('*');

// If the regex didn't match an npm script, or it has no wildcard,
// then we have nothing to do here
const wildcard = parseCommand(commandInfo.command);
if (!wildcard) {
return commandInfo;
}
const { command, scriptGlob, replace } = wildcard;
const wildcardPosition = scriptGlob.indexOf('*');
if (wildcardPosition === -1) {
return commandInfo;
}
Expand All @@ -108,7 +187,7 @@ export class ExpandWildcard implements CommandParser {
if (match !== undefined) {
commands.push({
...commandInfo,
command: `${command} ${script}${args}`,
command: replace(script),
// Will use an empty command name if no prefix has been specified and
// the wildcard match is empty, e.g. if `npm:watch-*` matches `npm run watch-`.
name: prefix + match,
Expand Down
12 changes: 12 additions & 0 deletions lib/concurrently.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { beforeEach, expect, it, Mock, MockedObject, vi } from 'vitest';
import { createMockInstance } from './__fixtures__/create-mock-instance.js';
import { createFakeProcess, FakeCommand } from './__fixtures__/fake-command.js';
import { ChildProcess, KillProcess, SpawnCommand } from './command.js';
import { ExpandWildcard } from './command-parser/expand-wildcard.js';
import { concurrently, ConcurrentlyCommandInput, ConcurrentlyOptions } from './concurrently.js';
import { FlowController } from './flow-control/flow-controller.js';
import { Logger } from './logger.js';
Expand Down Expand Up @@ -54,6 +55,17 @@ it('spawns all commands', () => {
expect(spawn).toHaveBeenCalledWith('kill', expect.objectContaining({}));
});

it('preserves wildcard command tails with a custom spawn', () => {
const readPackage = vi.spyOn(ExpandWildcard, 'readPackage').mockReturnValue({
scripts: { 'build:app': '' },
});
const { commands } = create(['npm run build:* && echo done']);
readPackage.mockRestore();

expect(commands.map((command) => command.command)).toEqual(['npm run build:app && echo done']);
expect(spawn).toHaveBeenCalledWith('npm run build:app && echo done', expect.anything());
});

it('log output is passed to output stream if logger is specified in options', () => {
const logger = new Logger({ hide: [] });
const outputStream = createMockInstance(Writable);
Expand Down
12 changes: 12 additions & 0 deletions lib/spawn.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,18 @@ describe('createSpawn()', () => {
});
});

it('retains the resolved shell when npm configuration changes', () => {
const command = 'echo banana';
const fakeSpawn = vi.fn();
const process = { ...baseProcess, env: { npm_config_script_shell: 'pwsh' } };
const spawn = createSpawn('/bin/bash', fakeSpawn, process);
process.env.npm_config_script_shell = 'cmd.exe';

spawn(command, {});

expect(fakeSpawn).toHaveBeenCalledWith('/bin/bash', ['-c', command], {});
});

describe('getSpawnOpts()', () => {
it('sets detached mode to false for Windows platform', () => {
expect(getSpawnOpts({ process: baseProcess }).detached).toBe(false);
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"shell-quote": "1.9.0",
"supports-color": "10.2.2",
"tree-kill": "1.2.2",
"unbash": "4.0.11",
"yargs": "18.0.0"
},
"devDependencies": {
Expand Down
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading