Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ Check out documentation and other usage examples in the [`docs` directory](./doc
- `options` (optional): an object containing any of the below:
- `cwd`: the working directory to be used by all commands. Can be overridden per command.
Default: `process.cwd()`.
- `shell`: shell executable used to run command strings. When unset, uses `npm_config_script_shell` if present (for example when run via `npm run`), otherwise `cmd.exe` on Windows or `/bin/sh` elsewhere. See [shell resolution](./docs/shell-resolution.md).
- `defaultInputTarget`: the default input target when reading from `inputStream`.
Default: `0`.
- `handleInput`: when `true`, reads input from `process.stdin`.
Expand Down
21 changes: 20 additions & 1 deletion bin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ const program = yargs(hideBin(process.argv))
type: 'boolean',
default: defaults.timings,
},
shell: {
describe:
'Shell to run commands with. Defaults to cmd.exe on Windows and /bin/sh elsewhere.',
type: 'string',
},
'passthrough-arguments': {
alias: 'P',
describe:
Expand Down Expand Up @@ -209,7 +214,20 @@ const program = yargs(hideBin(process.argv))
},
})
.group(
['m', 'n', 'name-separator', 's', 'r', 'no-color', 'hide', 'g', 'timings', 'P', 'teardown'],
[
'm',
'n',
'name-separator',
's',
'r',
'no-color',
'hide',
'g',
'timings',
'shell',
'P',
'teardown',
],
'General',
)
.group(['p', 'c', 'l', 't', 'pad-prefix'], 'Prefix styling')
Expand Down Expand Up @@ -265,6 +283,7 @@ concurrently(
successCondition: args.success,
timestampFormat: args.timestampFormat,
timings: args.timings,
shell: args.shell,
teardown: args.teardown,
additionalArguments: args.passthroughArguments ? additionalArguments : undefined,
},
Expand Down
6 changes: 6 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Concurrently Documentation

## General

These articles apply when using either concurrently's CLI or API:

- [Shell Resolution](./shell-resolution.md)

## CLI

These articles cover using concurrently through CLI:
Expand Down
48 changes: 48 additions & 0 deletions docs/shell-resolution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Shell Resolution

Each command runs inside a shell, not as a bare executable.
By default, concurrently uses `cmd.exe` on Windows and `/bin/sh` elsewhere.

## Using a different shell

If the default shell isn't suitable, it's possible to instruct concurrently to use a specific shell in a few ways.

This is useful, for example, to use Unix-style syntax (for example `BROWSER=none npm start`) on Windows, if you set concurrently shell to e.g. Git Bash.

### Via explicit override

An explicit shell override takes precedence over every other configuration.
To do that, pass the `--shell` flag to the CLI:

```bash
concurrently --shell "C:\Program Files\Git\bin\bash.exe" "echo Hello world | xargs -n 1 echo"
```

Or via the API:

```js
concurrently(['echo Hello world | xargs -n 1 echo'], {
shell: 'C:\\Program Files\\Git\\bin\\bash.exe',
});
```

### Via npm/pnpm/yarn v1

When using npm, pnpm or yarn v1 to run concurrently via a `package.json` script, the
[`script-shell` configuration](https://docs.npmjs.com/cli/v6/using-npm/config#script-shell) is inherited and used to spawn commands.

```bash
npm config set script-shell /bin/bash
npm dev # Runs the dev script on bash. Concurrently will also run commands using bash.
```

## Supported shells

If you've specified a different shell, concurrently detects its kind and spawns commands
using the right syntax for that shell.

The following shell types are supported:

- Windows `cmd.exe`
- Powershell
- Any POSIX compliant shells (bash, zsh, dash, etc)
4 changes: 2 additions & 2 deletions lib/concurrently.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ 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 { getSpawnOpts, spawn } from './spawn.js';
import { createSpawn, getSpawnOpts } from './spawn.js';
import { castArray } from './utils.js';

const defaults: ConcurrentlyOptions = {
spawn,
spawn: createSpawn(),
kill: treeKill,
raw: false,
controllers: [],
Expand Down
48 changes: 22 additions & 26 deletions lib/flow-control/teardown.spec.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,28 @@
import { ChildProcess } from 'node:child_process';

import { afterEach, describe, expect, it, Mock, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';

import { createMockInstance } from '../__fixtures__/create-mock-instance.js';
import { createFakeProcess, FakeCommand } from '../__fixtures__/fake-command.js';
import { SpawnCommand } from '../command.js';
import { Logger } from '../logger.js';
import * as spawn from '../spawn.js';
import { getSpawnOpts } from '../spawn.js';
import { Teardown } from './teardown.js';

const spySpawn = vi
.spyOn(spawn, 'spawn')
.mockImplementation(() => createFakeProcess(1) as ChildProcess) as Mock;
let spawn: Mock<SpawnCommand>;
const logger = createMockInstance(Logger);
const commands = [new FakeCommand()];
const teardown = 'cowsay bye';

beforeEach(() => {
spawn = vi.fn(() => createFakeProcess(1) as ChildProcess);
});

afterEach(() => {
vi.clearAllMocks();
});

const create = (teardown: string[], spawn?: SpawnCommand) =>
const create = (teardown: string[]) =>
new Teardown({
spawn,
logger,
Expand All @@ -35,23 +37,17 @@ it('returns commands unchanged', () => {
describe('onFinish callback', () => {
it('does not spawn nothing if there are no teardown commands', () => {
create([]).handle(commands).onFinish();
expect(spySpawn).not.toHaveBeenCalled();
expect(spawn).not.toHaveBeenCalled();
});

it('runs teardown command', () => {
create([teardown]).handle(commands).onFinish();
expect(spySpawn).toHaveBeenCalledWith(teardown, spawn.getSpawnOpts({ stdio: 'raw' }));
});

it('runs teardown command with custom spawn function', () => {
const customSpawn = vi.fn(() => createFakeProcess(1));
create([teardown], customSpawn).handle(commands).onFinish();
expect(customSpawn).toHaveBeenCalledWith(teardown, spawn.getSpawnOpts({ stdio: 'raw' }));
expect(spawn).toHaveBeenCalledWith(teardown, getSpawnOpts({ stdio: 'raw' }));
});

it('waits for teardown command to close', async () => {
const child = createFakeProcess(1);
spySpawn.mockReturnValue(child);
spawn.mockReturnValue(child);

const result = create([teardown]).handle(commands).onFinish();
child.emit('close', 1, null);
Expand All @@ -60,7 +56,7 @@ describe('onFinish callback', () => {

it('rejects if teardown command errors (string)', async () => {
const child = createFakeProcess(1);
spySpawn.mockReturnValue(child);
spawn.mockReturnValue(child);

const result = create([teardown]).handle(commands).onFinish();
const error = 'fail';
Expand All @@ -71,7 +67,7 @@ describe('onFinish callback', () => {

it('rejects if teardown command errors (error)', async () => {
const child = createFakeProcess(1);
spySpawn.mockReturnValue(child);
spawn.mockReturnValue(child);

const result = create([teardown]).handle(commands).onFinish();
const error = new Error('fail');
Expand All @@ -84,7 +80,7 @@ describe('onFinish callback', () => {

it('rejects if teardown command errors (error, no stack)', async () => {
const child = createFakeProcess(1);
spySpawn.mockReturnValue(child);
spawn.mockReturnValue(child);

const result = create([teardown]).handle(commands).onFinish();
const error = new Error('fail');
Expand All @@ -97,32 +93,32 @@ describe('onFinish callback', () => {
it('runs multiple teardown commands in sequence', async () => {
const child1 = createFakeProcess(1);
const child2 = createFakeProcess(2);
spySpawn.mockReturnValueOnce(child1).mockReturnValueOnce(child2);
spawn.mockReturnValueOnce(child1).mockReturnValueOnce(child2);

const result = create(['foo', 'bar']).handle(commands).onFinish();

expect(spySpawn).toHaveBeenCalledTimes(1);
expect(spySpawn).toHaveBeenLastCalledWith('foo', spawn.getSpawnOpts({ stdio: 'raw' }));
expect(spawn).toHaveBeenCalledTimes(1);
expect(spawn).toHaveBeenLastCalledWith('foo', getSpawnOpts({ stdio: 'raw' }));

child1.emit('close', 1, null);
await new Promise((resolve) => setTimeout(resolve));

expect(spySpawn).toHaveBeenCalledTimes(2);
expect(spySpawn).toHaveBeenLastCalledWith('bar', spawn.getSpawnOpts({ stdio: 'raw' }));
expect(spawn).toHaveBeenCalledTimes(2);
expect(spawn).toHaveBeenLastCalledWith('bar', getSpawnOpts({ stdio: 'raw' }));

child2.emit('close', 0, null);
await expect(result).resolves.toBeUndefined();
});

it('stops running teardown commands on SIGINT', async () => {
const child = createFakeProcess(1);
spySpawn.mockReturnValue(child);
spawn.mockReturnValue(child);

const result = create(['foo', 'bar']).handle(commands).onFinish();
child.emit('close', null, 'SIGINT');
await result;

expect(spySpawn).toHaveBeenCalledTimes(1);
expect(spySpawn).toHaveBeenLastCalledWith('foo', expect.anything());
expect(spawn).toHaveBeenCalledTimes(1);
expect(spawn).toHaveBeenLastCalledWith('foo', expect.anything());
});
});
7 changes: 3 additions & 4 deletions lib/flow-control/teardown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Rx from 'rxjs';

import { Command, SpawnCommand } from '../command.js';
import { Logger } from '../logger.js';
import { getSpawnOpts, spawn as baseSpawn } from '../spawn.js';
import { getSpawnOpts } from '../spawn.js';
import { FlowController } from './flow-controller.js';

export class Teardown implements FlowController {
Expand All @@ -18,13 +18,12 @@ export class Teardown implements FlowController {
logger: Logger;
/**
* Which function to use to spawn commands.
* Defaults to the same used by the rest of concurrently.
*/
spawn?: SpawnCommand;
spawn: SpawnCommand;
commands: readonly string[];
}) {
this.logger = logger;
this.spawn = spawn || baseSpawn;
this.spawn = spawn;
this.teardown = commands;
}

Expand Down
17 changes: 15 additions & 2 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,13 @@ 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 { castArray } from './utils.js';

export type ConcurrentlyOptions = Omit<BaseConcurrentlyOptions, 'abortSignal' | 'hide'> & {
export type ConcurrentlyOptions = Omit<
BaseConcurrentlyOptions,
'abortSignal' | 'hide' | 'spawn'
> & {
// Logger options
/**
* Which command(s) should have their output hidden.
Expand Down Expand Up @@ -120,6 +124,13 @@ export type ConcurrentlyOptions = Omit<BaseConcurrentlyOptions, 'abortSignal' |
* If not defined, no argument replacing will happen.
*/
additionalArguments?: string[];

/**
* Shell executable used to run command strings.
* When unset, uses the `npm_config_script_shell` env variable if present. Otherwise, falls back
* to `cmd.exe` on Windows, and `/bin/sh` elsewhere.
*/
shell?: string;
};

export function concurrently(
Expand Down Expand Up @@ -157,11 +168,13 @@ export function concurrently(
const abortController = new AbortController();
const outputStream = options.outputStream || process.stdout;

const spawn = createSpawn(options.shell);
return createConcurrently(commands, {
maxProcesses: options.maxProcesses,
raw: options.raw,
successCondition: options.successCondition,
cwd: options.cwd,
spawn,
hide,
logger,
outputStream,
Expand Down Expand Up @@ -198,7 +211,7 @@ export function concurrently(
logger: options.timings ? logger : undefined,
timestampFormat: options.timestampFormat,
}),
new Teardown({ logger, spawn: options.spawn, commands: options.teardown || [] }),
new Teardown({ logger, spawn, commands: options.teardown || [] }),
],
prefixColors: options.prefixColors || [],
additionalArguments: options.additionalArguments,
Expand Down
Loading
Loading