diff --git a/README.md b/README.md index 6ce460ca..385fd7f5 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/bin/index.ts b/bin/index.ts index 19ba5d28..a71c39b3 100755 --- a/bin/index.ts +++ b/bin/index.ts @@ -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: @@ -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') @@ -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, }, diff --git a/docs/README.md b/docs/README.md index a52e7813..426b48dc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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: diff --git a/docs/shell-resolution.md b/docs/shell-resolution.md new file mode 100644 index 00000000..01e998e3 --- /dev/null +++ b/docs/shell-resolution.md @@ -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) diff --git a/lib/concurrently.ts b/lib/concurrently.ts index c7b3ee38..973bc276 100644 --- a/lib/concurrently.ts +++ b/lib/concurrently.ts @@ -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: [], diff --git a/lib/flow-control/teardown.spec.ts b/lib/flow-control/teardown.spec.ts index a55c9321..47d5a1cd 100644 --- a/lib/flow-control/teardown.spec.ts +++ b/lib/flow-control/teardown.spec.ts @@ -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; 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, @@ -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); @@ -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'; @@ -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'); @@ -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'); @@ -97,18 +93,18 @@ 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(); @@ -116,13 +112,13 @@ describe('onFinish callback', () => { 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()); }); }); diff --git a/lib/flow-control/teardown.ts b/lib/flow-control/teardown.ts index ede50ebf..afa016b5 100644 --- a/lib/flow-control/teardown.ts +++ b/lib/flow-control/teardown.ts @@ -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 { @@ -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; } diff --git a/lib/index.ts b/lib/index.ts index c3f8545f..cfbc1a3c 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -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 & { +export type ConcurrentlyOptions = Omit< + BaseConcurrentlyOptions, + 'abortSignal' | 'hide' | 'spawn' +> & { // Logger options /** * Which command(s) should have their output hidden. @@ -120,6 +124,13 @@ export type ConcurrentlyOptions = Omit { - it('spawns the given command', async () => { - const fakeSpawn = vi.fn(); - spawn('echo banana', {}, fakeSpawn, baseProcess); - expect(fakeSpawn).toHaveBeenCalled(); - expect(fakeSpawn.mock.calls[0][1].join(' ')).toContain('echo banana'); +describe('createSpawn()', () => { + const command = 'echo banana'; + const makeShellArgs = (kind: ShellKind) => { + switch (kind) { + case 'cmd': + return ['/s', '/c', `"${command}"`]; + case 'posix': + return ['-c', command]; + case 'powershell': + return ['-NoProfile', '-Command', command]; + default: + throw new UnreachableError(kind); + } + }; + + describe('when shell is not provided', () => { + it('uses npm_config_script_shell when set', () => { + const fakeSpawn = vi.fn(); + const spawn = createSpawn(undefined, fakeSpawn, { + ...baseProcess, + env: { npm_config_script_shell: 'C:\\Git\\bin\\bash.exe' }, + }); + spawn(command, {}); + expect(fakeSpawn).toHaveBeenCalledWith('C:\\Git\\bin\\bash.exe', ['-c', command], {}); + }); + + it('creates spawn function that uses cmd.exe on Windows', () => { + const fakeSpawn = vi.fn(); + const spawn = createSpawn(undefined, fakeSpawn, { ...baseProcess, platform: 'win32' }); + spawn(command, {}); + expect(fakeSpawn).toHaveBeenCalledWith('cmd.exe', ['/s', '/c', `"${command}"`], { + windowsVerbatimArguments: true, + }); + }); + + it('creates spawn function that uses /bin/sh on non-Windows platforms', () => { + const fakeSpawn = vi.fn(); + const spawn = createSpawn(undefined, fakeSpawn, { ...baseProcess, platform: 'linux' }); + spawn(command, {}); + expect(fakeSpawn).toHaveBeenCalledWith( + '/bin/sh', + ['-c', command], + expect.objectContaining({}), + ); + }); }); - it('returns spawned process', async () => { - const childProcess = {}; - const fakeSpawn = vi.fn().mockReturnValue(childProcess); - const child = spawn('echo banana', {}, fakeSpawn, baseProcess); - expect(child).toBe(childProcess); + describe.each([ + { style: 'cmd', file: 'cmd.exe' }, + { style: 'posix', file: 'C:\\bash.exe' }, + { style: 'powershell', file: 'pwsh' }, + { style: 'posix', file: '/bin/sh' }, + { style: 'posix', file: '/bin/zsh' }, + ] as const)('when shell is set to $file', ({ style, file }) => { + it(`creates spawn function that uses ${file} as shell with ${style} style arguments`, () => { + const fakeSpawn = vi.fn(); + const spawn = createSpawn(file, fakeSpawn, baseProcess); + spawn(command, {}); + expect(fakeSpawn).toHaveBeenCalledWith( + file, + makeShellArgs(style), + expect.objectContaining({}), + ); + }); }); }); diff --git a/lib/spawn.ts b/lib/spawn.ts index 00b82a4e..257c28fd 100644 --- a/lib/spawn.ts +++ b/lib/spawn.ts @@ -1,28 +1,111 @@ import assert from 'node:assert'; import { ChildProcess, IOType, spawn as baseSpawn, SpawnOptions } from 'node:child_process'; +import path from 'node:path'; import nodeProcess from 'node:process'; import supportsColor, { ColorSupport } from 'supports-color'; +import { SpawnCommand } from './command.js'; +import { UnreachableError } from './utils.js'; + /** - * Spawns a command using `cmd.exe` on Windows, or `/bin/sh` elsewhere. + * Creates a spawn function that uses the given shell executable. + * + * The shell is resolved in the following priority order: + * 1. explicit shell option + * 2. `npm_config_script_shell` env variable + * 3. platform default (`cmd.exe` on Windows, `/bin/sh` elsewhere) + * + * @see https://docs.npmjs.com/cli/v6/using-npm/config#script-shell */ -// Implementation based off of https://github.com/mmalecki/spawn-command/blob/v0.0.2-1/lib/spawn-command.js -export function spawn( - command: string, - options: SpawnOptions, +export function createSpawn( + shell?: string, // For testing spawn: (command: string, args: string[], options: SpawnOptions) => ChildProcess = baseSpawn, - process: Pick = nodeProcess, -): ChildProcess { - let file = '/bin/sh'; - let args = ['-c', command]; - if (process.platform === 'win32') { - file = 'cmd.exe'; - args = ['/s', '/c', `"${command}"`]; - options.windowsVerbatimArguments = true; + process: Pick = nodeProcess, +): SpawnCommand { + const resolved = resolveShell(shell, process); + return (command, spawnOpts) => { + const { file, args, shellOptions } = getShellSpawnArgs(resolved, command); + return spawn(file, args, { ...spawnOpts, ...shellOptions }); + }; +} + +const NPM_SCRIPT_SHELL_ENV = 'npm_config_script_shell'; + +/** + * Resolves which shell executable to use when spawning commands. + * @see {@link createSpawn()} + */ +function resolveShell( + shell?: string, + process: Pick = nodeProcess, +): string { + if (shell) { + return shell; + } + + const npmScriptShell = process.env[NPM_SCRIPT_SHELL_ENV]; + if (npmScriptShell) { + return npmScriptShell; + } + + return process.platform === 'win32' ? 'cmd.exe' : '/bin/sh'; +} + +/** + * Builds spawn file/args for the given shell and command string. + */ +function getShellSpawnArgs( + shellPath: string, + command: string, +): { + file: string; + args: string[]; + shellOptions?: Pick; +} { + const kind = detectShellKind(shellPath); + switch (kind) { + case 'cmd': + return { + file: shellPath, + args: ['/s', '/c', `"${command}"`], + shellOptions: { windowsVerbatimArguments: true }, + }; + case 'powershell': + return { + file: shellPath, + args: ['-NoProfile', '-Command', command], + }; + case 'posix': + return { + file: shellPath, + args: ['-c', command], + }; + default: + throw new UnreachableError(kind); + } +} + +export type ShellKind = 'cmd' | 'posix' | 'powershell'; + +/** + * Detects which argument style to use when spawning the given shell executable. + */ +function detectShellKind(shellPath: string): ShellKind { + const normalized = shellPath.replace(/\\/g, '/'); + const base = path + .basename(normalized) + .toLowerCase() + .replace(/\.exe$/i, ''); + + if (base === 'cmd') { + return 'cmd'; + } + if (base === 'powershell' || base === 'pwsh') { + return 'powershell'; } - return spawn(file, args, options); + return 'posix'; } export const getSpawnOpts = ({ diff --git a/lib/utils.ts b/lib/utils.ts index 068b0b64..076159b4 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -41,3 +41,12 @@ export function splitOutsideParens(input: string, delimiter: string): string[] { if (trimmed) segments.push(trimmed); return segments; } + +/** + * Error thrown when a condition is reached that should be impossible. + */ +export class UnreachableError extends Error { + constructor(value: never) { + super(`Unreachable condition: ${value}`); + } +}