Skip to content

Commit b229c8c

Browse files
authored
Allow shell overrides (#589)
1 parent 125a567 commit b229c8c

11 files changed

Lines changed: 287 additions & 61 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ Check out documentation and other usage examples in the [`docs` directory](./doc
9797
- `options` (optional): an object containing any of the below:
9898
- `cwd`: the working directory to be used by all commands. Can be overridden per command.
9999
Default: `process.cwd()`.
100+
- `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).
100101
- `defaultInputTarget`: the default input target when reading from `inputStream`.
101102
Default: `0`.
102103
- `handleInput`: when `true`, reads input from `process.stdin`.

bin/index.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,11 @@ const program = yargs(hideBin(process.argv))
9696
type: 'boolean',
9797
default: defaults.timings,
9898
},
99+
shell: {
100+
describe:
101+
'Shell to run commands with. Defaults to cmd.exe on Windows and /bin/sh elsewhere.',
102+
type: 'string',
103+
},
99104
'passthrough-arguments': {
100105
alias: 'P',
101106
describe:
@@ -209,7 +214,20 @@ const program = yargs(hideBin(process.argv))
209214
},
210215
})
211216
.group(
212-
['m', 'n', 'name-separator', 's', 'r', 'no-color', 'hide', 'g', 'timings', 'P', 'teardown'],
217+
[
218+
'm',
219+
'n',
220+
'name-separator',
221+
's',
222+
'r',
223+
'no-color',
224+
'hide',
225+
'g',
226+
'timings',
227+
'shell',
228+
'P',
229+
'teardown',
230+
],
213231
'General',
214232
)
215233
.group(['p', 'c', 'l', 't', 'pad-prefix'], 'Prefix styling')
@@ -265,6 +283,7 @@ concurrently(
265283
successCondition: args.success,
266284
timestampFormat: args.timestampFormat,
267285
timings: args.timings,
286+
shell: args.shell,
268287
teardown: args.teardown,
269288
additionalArguments: args.passthroughArguments ? additionalArguments : undefined,
270289
},

docs/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Concurrently Documentation
22

3+
## General
4+
5+
These articles apply when using either concurrently's CLI or API:
6+
7+
- [Shell Resolution](./shell-resolution.md)
8+
39
## CLI
410

511
These articles cover using concurrently through CLI:

docs/shell-resolution.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Shell Resolution
2+
3+
Each command runs inside a shell, not as a bare executable.
4+
By default, concurrently uses `cmd.exe` on Windows and `/bin/sh` elsewhere.
5+
6+
## Using a different shell
7+
8+
If the default shell isn't suitable, it's possible to instruct concurrently to use a specific shell in a few ways.
9+
10+
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.
11+
12+
### Via explicit override
13+
14+
An explicit shell override takes precedence over every other configuration.
15+
To do that, pass the `--shell` flag to the CLI:
16+
17+
```bash
18+
concurrently --shell "C:\Program Files\Git\bin\bash.exe" "echo Hello world | xargs -n 1 echo"
19+
```
20+
21+
Or via the API:
22+
23+
```js
24+
concurrently(['echo Hello world | xargs -n 1 echo'], {
25+
shell: 'C:\\Program Files\\Git\\bin\\bash.exe',
26+
});
27+
```
28+
29+
### Via npm/pnpm/yarn v1
30+
31+
When using npm, pnpm or yarn v1 to run concurrently via a `package.json` script, the
32+
[`script-shell` configuration](https://docs.npmjs.com/cli/v6/using-npm/config#script-shell) is inherited and used to spawn commands.
33+
34+
```bash
35+
npm config set script-shell /bin/bash
36+
npm dev # Runs the dev script on bash. Concurrently will also run commands using bash.
37+
```
38+
39+
## Supported shells
40+
41+
If you've specified a different shell, concurrently detects its kind and spawns commands
42+
using the right syntax for that shell.
43+
44+
The following shell types are supported:
45+
46+
- Windows `cmd.exe`
47+
- Powershell
48+
- Any POSIX compliant shells (bash, zsh, dash, etc)

lib/concurrently.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@ import { FlowController } from './flow-control/flow-controller.js';
2323
import { Logger } from './logger.js';
2424
import { OutputWriter } from './output-writer.js';
2525
import { PrefixColorSelector } from './prefix-color-selector.js';
26-
import { getSpawnOpts, spawn } from './spawn.js';
26+
import { createSpawn, getSpawnOpts } from './spawn.js';
2727
import { castArray } from './utils.js';
2828

2929
const defaults: ConcurrentlyOptions = {
30-
spawn,
30+
spawn: createSpawn(),
3131
kill: treeKill,
3232
raw: false,
3333
controllers: [],

lib/flow-control/teardown.spec.ts

Lines changed: 22 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,28 @@
11
import { ChildProcess } from 'node:child_process';
22

3-
import { afterEach, describe, expect, it, Mock, vi } from 'vitest';
3+
import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
44

55
import { createMockInstance } from '../__fixtures__/create-mock-instance.js';
66
import { createFakeProcess, FakeCommand } from '../__fixtures__/fake-command.js';
77
import { SpawnCommand } from '../command.js';
88
import { Logger } from '../logger.js';
9-
import * as spawn from '../spawn.js';
9+
import { getSpawnOpts } from '../spawn.js';
1010
import { Teardown } from './teardown.js';
1111

12-
const spySpawn = vi
13-
.spyOn(spawn, 'spawn')
14-
.mockImplementation(() => createFakeProcess(1) as ChildProcess) as Mock;
12+
let spawn: Mock<SpawnCommand>;
1513
const logger = createMockInstance(Logger);
1614
const commands = [new FakeCommand()];
1715
const teardown = 'cowsay bye';
1816

17+
beforeEach(() => {
18+
spawn = vi.fn(() => createFakeProcess(1) as ChildProcess);
19+
});
20+
1921
afterEach(() => {
2022
vi.clearAllMocks();
2123
});
2224

23-
const create = (teardown: string[], spawn?: SpawnCommand) =>
25+
const create = (teardown: string[]) =>
2426
new Teardown({
2527
spawn,
2628
logger,
@@ -35,23 +37,17 @@ it('returns commands unchanged', () => {
3537
describe('onFinish callback', () => {
3638
it('does not spawn nothing if there are no teardown commands', () => {
3739
create([]).handle(commands).onFinish();
38-
expect(spySpawn).not.toHaveBeenCalled();
40+
expect(spawn).not.toHaveBeenCalled();
3941
});
4042

4143
it('runs teardown command', () => {
4244
create([teardown]).handle(commands).onFinish();
43-
expect(spySpawn).toHaveBeenCalledWith(teardown, spawn.getSpawnOpts({ stdio: 'raw' }));
44-
});
45-
46-
it('runs teardown command with custom spawn function', () => {
47-
const customSpawn = vi.fn(() => createFakeProcess(1));
48-
create([teardown], customSpawn).handle(commands).onFinish();
49-
expect(customSpawn).toHaveBeenCalledWith(teardown, spawn.getSpawnOpts({ stdio: 'raw' }));
45+
expect(spawn).toHaveBeenCalledWith(teardown, getSpawnOpts({ stdio: 'raw' }));
5046
});
5147

5248
it('waits for teardown command to close', async () => {
5349
const child = createFakeProcess(1);
54-
spySpawn.mockReturnValue(child);
50+
spawn.mockReturnValue(child);
5551

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

6157
it('rejects if teardown command errors (string)', async () => {
6258
const child = createFakeProcess(1);
63-
spySpawn.mockReturnValue(child);
59+
spawn.mockReturnValue(child);
6460

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

7268
it('rejects if teardown command errors (error)', async () => {
7369
const child = createFakeProcess(1);
74-
spySpawn.mockReturnValue(child);
70+
spawn.mockReturnValue(child);
7571

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

8581
it('rejects if teardown command errors (error, no stack)', async () => {
8682
const child = createFakeProcess(1);
87-
spySpawn.mockReturnValue(child);
83+
spawn.mockReturnValue(child);
8884

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

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

104-
expect(spySpawn).toHaveBeenCalledTimes(1);
105-
expect(spySpawn).toHaveBeenLastCalledWith('foo', spawn.getSpawnOpts({ stdio: 'raw' }));
100+
expect(spawn).toHaveBeenCalledTimes(1);
101+
expect(spawn).toHaveBeenLastCalledWith('foo', getSpawnOpts({ stdio: 'raw' }));
106102

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

110-
expect(spySpawn).toHaveBeenCalledTimes(2);
111-
expect(spySpawn).toHaveBeenLastCalledWith('bar', spawn.getSpawnOpts({ stdio: 'raw' }));
106+
expect(spawn).toHaveBeenCalledTimes(2);
107+
expect(spawn).toHaveBeenLastCalledWith('bar', getSpawnOpts({ stdio: 'raw' }));
112108

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

117113
it('stops running teardown commands on SIGINT', async () => {
118114
const child = createFakeProcess(1);
119-
spySpawn.mockReturnValue(child);
115+
spawn.mockReturnValue(child);
120116

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

125-
expect(spySpawn).toHaveBeenCalledTimes(1);
126-
expect(spySpawn).toHaveBeenLastCalledWith('foo', expect.anything());
121+
expect(spawn).toHaveBeenCalledTimes(1);
122+
expect(spawn).toHaveBeenLastCalledWith('foo', expect.anything());
127123
});
128124
});

lib/flow-control/teardown.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import Rx from 'rxjs';
22

33
import { Command, SpawnCommand } from '../command.js';
44
import { Logger } from '../logger.js';
5-
import { getSpawnOpts, spawn as baseSpawn } from '../spawn.js';
5+
import { getSpawnOpts } from '../spawn.js';
66
import { FlowController } from './flow-controller.js';
77

88
export class Teardown implements FlowController {
@@ -18,13 +18,12 @@ export class Teardown implements FlowController {
1818
logger: Logger;
1919
/**
2020
* Which function to use to spawn commands.
21-
* Defaults to the same used by the rest of concurrently.
2221
*/
23-
spawn?: SpawnCommand;
22+
spawn: SpawnCommand;
2423
commands: readonly string[];
2524
}) {
2625
this.logger = logger;
27-
this.spawn = spawn || baseSpawn;
26+
this.spawn = spawn;
2827
this.teardown = commands;
2928
}
3029

lib/index.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,13 @@ import { OutputErrorHandler } from './flow-control/output-error-handler.js';
2222
import { RestartDelay, RestartProcess } from './flow-control/restart-process.js';
2323
import { Teardown } from './flow-control/teardown.js';
2424
import { Logger } from './logger.js';
25+
import { createSpawn } from './spawn.js';
2526
import { castArray } from './utils.js';
2627

27-
export type ConcurrentlyOptions = Omit<BaseConcurrentlyOptions, 'abortSignal' | 'hide'> & {
28+
export type ConcurrentlyOptions = Omit<
29+
BaseConcurrentlyOptions,
30+
'abortSignal' | 'hide' | 'spawn'
31+
> & {
2832
// Logger options
2933
/**
3034
* Which command(s) should have their output hidden.
@@ -120,6 +124,13 @@ export type ConcurrentlyOptions = Omit<BaseConcurrentlyOptions, 'abortSignal' |
120124
* If not defined, no argument replacing will happen.
121125
*/
122126
additionalArguments?: string[];
127+
128+
/**
129+
* Shell executable used to run command strings.
130+
* When unset, uses the `npm_config_script_shell` env variable if present. Otherwise, falls back
131+
* to `cmd.exe` on Windows, and `/bin/sh` elsewhere.
132+
*/
133+
shell?: string;
123134
};
124135

125136
export function concurrently(
@@ -157,11 +168,13 @@ export function concurrently(
157168
const abortController = new AbortController();
158169
const outputStream = options.outputStream || process.stdout;
159170

171+
const spawn = createSpawn(options.shell);
160172
return createConcurrently(commands, {
161173
maxProcesses: options.maxProcesses,
162174
raw: options.raw,
163175
successCondition: options.successCondition,
164176
cwd: options.cwd,
177+
spawn,
165178
hide,
166179
logger,
167180
outputStream,
@@ -198,7 +211,7 @@ export function concurrently(
198211
logger: options.timings ? logger : undefined,
199212
timestampFormat: options.timestampFormat,
200213
}),
201-
new Teardown({ logger, spawn: options.spawn, commands: options.teardown || [] }),
214+
new Teardown({ logger, spawn, commands: options.teardown || [] }),
202215
],
203216
prefixColors: options.prefixColors || [],
204217
additionalArguments: options.additionalArguments,

0 commit comments

Comments
 (0)