Showing preview only (356K chars total). Download the full file or copy to clipboard to get everything.
Repository: open-cli-tools/concurrently
Branch: main
Commit: 008b526de07d
Files: 111
Total size: 330.1 KB
Directory structure:
gitextract_injgqv22/
├── .editorconfig
├── .gitattributes
├── .github/
│ ├── FUNDING.yml
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug-report.yml
│ │ ├── config.yml
│ │ └── feature-request.yml
│ ├── actions/
│ │ └── setup/
│ │ └── action.yml
│ └── workflows/
│ ├── ci.yml
│ └── release.yml
├── .gitignore
├── .husky/
│ └── pre-commit
├── .node-version
├── .prettierignore
├── .prettierrc.json
├── .vscode/
│ ├── extensions.json
│ └── settings.json
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── bin/
│ ├── __fixtures__/
│ │ ├── read-echo.js
│ │ └── sleep.js
│ ├── index.spec.ts
│ ├── index.ts
│ └── read-package-json.ts
├── docs/
│ ├── README.md
│ └── cli/
│ ├── configuration.md
│ ├── input-handling.md
│ ├── output-control.md
│ ├── passthrough-arguments.md
│ ├── prefixing.md
│ ├── restarting.md
│ ├── shortcuts.md
│ ├── success.md
│ └── terminating.md
├── eslint.config.js
├── lib/
│ ├── __fixtures__/
│ │ ├── create-mock-instance.ts
│ │ └── fake-command.ts
│ ├── assert.spec.ts
│ ├── assert.ts
│ ├── command-parser/
│ │ ├── command-parser.d.ts
│ │ ├── expand-arguments.spec.ts
│ │ ├── expand-arguments.ts
│ │ ├── expand-shortcut.spec.ts
│ │ ├── expand-shortcut.ts
│ │ ├── expand-wildcard.spec.ts
│ │ ├── expand-wildcard.ts
│ │ ├── strip-quotes.spec.ts
│ │ └── strip-quotes.ts
│ ├── command.spec.ts
│ ├── command.ts
│ ├── completion-listener.spec.ts
│ ├── completion-listener.ts
│ ├── concurrently.spec.ts
│ ├── concurrently.ts
│ ├── date-format.spec.ts
│ ├── date-format.ts
│ ├── declarations/
│ │ └── intl.d.ts
│ ├── defaults.ts
│ ├── flow-control/
│ │ ├── flow-controller.d.ts
│ │ ├── input-handler.spec.ts
│ │ ├── input-handler.ts
│ │ ├── kill-on-signal.spec.ts
│ │ ├── kill-on-signal.ts
│ │ ├── kill-others.spec.ts
│ │ ├── kill-others.ts
│ │ ├── log-error.spec.ts
│ │ ├── log-error.ts
│ │ ├── log-exit.spec.ts
│ │ ├── log-exit.ts
│ │ ├── log-output.spec.ts
│ │ ├── log-output.ts
│ │ ├── log-timings.spec.ts
│ │ ├── log-timings.ts
│ │ ├── logger-padding.spec.ts
│ │ ├── logger-padding.ts
│ │ ├── output-error-handler.spec.ts
│ │ ├── output-error-handler.ts
│ │ ├── restart-process.spec.ts
│ │ ├── restart-process.ts
│ │ ├── teardown.spec.ts
│ │ └── teardown.ts
│ ├── index.ts
│ ├── jsonc.spec.ts
│ ├── jsonc.ts
│ ├── logger.spec.ts
│ ├── logger.ts
│ ├── observables.spec.ts
│ ├── observables.ts
│ ├── output-writer.spec.ts
│ ├── output-writer.ts
│ ├── prefix-color-selector.spec.ts
│ ├── prefix-color-selector.ts
│ ├── spawn.spec.ts
│ ├── spawn.ts
│ ├── utils.spec.ts
│ └── utils.ts
├── package.json
├── pnpm-workspace.yaml
├── tests/
│ ├── cjs-import/
│ │ ├── package.json
│ │ ├── smoke-test.ts
│ │ └── tsconfig.json
│ ├── cjs-require/
│ │ ├── package.json
│ │ ├── smoke-test.ts
│ │ └── tsconfig.json
│ ├── esm/
│ │ ├── package.json
│ │ ├── smoke-test.ts
│ │ └── tsconfig.json
│ ├── package.json
│ └── smoke-tests.spec.ts
├── tsconfig.json
└── vitest.config.ts
================================================
FILE CONTENTS
================================================
================================================
FILE: .editorconfig
================================================
# EditorConfig is awesome: https://editorconfig.org
root = true
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
indent_style = space
trim_trailing_whitespace = true
[*.{js,ts}]
indent_size = 4
max_line_length = 100
[*.md]
trim_trailing_whitespace = false
================================================
FILE: .gitattributes
================================================
* text=auto eol=lf
================================================
FILE: .github/FUNDING.yml
================================================
# Thank you! <3
github: [gustavohenke, paescuj]
================================================
FILE: .github/ISSUE_TEMPLATE/bug-report.yml
================================================
name: Bug Report
description: File a bug report
body:
- type: textarea
attributes:
label: Describe the bug
value: |
<!--
>> Before getting started, please ensure there's no existing issue with the same concern!
Please provide the following information:
- Description: Clear and concise description of what the bug is
- Expected Behavior: What you expected to happen
- Environment: At least the OS, Node.js, and concurrently's versions
- Reproduction: A way to reproduce the issue
Our time to work on this project is limited.
With an accurate report you're helping us to understand and solve the request faster.
Please understand that reports that do not meet the requirements may be closed without further notice.
Thank you!
-->
validations:
required: true
================================================
FILE: .github/ISSUE_TEMPLATE/config.yml
================================================
blank_issues_enabled: false
================================================
FILE: .github/ISSUE_TEMPLATE/feature-request.yml
================================================
name: Feature Request
description: Open a feature request
body:
- type: textarea
attributes:
label: Describe the feature request
value: |
<!--
>> Before getting started, please ensure there's no existing issue with the same concern!
Please provide the following information:
- Description: Clear and concise description of what you want
- Use Case: Why is this needed and what is a use case for it
Our time to work on this project is limited.
With an accurate report you're helping us to understand and solve the request faster.
Please understand that reports that do not meet the requirements may be closed without further notice.
Thank you!
-->
validations:
required: true
================================================
FILE: .github/actions/setup/action.yml
================================================
name: Setup
description: Setup the environment for the project
inputs:
node-version:
description: Node.js version
required: false
node-registry:
description: Node.js package registry to set up for auth
required: false
runs:
using: composite
steps:
- name: Install Node.js
uses: actions/setup-node@v5
with:
node-version-file: ${{ !inputs.node-version && '.node-version' || '' }}
node-version: ${{ inputs.node-version }}
registry-url: ${{ inputs.node-registry }}
package-manager-cache: false
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
id: pnpm-cache-dir
shell: bash
run: echo "dir=$(pnpm store path)" >> $GITHUB_OUTPUT
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache-dir.outputs.dir }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
shell: bash
run: pnpm install
================================================
FILE: .github/workflows/ci.yml
================================================
name: CI
on:
push:
branches:
- main
pull_request:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Setup
uses: ./.github/actions/setup
- name: Lint
run: pnpm run lint
- name: Format
run: pnpm run format
- name: Typecheck
run: pnpm run typecheck
test:
name: Test (Node.js ${{ matrix.node }}, ${{ matrix.os.name }})
runs-on: ${{ matrix.os.version }}
strategy:
fail-fast: false
matrix:
node:
- 20
- 22
- 24
os:
- name: Ubuntu
version: ubuntu-latest
- name: Windows
version: windows-latest
- name: macOS
version: macOS-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Setup
uses: ./.github/actions/setup
with:
node-version: ${{ matrix.node }}
- name: Test
run: pnpm exec vitest --coverage
- name: Submit coverage
uses: coverallsapp/github-action@master
continue-on-error: true
with:
github-token: ${{ secrets.github_token }}
flag-name: Node.js ${{ matrix.node }} on ${{ matrix.os.name }}
parallel: true
coverage:
name: Coverage
needs: test
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Finish coverage
uses: coverallsapp/github-action@master
with:
github-token: ${{ secrets.github_token }}
parallel-finished: true
================================================
FILE: .github/workflows/release.yml
================================================
name: Release
on:
push:
tags:
- 'v*'
jobs:
gh-release:
name: Create GitHub Release
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Create release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release create "$GITHUB_REF_NAME" --generate-notes
publish-npm:
name: Publish to NPM
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Setup
uses: ./.github/actions/setup
with:
node-registry: https://registry.npmjs.org
- name: Publish to NPM
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_PROVENANCE: true
run: pnpm publish --no-git-checks
================================================
FILE: .gitignore
================================================
# Outputs
dist
# Logs
*.log
# Coverage directory used by tools like istanbul
coverage
# Dependency directory
node_modules
# OS X
.DS_Store
================================================
FILE: .husky/pre-commit
================================================
./node_modules/.bin/lint-staged
================================================
FILE: .node-version
================================================
22
================================================
FILE: .prettierignore
================================================
dist
coverage
pnpm-lock.yaml
================================================
FILE: .prettierrc.json
================================================
{
"singleQuote": true
}
================================================
FILE: .vscode/extensions.json
================================================
{
"recommendations": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"editorconfig.editorconfig",
"vitest.explorer"
]
}
================================================
FILE: .vscode/settings.json
================================================
{
// For contributors with the Jest extension installed,
// it might incorrectly report errors in the suite
"jest.enable": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"[javascript, typescript]": {
"editor.formatOnSave": false
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"editor.rulers": [100]
}
================================================
FILE: CONTRIBUTING.md
================================================
# Contributing
Pull requests and contributions are warmly welcome.
Please follow existing code style and commit message conventions. Also remember to keep documentation
updated.
**Pull requests:** You don't need to bump version numbers or modify anything related to releasing. That stuff is fully automated, just write the functionality.
# Maintaining
## Code Format & Linting
Code format and lint checks are performed locally when committing to ensure the changes align with the configured rules of this repository. This happens with the help of the tools [simple-git-hooks](https://github.com/toplenboren/simple-git-hooks) and [lint-staged](https://github.com/okonet/lint-staged) which are automatically installed and configured on `pnpm install` (no further steps required).
In case of problems, a corresponding message is displayed in your terminal.
Please fix them and then run the commit command again.
## Test
Tests can be executed with the following command:
```bash
pnpm test
```
================================================
FILE: LICENSE
================================================
The MIT License (MIT)
Copyright (c) 2015 Kimmo Brunfeldt
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# concurrently
[](https://github.com/open-cli-tools/concurrently/releases)
[](https://github.com/open-cli-tools/concurrently/blob/main/LICENSE)
[](https://www.npmjs.com/package/concurrently)
[](https://github.com/open-cli-tools/concurrently/actions/workflows/test.yml)
[](https://coveralls.io/github/open-cli-tools/concurrently?branch=main)
Run multiple commands concurrently.
Like `npm run watch-js & npm run watch-less` but better.

**Table of Contents**
- [concurrently](#concurrently)
- [Why](#why)
- [Installation](#installation)
- [Usage](#usage)
- [API](#api)
- [`concurrently(commands[, options])`](#concurrentlycommands-options)
- [`Command`](#command)
- [`CloseEvent`](#closeevent)
- [FAQ](#faq)
## Why
I like [task automation with npm](https://web.archive.org/web/20220531064025/https://github.com/substack/blog/blob/master/npm_run.markdown)
but the usual way to run multiple commands concurrently is
`npm run watch-js & npm run watch-css`. That's fine but it's hard to keep
on track of different outputs. Also if one process fails, others still keep running
and you won't even notice the difference.
Another option would be to just run all commands in separate terminals. I got
tired of opening terminals and made **concurrently**.
**Features:**
- Cross platform (including Windows)
- Output is easy to follow with prefixes
- With `--kill-others` switch, all commands are killed if one dies
## Installation
**concurrently** can be installed in the global scope (if you'd like to have it available and use it on the whole system) or locally for a specific package (for example if you'd like to use it in the `scripts` section of your package):
| | npm | Yarn | pnpm | Bun |
| ----------- | ----------------------- | ------------------------------ | -------------------------- | ------------------------- |
| **Global** | `npm i -g concurrently` | `yarn global add concurrently` | `pnpm add -g concurrently` | `bun add -g concurrently` |
| **Local**\* | `npm i -D concurrently` | `yarn add -D concurrently` | `pnpm add -D concurrently` | `bun add -d concurrently` |
<sub>\* It's recommended to add **concurrently** to `devDependencies` as it's usually used for developing purposes. Please adjust the command if this doesn't apply in your case.</sub>
## Usage
> **Note**
> The `concurrently` command is also available under the shorthand alias `conc`.
The tool is written in Node.js, but you can use it to run **any** commands.
Remember to surround separate commands with quotes:
```bash
concurrently 'command1 arg' 'command2 arg'
```
Otherwise **concurrently** would try to run 4 separate commands:
`command1`, `arg`, `command2`, `arg`.
> [!IMPORTANT]
> Windows only supports double quotes:
>
> ```bash
> concurrently "command1 arg" "command2 arg"
> ```
>
> Remember to escape the double quotes in your package.json when using Windows:
>
> ```json
> "start": "concurrently \"command1 arg\" \"command2 arg\""
> ```
You can always check concurrently's flag list by running `concurrently --help`.
For the version, run `concurrently --version`.
Check out documentation and other usage examples in the [`docs` directory](./docs/README.md).
## API
**concurrently** can be used programmatically by using the API documented below:
### `concurrently(commands[, options])`
- `commands`: an array of either strings (containing the commands to run) or objects
with the shape `{ command, name, prefixColor, env, cwd, ipc }`.
- `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()`.
- `defaultInputTarget`: the default input target when reading from `inputStream`.
Default: `0`.
- `handleInput`: when `true`, reads input from `process.stdin`.
- `inputStream`: a [`Readable` stream](https://nodejs.org/dist/latest-v10.x/docs/api/stream.html#stream_readable_streams)
to read the input from. Should only be used in the rare instance you would like to stream anything other than `process.stdin`. Overrides `handleInput`.
- `pauseInputStreamOnFinish`: by default, pauses the input stream (`process.stdin` when `handleInput` is enabled, or `inputStream` if provided) when all of the processes have finished. If you need to read from the input stream after `concurrently` has finished, set this to `false`. ([#252](https://github.com/kimmobrunfeldt/concurrently/issues/252)).
- `killOthersOn`: once the first command exits with one of these statuses, kill other commands.
Can be an array containing the strings `success` (status code zero) and/or `failure` (non-zero exit status).
- `maxProcesses`: how many processes should run at once.
- `outputStream`: a [`Writable` stream](https://nodejs.org/dist/latest-v10.x/docs/api/stream.html#stream_writable_streams)
to write logs to. Default: `process.stdout`.
- `prefix`: the prefix type to use when logging processes output.
Possible values: `index`, `pid`, `time`, `command`, `name`, `none`, or a template (eg `[{time} process: {pid}]`).
Default: the name of the process, or its index if no name is set.
- `prefixColors`: a list of colors or a string as supported by [Chalk](https://www.npmjs.com/package/chalk) and additional style `auto` for an automatically picked color.
If concurrently would run more commands than there are colors, the last color is repeated, unless if the last color value is `auto` which means following colors are automatically picked to vary.
Prefix colors specified per-command take precedence over this list.
- `prefixLength`: how many characters to show when prefixing with `command`. Default: `10`
- `raw`: whether raw mode should be used, meaning strictly process output will
be logged, without any prefixes, coloring or extra stuff. Can be overridden per command.
- `successCondition`: the condition to consider the run was successful.
If `first`, only the first process to exit will make up the success of the run; if `last`, the last process that exits will determine whether the run succeeds.
Anything else means all processes should exit successfully.
- `restartTries`: how many attempts to restart a process that dies will be made. Default: `0`.
- `restartDelay`: how many milliseconds to wait between process restarts. Default: `0`.
- `timestampFormat`: a [Unicode format](https://www.unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table)
to use when prefixing with `time`. Default: `yyyy-MM-dd HH:mm:ss.SSS`
- `additionalArguments`: list of additional arguments passed that will get replaced in each command. If not defined, no argument replacing will happen.
> **Returns:** an object in the shape `{ result, commands }`.
>
> - `result`: a `Promise` that resolves if the run was successful (according to `successCondition` option),
> or rejects, containing an array of [`CloseEvent`](#CloseEvent), in the order that the commands terminated.
> - `commands`: an array of all spawned [`Command`s](#Command).
Example:
```js
const concurrently = require('concurrently');
const { result } = concurrently(
[
'npm:watch-*',
{ command: 'nodemon', name: 'server' },
{ command: 'deploy', name: 'deploy', env: { PUBLIC_KEY: '...' } },
{
command: 'watch',
name: 'watch',
cwd: path.resolve(__dirname, 'scripts/watchers'),
},
],
{
prefix: 'name',
killOthersOn: ['failure', 'success'],
restartTries: 3,
cwd: path.resolve(__dirname, 'scripts'),
},
);
result.then(success, failure);
```
### `Command`
An object that contains all information about a spawned command, and ways to interact with it.<br>
It has the following properties:
- `index`: the index of the command among all commands spawned.
- `command`: the command line of the command.
- `name`: the name of the command; defaults to an empty string.
- `cwd`: the current working directory of the command.
- `env`: an object with all the environment variables that the command will be spawned with.
- `killed`: whether the command has been killed.
- `state`: the command's state. Can be one of
- `stopped`: if the command was never started
- `started`: if the command is currently running
- `errored`: if the command failed spawning
- `exited`: if the command is not running anymore, e.g. it received a close event
- `pid`: the command's process ID.
- `stdin`: a Writable stream to the command's `stdin`.
- `stdout`: an RxJS observable to the command's `stdout`.
- `stderr`: an RxJS observable to the command's `stderr`.
- `error`: an RxJS observable to the command's error events (e.g. when it fails to spawn).
- `timer`: an RxJS observable to the command's timing events (e.g. starting, stopping).
- `stateChange`: an RxJS observable for changes to the command's `state` property.
- `messages`: an object with the following properties:
- `incoming`: an RxJS observable for the IPC messages received from the underlying process.
- `outgoing`: an RxJS observable for the IPC messages sent to the underlying process.
Both observables emit [`MessageEvent`](#messageevent)s.<br>
Note that if the command wasn't spawned with IPC support, these won't emit any values.
- `close`: an RxJS observable to the command's close events.
See [`CloseEvent`](#CloseEvent) for more information.
- `start()`: starts the command and sets up all of the above streams
- `send(message[, handle, options])`: sends a message to the underlying process via IPC channels,
returning a promise that resolves once the message has been sent.
See [Node.js docs](https://nodejs.org/docs/latest/api/child_process.html#subprocesssendmessage-sendhandle-options-callback).
- `kill([signal])`: kills the command, optionally specifying a signal (e.g. `SIGTERM`, `SIGKILL`, etc).
### `MessageEvent`
An object that represents a message that was received from/sent to the underlying command process.<br>
It has the following properties:
- `message`: the message itself.
- `handle`: a [`net.Socket`](https://nodejs.org/docs/latest/api/net.html#class-netsocket),
[`net.Server`](https://nodejs.org/docs/latest/api/net.html#class-netserver) or
[`dgram.Socket`](https://nodejs.org/docs/latest/api/dgram.html#class-dgramsocket),
if one was sent, or `undefined`.
### `CloseEvent`
An object with information about a command's closing event.<br>
It contains the following properties:
- `command`: a stripped down version of [`Command`](#command), including only `name`, `command`, `env` and `cwd` properties.
- `index`: the index of the command among all commands spawned.
- `killed`: whether the command exited because it was killed.
- `exitCode`: the exit code of the command's process, or the signal which it was killed with.
- `timings`: an object in the shape `{ startDate, endDate, durationSeconds }`.
## FAQ
- Process exited with code _null_?
From [Node child_process documentation](http://nodejs.org/api/child_process.html#child_process_event_exit), `exit` event:
> This event is emitted after the child process ends. If the process
> terminated normally, code is the final exit code of the process,
> otherwise null. If the process terminated due to receipt of a signal,
> signal is the string name of the signal, otherwise null.
So _null_ means the process didn't terminate normally. This will make **concurrently**
to return non-zero exit code too.
- Does this work with the npm-replacements [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), or [Bun](https://bun.sh/)?
Yes! In all examples above, you may replace "`npm`" with "`yarn`", "`pnpm`", or "`bun`".
================================================
FILE: bin/__fixtures__/read-echo.js
================================================
import process from 'node:process';
process.stdin.on('data', (chunk) => {
const line = chunk.toString().trim();
console.log(line);
if (line === 'stop') {
process.exit(0);
}
});
console.log('READING');
================================================
FILE: bin/__fixtures__/sleep.js
================================================
/*
* Platform independent implementation of 'sleep' used as a command in tests
*
* (Windows doesn't provide the 'sleep' command by default,
* see https://github.com/open-cli-tools/concurrently/issues/277)
*/
import process from 'node:process';
const seconds = +process.argv[2];
if (!seconds || Number.isNaN(seconds) || process.argv.length > 3) {
// Mimic behavior from native 'sleep' command
console.error('usage: sleep seconds');
process.exit(1);
}
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
================================================
FILE: bin/index.spec.ts
================================================
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import readline from 'node:readline';
import { subscribeSpyTo } from '@hirez_io/observer-spy';
import { sendCtrlC, spawnWithWrapper } from 'ctrlc-wrapper';
import { build } from 'esbuild';
import Rx from 'rxjs';
import { map } from 'rxjs/operators';
import stringArgv from 'string-argv';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { escapeRegExp } from '../lib/utils.js';
const isWindows = process.platform === 'win32';
const createKillMessage = (prefix: string, signal: 'SIGTERM' | 'SIGINT' | string) => {
const map: Record<string, string | number> = {
SIGTERM: isWindows ? 1 : '(SIGTERM|143)',
// Could theoretically be anything (e.g. 0) if process has SIGINT handler
SIGINT: isWindows ? '(3221225786|0)' : '(SIGINT|130|0)',
};
return new RegExp(`${escapeRegExp(prefix)} exited with code ${map[signal] ?? signal}`);
};
let tmpDir: string;
beforeAll(async () => {
// Build 'concurrently' and store it in a temporary directory
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'concurrently-'));
await build({
entryPoints: [path.join(__dirname, 'index.ts')],
platform: 'node',
bundle: true,
// it doesn't seem like esbuild is able to change a CJS module to ESM, so target CJS instead.
// https://github.com/evanw/esbuild/issues/1921
format: 'cjs',
outfile: path.join(tmpDir, 'concurrently.cjs'),
});
fs.copyFileSync(path.join(__dirname, '..', 'package.json'), path.join(tmpDir, 'package.json'));
}, 8000);
afterAll(() => {
// Remove the temporary directory where 'concurrently' was stored
if (tmpDir) {
fs.rmSync(tmpDir, { recursive: true });
}
});
/**
* Creates a child process running 'concurrently' with the given args.
* Returns observables for its combined stdout + stderr output, close events, pid, and stdin stream.
*/
const run = (args: string, ctrlcWrapper?: boolean) => {
const spawnFn = ctrlcWrapper ? spawnWithWrapper : spawn;
const child = spawnFn('node', [path.join(tmpDir, 'concurrently.cjs'), ...stringArgv(args)], {
cwd: __dirname,
env: {
...process.env,
},
});
const stdout = readline.createInterface({
input: child.stdout,
});
const stderr = readline.createInterface({
input: child.stderr,
});
const log = new Rx.Observable<string>((observer) => {
stdout.on('line', (line) => {
observer.next(line);
});
stderr.on('line', (line) => {
observer.next(line);
});
child.on('close', () => {
observer.complete();
});
});
const exit = Rx.firstValueFrom(
Rx.fromEvent(child, 'exit').pipe(
map((event) => {
const exit = event as [number | null, NodeJS.Signals | null];
return {
/** The exit code if the child exited on its own. */
code: exit[0],
/** The signal by which the child process was terminated. */
signal: exit[1],
};
}),
),
);
const getLogLines = async (): Promise<string[]> => {
const observerSpy = subscribeSpyTo(log);
await observerSpy.onComplete();
observerSpy.unsubscribe();
return observerSpy.getValues();
};
return {
process: child,
stdin: child.stdin,
pid: child.pid,
log,
getLogLines,
exit,
};
};
it('has help command', async () => {
const exit = await run('--help').exit;
expect(exit.code).toBe(0);
});
it('prints help when no arguments are passed', async () => {
const exit = await run('').exit;
expect(exit.code).toBe(0);
});
describe('has version command', () => {
const pkg = fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8');
const { version } = JSON.parse(pkg);
it.each(['--version', '-V', '-v'])('%s', async (arg) => {
const child = run(arg);
const log = await child.getLogLines();
expect(log).toContain(version);
const { code } = await child.exit;
expect(code).toBe(0);
});
});
describe('exiting conditions', () => {
it('is of success by default when running successful commands', async () => {
const exit = await run('"echo foo" "echo bar"').exit;
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;
expect(exit.code).toBeGreaterThan(0);
});
it('is of success when --success=first and first command to exit succeeds', async () => {
const exit = await run(
'--success=first "echo foo" "node __fixtures__/sleep.js 0.5 && exit 1"',
).exit;
expect(exit.code).toBe(0);
});
it('is of failure when --success=first and first command to exit fails', async () => {
const exit = await run(
'--success=first "exit 1" "node __fixtures__/sleep.js 0.5 && echo foo"',
).exit;
expect(exit.code).toBeGreaterThan(0);
});
describe('is of success when --success=last and last command to exit succeeds', () => {
it.each(['--success=last', '-s last'])('%s', async (arg) => {
const exit = await run(`${arg} "exit 1" "node __fixtures__/sleep.js 0.5 && echo foo"`)
.exit;
expect(exit.code).toBe(0);
});
});
it('is of failure when --success=last and last command to exit fails', async () => {
const exit = await run(
'--success=last "echo foo" "node __fixtures__/sleep.js 0.5 && exit 1"',
).exit;
expect(exit.code).toBeGreaterThan(0);
});
it('is of success when a SIGINT is sent', async () => {
// Windows doesn't support sending signals like on POSIX platforms.
// However, in a console, processes can be interrupted with CTRL+C (like a SIGINT).
// This is what we simulate here with the help of a wrapper application.
const child = run('"node __fixtures__/read-echo.js"', isWindows);
// Wait for command to have started before sending SIGINT
child.log.subscribe((line) => {
if (/READING/.test(line)) {
if (isWindows) {
// Instruct the wrapper to send CTRL+C to its child
sendCtrlC(child.process);
} else {
process.kill(Number(child.pid), 'SIGINT');
}
}
});
const lines = await child.getLogLines();
const exit = await child.exit;
expect(exit.code).toBe(0);
expect(lines).toContainEqual(
expect.stringMatching(
createKillMessage(
'[0] node __fixtures__/read-echo.js',
// TODO: Flappy value due to race condition, sometimes killed by concurrently (exit code 1),
// sometimes terminated on its own (exit code 0).
// Related issue: https://github.com/open-cli-tools/concurrently/issues/283
isWindows ? '(3221225786|0|1)' : 'SIGINT',
),
),
);
});
});
describe('does not log any extra output', () => {
it.each(['--raw', '-r'])('%s', async (arg) => {
const lines = await run(`${arg} "echo foo" "echo bar"`).getLogLines();
expect(lines).toHaveLength(2);
expect(lines).toContainEqual(expect.stringContaining('foo'));
expect(lines).toContainEqual(expect.stringContaining('bar'));
});
});
describe('--hide', () => {
it('hides the output of a process by its index', async () => {
const lines = await run('--hide 1 "echo foo" "echo bar"').getLogLines();
expect(lines).toContainEqual(expect.stringContaining('foo'));
expect(lines).not.toContainEqual(expect.stringContaining('bar'));
});
it('hides the output of a process by its name', async () => {
const lines = await run('-n foo,bar --hide bar "echo foo" "echo bar"').getLogLines();
expect(lines).toContainEqual(expect.stringContaining('foo'));
expect(lines).not.toContainEqual(expect.stringContaining('bar'));
});
it('hides the output of a process by its index in raw mode', async () => {
const lines = await run('--hide 1 --raw "echo foo" "echo bar"').getLogLines();
expect(lines).toHaveLength(1);
expect(lines).toContainEqual(expect.stringContaining('foo'));
expect(lines).not.toContainEqual(expect.stringContaining('bar'));
});
it('hides the output of a process by its name in raw mode', async () => {
const lines = await run('-n foo,bar --hide bar --raw "echo foo" "echo bar"').getLogLines();
expect(lines).toHaveLength(1);
expect(lines).toContainEqual(expect.stringContaining('foo'));
expect(lines).not.toContainEqual(expect.stringContaining('bar'));
});
});
describe('--group', () => {
it('groups output per process', async () => {
const lines = await run(
'--group "echo foo && node __fixtures__/sleep.js 1 && echo bar" "echo baz"',
).getLogLines();
expect(lines.slice(0, 4)).toEqual([
expect.stringContaining('foo'),
expect.stringContaining('bar'),
expect.any(String),
expect.stringContaining('baz'),
]);
});
});
describe('--names', () => {
describe('prefixes with names', () => {
it.each(['--names', '-n'])('%s', async (arg) => {
const lines = await run(`${arg} foo,bar "echo foo" "echo bar"`).getLogLines();
expect(lines).toContainEqual(expect.stringContaining('[foo] foo'));
expect(lines).toContainEqual(expect.stringContaining('[bar] bar'));
});
});
it('is split using --name-separator arg', async () => {
const lines = await run(
'--names "foo|bar" --name-separator "|" "echo foo" "echo bar"',
).getLogLines();
expect(lines).toContainEqual(expect.stringContaining('[foo] foo'));
expect(lines).toContainEqual(expect.stringContaining('[bar] bar'));
});
});
describe('specifies custom prefix', () => {
it.each(['--prefix', '-p'])('%s', async (arg) => {
const lines = await run(`${arg} command "echo foo" "echo bar"`).getLogLines();
expect(lines).toContainEqual(expect.stringContaining('[echo foo] foo'));
expect(lines).toContainEqual(expect.stringContaining('[echo bar] bar'));
});
});
describe('specifies custom prefix length', () => {
it.each(['--prefix command --prefix-length 5', '-p command -l 5'])('%s', async (arg) => {
const lines = await run(`${arg} "echo foo" "echo bar"`).getLogLines();
expect(lines).toContainEqual(expect.stringContaining('[ec..o] foo'));
expect(lines).toContainEqual(expect.stringContaining('[ec..r] bar'));
});
});
describe('--pad-prefix', () => {
it('pads prefixes with spaces', async () => {
const lines = await run('--pad-prefix -n foo,barbaz "echo foo" "echo bar"').getLogLines();
expect(lines).toContainEqual(expect.stringContaining('[foo ]'));
expect(lines).toContainEqual(expect.stringContaining('[barbaz]'));
});
});
describe('--restart-tries', () => {
it('changes how many times a command will restart', async () => {
const lines = await run('--restart-tries 1 "exit 1"').getLogLines();
expect(lines).toEqual([
expect.stringContaining('[0] exit 1 exited with code 1'),
expect.stringContaining('[0] exit 1 restarted'),
expect.stringContaining('[0] exit 1 exited with code 1'),
]);
});
});
describe('--kill-others', () => {
describe('kills on success', () => {
it.each(['--kill-others', '-k'])('%s', async (arg) => {
const lines = await run(
`${arg} "node __fixtures__/sleep.js 10" "exit 0"`,
).getLogLines();
expect(lines).toContainEqual(expect.stringContaining('[1] exit 0 exited with code 0'));
expect(lines).toContainEqual(
expect.stringContaining('Sending SIGTERM to other processes'),
);
expect(lines).toContainEqual(
expect.stringMatching(
createKillMessage('[0] node __fixtures__/sleep.js 10', 'SIGTERM'),
),
);
});
});
it('kills on failure', async () => {
const lines = await run(
'--kill-others "node __fixtures__/sleep.js 10" "exit 1"',
).getLogLines();
expect(lines).toContainEqual(expect.stringContaining('[1] exit 1 exited with code 1'));
expect(lines).toContainEqual(expect.stringContaining('Sending SIGTERM to other processes'));
expect(lines).toContainEqual(
expect.stringMatching(
createKillMessage('[0] node __fixtures__/sleep.js 10', 'SIGTERM'),
),
);
});
});
describe('--kill-others-on-fail', () => {
it('does not kill on success', async () => {
const lines = await run(
'--kill-others-on-fail "node __fixtures__/sleep.js 0.5" "exit 0"',
).getLogLines();
expect(lines).toContainEqual(expect.stringContaining('[1] exit 0 exited with code 0'));
expect(lines).toContainEqual(
expect.stringContaining('[0] node __fixtures__/sleep.js 0.5 exited with code 0'),
);
});
it('kills on failure', async () => {
const lines = await run(
'--kill-others-on-fail "node __fixtures__/sleep.js 10" "exit 1"',
).getLogLines();
expect(lines).toContainEqual(expect.stringContaining('[1] exit 1 exited with code 1'));
expect(lines).toContainEqual(expect.stringContaining('Sending SIGTERM to other processes'));
expect(lines).toContainEqual(
expect.stringMatching(
createKillMessage('[0] node __fixtures__/sleep.js 10', 'SIGTERM'),
),
);
});
});
describe('--handle-input', () => {
describe('forwards input to first process by default', () => {
it.each(['--handle-input', '-i'])('%s', async (arg) => {
const child = run(`${arg} "node __fixtures__/read-echo.js"`);
child.log.subscribe((line) => {
if (/READING/.test(line)) {
child.stdin.write('stop\n');
}
});
const lines = await child.getLogLines();
const exit = await child.exit;
expect(exit.code).toBe(0);
expect(lines).toContainEqual(expect.stringContaining('[0] stop'));
expect(lines).toContainEqual(
expect.stringContaining('[0] node __fixtures__/read-echo.js exited with code 0'),
);
});
});
it('forwards input to process --default-input-target', async () => {
const child = run(
'-ki --default-input-target 1 "node __fixtures__/read-echo.js" "node __fixtures__/read-echo.js"',
);
child.log.subscribe((line) => {
if (/\[1\] READING/.test(line)) {
child.stdin.write('stop\n');
}
});
const lines = await child.getLogLines();
const exit = await child.exit;
expect(exit.code).toBeGreaterThan(0);
expect(lines).toContainEqual(expect.stringContaining('[1] stop'));
expect(lines).toContainEqual(
expect.stringMatching(
createKillMessage('[0] node __fixtures__/read-echo.js', 'SIGTERM'),
),
);
});
it('forwards input to specified process', async () => {
const child = run('-ki "node __fixtures__/read-echo.js" "node __fixtures__/read-echo.js"');
child.log.subscribe((line) => {
if (/\[1\] READING/.test(line)) {
child.stdin.write('1:stop\n');
}
});
const lines = await child.getLogLines();
const exit = await child.exit;
expect(exit.code).toBeGreaterThan(0);
expect(lines).toContainEqual(expect.stringContaining('[1] stop'));
expect(lines).toContainEqual(
expect.stringMatching(
createKillMessage('[0] node __fixtures__/read-echo.js', 'SIGTERM'),
),
);
});
});
describe('--teardown', () => {
it('runs teardown commands when input commands exit', async () => {
const lines = await run('--teardown "echo bye" "echo hey"').getLogLines();
expect(lines).toEqual([
expect.stringContaining('[0] hey'),
expect.stringContaining('[0] echo hey exited with code 0'),
expect.stringContaining('--> Running teardown command "echo bye"'),
expect.stringContaining('bye'),
expect.stringContaining('--> Teardown command "echo bye" exited with code 0'),
]);
});
it('runs multiple teardown commands', async () => {
const lines = await run(
'--teardown "echo bye" --teardown "echo bye2" "echo hey"',
).getLogLines();
expect(lines).toContain('bye');
expect(lines).toContain('bye2');
});
});
describe('--timings', () => {
const defaultTimestampFormatRegex = /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}.\d{3}/;
const tableTopBorderRegex = /^--> ┌[─┬]+┐$/;
const tableHeaderRowRegex = /^--> │ name +│ duration +│ exit code +│ killed +│ command +│$/;
const tableBottomBorderRegex = /^--> └[─┴]+┘$/;
const timingsTests = {
'shows timings on success': ['node __fixtures__/sleep.js 0.5', 'exit 0'],
'shows timings on failure': ['node __fixtures__/sleep.js 0.75', 'exit 1'],
};
it.each(Object.entries(timingsTests))('%s', async (_, commands) => {
const lines = await run(
`--timings ${commands.map((command) => `"${command}"`).join(' ')}`,
).getLogLines();
// Expect output to contain process start / stop messages for each command
commands.forEach((command, index) => {
const escapedCommand = escapeRegExp(command);
expect(lines).toContainEqual(
expect.stringMatching(
new RegExp(
`^\\[${index}] ${escapedCommand} started at ${defaultTimestampFormatRegex.source}$`,
),
),
);
expect(lines).toContainEqual(
expect.stringMatching(
new RegExp(
`^\\[${index}] ${escapedCommand} stopped at ${defaultTimestampFormatRegex.source} after (\\d|,)+ms$`,
),
),
);
});
// Expect output to contain timings table
expect(lines).toContainEqual(expect.stringMatching(tableTopBorderRegex));
expect(lines).toContainEqual(expect.stringMatching(tableHeaderRowRegex));
expect(lines).toContainEqual(expect.stringMatching(tableBottomBorderRegex));
});
});
describe('--passthrough-arguments', () => {
it('argument placeholders are properly replaced when passthrough-arguments is enabled', async () => {
const lines = await run('--passthrough-arguments "echo {1}" -- echo').getLogLines();
expect(lines).toContainEqual(expect.stringContaining('[0] echo echo exited with code 0'));
});
it('argument placeholders are not replaced when passthrough-arguments is disabled', async () => {
const lines = await run('"echo {1}" -- echo').getLogLines();
expect(lines).toContainEqual(expect.stringContaining('[0] echo {1} exited with code 0'));
expect(lines).toContainEqual(expect.stringContaining('[1] echo exited with code 0'));
});
});
================================================
FILE: bin/index.ts
================================================
#!/usr/bin/env node
import process from 'node:process';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import { assertDeprecated } from '../lib/assert.js';
import * as defaults from '../lib/defaults.js';
import { concurrently } from '../lib/index.js';
import { castArray } from '../lib/utils.js';
import { readPackageJson } from './read-package-json.js';
const version = String(readPackageJson().version);
const epilogue = `For documentation and more examples, visit:\nhttps://github.com/open-cli-tools/concurrently/tree/v${version}/docs`;
// Clean-up arguments (yargs expects only the arguments after the program name)
const program = yargs(hideBin(process.argv))
.parserConfiguration({
// Avoids options that can be specified multiple times from requiring a `--` to pass commands
'greedy-arrays': false,
// Makes sure that --passthrough-arguments works correctly
'populate--': true,
})
.usage('$0 [options] <command ...>')
.help('h')
.alias('h', 'help')
.version(version)
.alias('version', 'v')
.alias('version', 'V')
// TODO: Add some tests for this.
.env('CONCURRENTLY')
.options({
// General
'max-processes': {
alias: 'm',
describe:
'How many processes should run at once.\n' +
'New processes only spawn after all restart tries of a process.\n' +
'Exact number or a percent of CPUs available (for example "50%")',
type: 'string',
},
names: {
alias: 'n',
describe:
'List of custom names to be used in prefix template.\n' +
'Example names: "main,browser,server"',
type: 'string',
},
'name-separator': {
describe:
'The character to split <names> on. Example usage:\n' +
'-n "styles|scripts|server" --name-separator "|"',
default: defaults.nameSeparator,
},
success: {
alias: 's',
describe:
'Which command(s) must exit with code 0 in order for concurrently exit with ' +
'code 0 too. Options are:\n' +
'- "first" for the first command to exit;\n' +
'- "last" for the last command to exit;\n' +
'- "all" for all commands;\n' +
// Note: not a typo. Multiple commands can have the same name.
'- "command-{name}"/"command-{index}" for the commands with that name or index;\n' +
'- "!command-{name}"/"!command-{index}" for all commands but the ones with that ' +
'name or index.\n',
default: defaults.success,
},
raw: {
alias: 'r',
describe:
'Output only raw output of processes, disables prettifying ' +
'and concurrently coloring.',
type: 'boolean',
},
// This one is provided for free. Chalk reads this itself and removes colors.
// https://www.npmjs.com/package/chalk#supportscolor
'no-color': {
describe: 'Disables colors from logging',
type: 'boolean',
},
hide: {
describe:
'Comma-separated list of processes to hide the output.\n' +
'The processes can be identified by their name or index.',
default: defaults.hide,
type: 'string',
},
group: {
alias: 'g',
describe: 'Order the output as if the commands were run sequentially.',
type: 'boolean',
},
timings: {
describe: 'Show timing information for all processes.',
type: 'boolean',
default: defaults.timings,
},
'passthrough-arguments': {
alias: 'P',
describe:
'Passthrough additional arguments to commands (accessible via placeholders) ' +
'instead of treating them as commands.',
type: 'boolean',
default: defaults.passthroughArguments,
},
teardown: {
describe:
'Clean up command(s) to execute before exiting concurrently. Might be specified multiple times.\n' +
"These aren't prefixed and they don't affect concurrently's exit code.",
type: 'string',
array: true,
},
// Kill others
'kill-others': {
alias: 'k',
describe: 'Kill other processes once the first exits.',
type: 'boolean',
},
'kill-others-on-fail': {
describe: 'Kill other processes if one exits with non zero status code.',
type: 'boolean',
},
'kill-signal': {
alias: 'ks',
describe:
'Signal to send to other processes if one exits or dies. (SIGTERM/SIGKILL, defaults to SIGTERM)',
type: 'string',
default: defaults.killSignal,
},
'kill-timeout': {
describe: 'How many milliseconds to wait before forcing process terminating.',
type: 'number',
},
// Prefix
prefix: {
alias: 'p',
describe:
'Prefix used in logging for each process.\n' +
'Possible values: index, pid, time, command, name, none, or a template. ' +
'Example template: "{time}-{pid}"',
defaultDescription: 'index or name (when --names is set)',
type: 'string',
},
'prefix-colors': {
alias: 'c',
describe:
'Comma-separated list of Chalk colors to use on prefixes. ' +
'If there are more commands than colors, the last color will be repeated.\n' +
'- Available modifiers: reset, bold, dim, italic, underline, inverse, hidden, strikethrough\n' +
'- Available colors: black, red, green, yellow, blue, magenta, cyan, white, gray, \n' +
'any hex values for colors (e.g. #23de43) or auto for an automatically picked color\n' +
'- Available background colors: bgBlack, bgRed, bgGreen, bgYellow, bgBlue, bgMagenta, bgCyan, bgWhite\n' +
'See https://www.npmjs.com/package/chalk for more information.',
default: defaults.prefixColors,
type: 'string',
},
'prefix-length': {
alias: 'l',
describe:
'Limit how many characters of the command is displayed in prefix. ' +
'The option can be used to shorten the prefix when it is set to "command"',
default: defaults.prefixLength,
type: 'number',
},
'pad-prefix': {
describe: 'Pads short prefixes with spaces so that the length of all prefixes match',
type: 'boolean',
},
'timestamp-format': {
alias: 't',
describe:
'Specify the timestamp in Unicode format:\n' +
'https://www.unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table',
default: defaults.timestampFormat,
type: 'string',
},
// Restarting
'restart-tries': {
describe:
'How many times a process that died should restart.\n' +
'Negative numbers will make the process restart forever.',
default: defaults.restartTries,
type: 'number',
},
'restart-after': {
describe: 'Delay before restarting the process, in milliseconds, or "exponential".',
default: defaults.restartDelay,
type: 'string',
},
// Input
'handle-input': {
alias: 'i',
describe:
'Whether input should be forwarded to the child processes. ' +
'See examples for more information.',
type: 'boolean',
},
'default-input-target': {
default: defaults.defaultInputTarget,
describe:
'Identifier for child process to which input on stdin ' +
'should be sent if not specified at start of input.\n' +
'Can be either the index or the name of the process.',
},
})
.group(
['m', 'n', 'name-separator', 's', 'r', 'no-color', 'hide', 'g', 'timings', 'P', 'teardown'],
'General',
)
.group(['p', 'c', 'l', 't', 'pad-prefix'], 'Prefix styling')
.group(['i', 'default-input-target'], 'Input handling')
.group(['k', 'kill-others-on-fail', 'kill-signal', 'kill-timeout'], 'Killing other processes')
.group(['restart-tries', 'restart-after'], 'Restarting')
.epilogue(epilogue);
const args = program.parseSync();
assertDeprecated(
args.nameSeparator === defaults.nameSeparator,
'name-separator',
'Use commas as name separators instead.',
);
// Get names of commands by the specified separator
const names = (args.names || '').split(args.nameSeparator);
const additionalArguments = castArray(args['--'] ?? []).map(String);
const commands = args.passthroughArguments ? args._ : args._.concat(additionalArguments);
if (!commands.length) {
program.showHelp();
process.exit();
}
concurrently(
commands.map((command, index) => ({
command: String(command),
name: names[index],
})),
{
handleInput: args.handleInput,
defaultInputTarget: args.defaultInputTarget,
killOthersOn: args.killOthers
? ['success', 'failure']
: args.killOthersOnFail
? ['failure']
: [],
killSignal: args.killSignal,
killTimeout: args.killTimeout,
maxProcesses: args.maxProcesses,
raw: args.raw,
hide: args.hide.split(','),
group: args.group,
prefix: args.prefix,
prefixColors: args.prefixColors.split(','),
prefixLength: args.prefixLength,
padPrefix: args.padPrefix,
restartDelay:
args.restartAfter === 'exponential' ? 'exponential' : Number(args.restartAfter),
restartTries: args.restartTries,
successCondition: args.success,
timestampFormat: args.timestampFormat,
timings: args.timings,
teardown: args.teardown,
additionalArguments: args.passthroughArguments ? additionalArguments : undefined,
},
).result.then(
() => process.exit(0),
() => process.exit(1),
);
================================================
FILE: bin/read-package-json.ts
================================================
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
/**
* Read the package.json file of `concurrently`
*/
export function readPackageJson(): Record<string, unknown> {
let resolver;
try {
resolver = require.resolve;
} catch {
resolver = createRequire(import.meta.url).resolve;
}
const path = resolver('concurrently/package.json');
const content = readFileSync(path, 'utf8');
return JSON.parse(content);
}
================================================
FILE: docs/README.md
================================================
# Concurrently Documentation
## CLI
These articles cover using concurrently through CLI:
- [Prefixing](./cli/prefixing.md)
- [Output Control](./cli/output-control.md)
- [Success Conditions](./cli/success.md)
- [Shortcuts](./cli/shortcuts.md)
- [Terminating Commands](./cli/terminating.md)
- [Restarting Commands](./cli/restarting.md)
- [Input Handling](./cli/input-handling.md)
- [Passthrough Arguments](./cli/passthrough-arguments.md)
- [Configuration](./cli/configuration.md)
================================================
FILE: docs/cli/configuration.md
================================================
# Configuration
You might want to configure concurrently to always have certain flags on.
Any of concurrently's flags can be set via environment variables that are prefixed with `CONCURRENTLY_`.
```bash
$ export CONCURRENTLY_KILL_OTHERS=true
$ export CONCURRENTLY_HANDLE_INPUT=true
# Equivalent to passing --kill-others and --handle-input
$ concurrently nodemon "echo 'hey nodemon, you won't last long'"
```
================================================
FILE: docs/cli/input-handling.md
================================================
# Input Handling
By default, concurrently doesn't send input to any commands it spawns.<br/>
In the below example, typing `rs` to manually restart [nodemon](https://nodemon.io/) does nothing:
```bash
$ concurrently 'nodemon' 'npm run watch-js'
rs
```
To turn on input handling, it's necessary to set the `--handle-input`/`-i` flag.<br/>
This will send `rs` to the first command:
```bash
$ concurrently --handle-input 'nodemon' 'npm run watch-js'
rs
```
To send input to a different command instead, it's possible to prefix the input with the command index, followed by a `:`.<br/>
For example, the below sends `rs` to the second command:
```bash
$ concurrently --handle-input 'npm run watch-js' 'nodemon'
1:rs
```
If the command has a name, it's also possible to target it using that command's name:
```bash
$ concurrently --handle-input --names js,server 'npm run watch-js' 'nodemon'
server:rs
```
It's also possible to change the default command that receives input.<br/>
To do this, set the `--default-input-target` flag to a command's index or name.
```bash
$ concurrently --handle-input --default-input-target 1 'npm run watch-js' 'nodemon'
rs
```
================================================
FILE: docs/cli/output-control.md
================================================
# Output Control
concurrently offers a few ways to control a command's output.
## Hiding
A command's outputs (and all its events) can be hidden by using the `--hide` flag.
```bash
$ concurrently --hide 0 'echo Hello there' 'echo General Kenobi!'
[1] General Kenobi!
[1] echo 'General Kenobi!' exited with code 0
```
## Grouping
It might be useful at times to make sure that the commands outputs are grouped together, while running them in parallel.<br/>
This can be done with the `--group` flag.
```bash
$ concurrently --group 'echo Hello there && sleep 2 && echo General Kenobi!' 'echo hi Star Wars fans'
[0] Hello there
[0] General Kenobi!
[0] echo Hello there && sleep 2 && echo 'General Kenobi!' exited with code 0
[1] hi Star Wars fans
[1] echo hi Star Wars fans exited with code 0
```
## No Colors
When piping concurrently's outputs to another command or file, you might want to force it to not use colors, as these can break the other command's parsing, or reduce the legibility of the output in non-terminal environments.
```bash
$ concurrently -c red,blue --no-color 'echo Hello there' 'echo General Kenobi!'
```
================================================
FILE: docs/cli/passthrough-arguments.md
================================================
# Passthrough Arguments
If you have a shortcut for running a specific combination of commands through concurrently,
you might need at some point to pass additional arguments/flags to some of these.
For example, imagine you have in your `package.json` file scripts like this:
```jsonc
{
// ...
"scripts": {
"build:client": "tsc -p client",
"build:server": "tsc -p server",
"build": "concurrently npm:build:client npm:build:server",
},
}
```
If you wanted to run only either `build:server` or `build:client` with an additional `--noEmit` flag,
you can do so with `npm run build:server -- --noEmit`, for example.<br/>
However, if you want to do that while using concurrently, as `npm run build -- --noEmit` for example,
you might find that concurrently actually parses `--noEmit` as its own flag, which does nothing,
because it doesn't exist.
To solve this, you can set the `--passthrough-arguments`/`-P` flag, which instructs concurrently to
take everything after a `--` as additional arguments that are passed through to the input commands
via a few placeholder styles:
## Single argument
We can modify the original `build` script to pass a single additional argument/flag to a script by using
a 1-indexed `{number}` placeholder to the command you want it to apply to:
```jsonc
{
// ...
"scripts": {
// ...
"build": "concurrently -P 'npm:build:client -- {1}' npm:build:server --",
"typecheck": "npm run build -- --noEmit",
},
}
```
With this, running `npm run typecheck` will pass `--noEmit` only to `npm run build:client`.
## All arguments
In the original `build` example script, you're more likely to want to pass every additional argument/flag
to your commands. This can be done with the `{@}` placeholder.
```jsonc
{
// ...
"scripts": {
// ...
"build": "concurrently -P 'npm:build:client -- {@}' 'npm:build:server -- {@}' --",
"typecheck": "npm run build -- --watch --noEmit",
},
}
```
In the above example, both `--watch` and `--noEmit` are passed to each command.
## All arguments, combined
If for some reason you wish to combine all additional arguments into a single one, you can do that with the `{*}` placeholder,
which wraps the arguments in quotes.
```jsonc
{
// ...
"scripts": {
// ...
"build": "concurrently -P 'npm:build:client -- --outDir {*}/client' 'npm:build:server -- --outDir {*}/server' -- $(date)",
},
}
```
In the above example, the output of the `date` command, which looks like `Sun 1 Sep 2024 23:50:00 AEST` will be passed as a single string to the `--outDir` parameter of both commands.
================================================
FILE: docs/cli/prefixing.md
================================================
# Prefixing
## Prefix Styles
concurrently will by default prefix each command's outputs with a zero-based index, wrapped in square brackets:
```bash
$ concurrently 'echo Hello there' "echo 'General Kenobi!'"
[0] Hello there
[1] General Kenobi!
[0] echo Hello there exited with code 0
[1] echo 'General Kenobi!' exited with code 0
```
If you've given the commands names, they are used instead:
```bash
$ concurrently --names one,two 'echo Hello there' "echo 'General Kenobi!'"
[one] Hello there
[two] General Kenobi!
[one] echo Hello there exited with code 0
[two] echo 'General Kenobi!' exited with code 0
```
There are other prefix styles available too:
| Style | Description |
| --------- | --------------------------------- |
| `index` | Zero-based command's index |
| `name` | The command's name |
| `command` | The command's line |
| `time` | Time of output |
| `pid` | ID of the command's process (PID) |
| `none` | No prefix |
Any of these can be used by setting the `--prefix`/`-p` flag. For example:
```bash
$ concurrently --prefix pid 'echo Hello there' 'echo General Kenobi!'
[2222] Hello there
[2223] General Kenobi!
[2222] echo Hello there exited with code 0
[2223] echo 'General Kenobi!' exited with code 0
```
It's also possible to have a prefix based on a template. Any of the styles listed above can be used by wrapping it in `{}`.
Doing so will also remove the square brackets:
```bash
$ concurrently --prefix '{index}-{pid}' 'echo Hello there' 'echo General Kenobi!'
0-2222 Hello there
1-2223 General Kenobi!
0-2222 echo Hello there exited with code 0
1-2223 echo 'General Kenobi!' exited with code 0
```
## Prefix Colors
By default, there are no colors applied to concurrently prefixes, and they just use whatever the terminal's defaults are.
This can be changed by using the `--prefix-colors`/`-c` flag, which takes a comma-separated list of colors to use.<br/>
The available values are color names (e.g. `green`, `magenta`, `gray`, etc), a hex value (such as `#23de43`), or `auto`, to automatically select a color.
```bash
$ concurrently -c red,blue 'echo Hello there' 'echo General Kenobi!'
```
<details>
<summary>List of available color names</summary>
- `black`
- `blue`
- `cyan`
- `green`
- `gray`
- `magenta`
- `red`
- `white`
- `yellow`
</details>
Colors can take modifiers too. Several can be applied at once by appending `.<modifier 1>.<modifier 2>` and so on.
```bash
$ concurrently -c '#23de43.inverse,bold.blue.dim' 'echo Hello there' 'echo General Kenobi!'
```
<details>
<summary>List of available modifiers</summary>
- `reset`
- `bold`
- `dim`
- `hidden`
- `inverse`
- `italic`
- `strikethrough`
- `underline`
</details>
A background color can be set in a similarly fashion.
```bash
$ concurrently -c bgGray,red.bgBlack 'echo Hello there' 'echo General Kenobi!'
```
<details>
<summary>List of available background color names</summary>
- `bgBlack`
- `bgBlue`
- `bgCyan`
- `bgGreen`
- `bgGray`
- `bgMagenta`
- `bgRed`
- `bgWhite`
- `bgYellow`
</details>
## Prefix Length
When using the `command` prefix style, it's possible that it'll be too long.<br/>
It can be limited by setting the `--prefix-length`/`-l` flag:
```bash
$ concurrently -p command -l 10 'echo Hello there' 'echo General Kenobi!'
[echo..here] Hello there
[echo..bi!'] General Kenobi!
[echo..here] echo Hello there exited with code 0
[echo..bi!'] echo 'General Kenobi!' exited with code 0
```
It's also possible that some prefixes are too short, and you want all of them to have the same length.<br/>
This can be done by setting the `--pad-prefix` flag:
```bash
$ concurrently -n foo,barbaz --pad-prefix 'echo Hello there' 'echo General Kenobi!'
[foo ] Hello there
[foo ] echo Hello there exited with code 0
[barbaz] General Kenobi!
[barbaz] echo 'General Kenobi!' exited with code 0
```
> [!NOTE]
> If using the `pid` prefix style in combination with [`--restart-tries`](./restarting.md), the length of the PID might grow, in which case all subsequent lines will match the new length.<br/>
> This might happen, for example, if the PID was 99 and it's now 100.
================================================
FILE: docs/cli/restarting.md
================================================
# Restarting Commands
Sometimes it's useful to have commands that exited with a non-zero status to restart automatically.<br/>
concurrently lets you configure how many times you wish for such a command to restart through the `--restart-tries` flag:
```bash
$ concurrently --restart-tries 2 'exit 1'
[0] exit 1 exited with code 1
[0] exit 1 restarted
[0] exit 1 exited with code 1
[0] exit 1 restarted
[0] exit 1 exited with code 1
```
Sometimes, it might be interesting to have commands wait before restarting.<br/>
To do this, simply set `--restart-after` to a the number of milliseconds you'd like to delay restarting.
```bash
$ concurrently -p time --restart-tries 1 --restart-after 3000 'exit 1'
[2024-09-01 23:43:55.871] exit 1 exited with code 1
[2024-09-01 23:43:58.874] exit 1 restarted
[2024-09-01 23:43:58.891] exit 1 exited with code 1
```
If a command is not having success spawning, you might want to instead apply an exponential back-off.<br/>
Set `--restart-after exponential` to have commands respawn with a `2^N` seconds delay.
```bash
$ concurrently -p time --restart-tries 3 --restart-after exponential 'exit 1'
[2024-09-01 23:49:01.124] exit 1 exited with code 1
[2024-09-01 23:49:02.127] exit 1 restarted
[2024-09-01 23:49:02.139] exit 1 exited with code 1
[2024-09-01 23:49:04.141] exit 1 restarted
[2024-09-01 23:49:04.157] exit 1 exited with code 1
[2024-09-01 23:49:08.158] exit 1 restarted
[2024-09-01 23:49:08.174] exit 1 exited with code 1
```
================================================
FILE: docs/cli/shortcuts.md
================================================
# Command Shortcuts
Package managers that execute scripts from a `package.json` or `deno.(json|jsonc)` file can be shortened when in concurrently.<br/>
The following are supported:
| Syntax | Expands to |
| --------------- | --------------------- |
| `npm:<script>` | `npm run <script>` |
| `pnpm:<script>` | `pnpm run <script>` |
| `yarn:<script>` | `yarn run <script>` |
| `bun:<script>` | `bun run <script>` |
| `node:<script>` | `node --run <script>` |
| `deno:<script>` | `deno task <script>` |
> [!NOTE]
>
> `node --run` is only available from [Node 22 onwards](https://nodejs.org/en/blog/announcements/v22-release-announce#running-packagejson-scripts).
For example, given the following `package.json` contents:
```jsonc
{
// ...
"scripts": {
"lint:js": "...",
"lint:ts": "...",
"lint:fix:js": "...",
"lint:fix:ts": "...",
// ...
},
// ...
}
```
It's possible to run some of these with the following command line:
```bash
$ concurrently 'pnpm:lint:js'
# Is equivalent to
$ concurrently -n lint:js 'pnpm run lint:js'
```
Note that the command automatically receives a name equal to the script name.
If you have several scripts with similar name patterns, you can use the `*` wildcard to run all of them at once.<br/>
The spawned commands will receive names set to whatever the `*` wildcard matched.
```bash
$ concurrently 'npm:lint:fix:*'
# is equivalent to
$ concurrently -n js,ts 'npm run lint:fix:js' 'npm run lint:fix:ts'
```
If you specify a command name when using wildcards, it'll be a prefix of what the `*` wildcard matched:
```bash
$ concurrently -n fix: 'npm:lint:fix:*'
# is equivalent to
$ concurrently -n fix:js,fix:ts 'npm run lint:fix:js' 'npm run lint:fix:ts'
```
Filtering out commands matched by wildcard is also possible. Do this with by including `(!<some pattern>)` in the command line:
```bash
$ concurrently 'yarn:lint:*(!fix)'
# is equivalent to
$ concurrently -n js,ts 'yarn run lint:js' 'yarn run lint:ts'
```
> [!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/>
> See [here](https://serverfault.com/a/208266/160539) for more information.
================================================
FILE: docs/cli/success.md
================================================
# Success Conditions
When you're using concurrently in shell scripts or CI pipelines, the exit code matters.
It determines whether the next step runs, or if the script stops with a failure.
You can control concurrently's exit code using the `--success` flag.
This tells it **which command(s)** must succeed (exit with code `0`) for concurrently to return success overall.
There are several possible values:
## `all`
All commands must exit with code `0`.
This is the default value.
## `first`
The first command to exit must do so with code `0`.
```bash
# ✅ Exits with code 0 — second command exits first and succeeds
$ concurrently --success first 'sleep 1 && exit 1' 'exit 0'
# ❌ Exits with a non-zero code — second command exits first, but with code 1
$ concurrently --success first 'sleep 1 && exit 0' 'exit 1'
```
## `last`
The last command to exit must do so with code `0`.
```bash
# ✅ Exits with code 0 - first command exits last and succeeds
$ concurrently --success last 'sleep 1 && exit 0' 'exit 1'
# ❌ Exits with a non-zero code — first command exits last, but with code 1
$ concurrently --success last 'sleep 1 && exit 1' 'exit 0'
```
## `command-{name}` or `command-{index}`
A specific command, by name or index, must exit with code `0`.
```bash
# Exits with code 0 only if 'npm test' (index 1) passes.
$ concurrently --success command-1 --kill-others 'npm run server' 'npm test'
# Exits with code 0 only if 'test' command passes.
$ concurrently --success command-test --names server,test --kill-others \
'npm start' \
'npm test'
```
> [!TIP]
> Use `--kill-others` to kill a long-running process, such as a server, once tests pass.
## `!command-{name}` or `!command-{index}`
All but a specific command, by name or index, must exit with code `0`.
```bash
# Ignores 'npm start'; all others must succeed
$ concurrently --success '!command-2' --kill-others \
'npm test' \
'npm build' \
'npm start'
# Ignores 'server'; all others must succeed
$ concurrently --success '!command-server' --names test,build,server --kill-others \
'npm test' \
'npm build' \
'npm start'
```
================================================
FILE: docs/cli/terminating.md
================================================
# Terminating Commands
It's possible to have concurrently terminate other commands when one of them exits.<br/>
This can be done in the following ways:
## Terminating on either success or error
By using the `--kill-others` flag, concurrently will terminate other commands once the first one exits,
no matter the exit code.<br/>
This is useful to terminate the server process once the test is done.
```bash
$ concurrently --kill-others --names server,test 'npm start' 'npm test'
```
## Terminating on error only
By using the `--kill-others-on-fail` flag, concurrently will terminate other commands any command
exits with a non-zero code.<br/>
This is useful if you're building multiple applications, and you want to abort the others once you know
that any of them is broken.
```bash
$ concurrently --kill-others-on-fail 'npm run app1:build' 'npm run app2:build'
```
## Configuring termination
### Kill Signal
It's possible to configure which signal you want to send when terminating commands with the `--kill-signal` flag.
The default is `SIGTERM`, but it's also possible to send `SIGKILL`.
```bash
$ concurrently --kill-others --kill-signal SIGKILL 'npm start' 'npm test'
```
### Timeout
In case you have a misbehaving process that ignores the kill signal, you can force kill it after some
timeout (in milliseconds) by using the `--kill-timeout` flag.
This sends a `SIGKILL`, which cannot be caught.
```bash
$ concurrently --kill-others --kill-timeout 1000 'sleep 1 && echo bye' './misbehaving'
[0] bye
[0] sleep 1 && echo bye exited with code 0
--> Sending SIGTERM to other processes..
[1] IGNORING SIGNAL
--> Sending SIGKILL to 1 processes..
[1] ./misbehaving exited with code SIGKILL
```
================================================
FILE: eslint.config.js
================================================
// @ts-check
import eslint from '@eslint/js';
import pluginVitest from '@vitest/eslint-plugin';
import { defineConfig } from 'eslint/config';
import gitignore from 'eslint-config-flat-gitignore';
import pluginImportLite from 'eslint-plugin-import-lite';
import pluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import pluginSimpleImportSort from 'eslint-plugin-simple-import-sort';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default defineConfig(
gitignore(),
{
languageOptions: {
globals: {
...globals.node,
},
ecmaVersion: 2023,
},
},
eslint.configs.recommended,
tseslint.configs.recommended,
{
rules: {
curly: 'error',
eqeqeq: ['error', 'always', { null: 'ignore' }],
'no-var': 'error',
'no-console': 'error',
'prefer-const': 'error',
'prefer-object-spread': 'error',
'@typescript-eslint/no-unused-vars': [
'error',
{
varsIgnorePattern: '^_',
},
],
},
},
{ files: ['**/__fixtures__/**/*.{js,ts}'], rules: { 'no-console': 'off' } },
{
plugins: {
'simple-import-sort': pluginSimpleImportSort,
import: pluginImportLite,
},
rules: {
'simple-import-sort/imports': 'error',
'simple-import-sort/exports': 'error',
'import/first': 'error',
'import/newline-after-import': 'error',
'import/no-duplicates': 'error',
},
},
{
files: ['**/*.spec.ts'],
plugins: {
vitest: pluginVitest,
},
rules: {
...pluginVitest.configs.recommended.rules,
// Currently produces false positives, see https://github.com/vitest-dev/eslint-plugin-vitest/issues/775
'vitest/prefer-called-exactly-once-with': 'off',
},
},
pluginPrettierRecommended,
);
================================================
FILE: lib/__fixtures__/create-mock-instance.ts
================================================
import { MockedObject, vi } from 'vitest';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function createMockInstance<T>(constructor: new (...args: any[]) => T): MockedObject<T> {
return new (vi.mockObject(constructor))() as MockedObject<T>;
}
================================================
FILE: lib/__fixtures__/fake-command.ts
================================================
import EventEmitter from 'node:events';
import { PassThrough, Writable } from 'node:stream';
import { vi } from 'vitest';
import { ChildProcess, CloseEvent, Command, CommandInfo } from '../command.js';
import { createMockInstance } from './create-mock-instance.js';
export class FakeCommand extends Command {
constructor(name = 'foo', command = 'echo foo', index = 0, info?: Partial<CommandInfo>) {
super(
{
index,
name,
command,
...info,
},
{},
vi.fn(),
vi.fn(),
);
this.stdin = createMockInstance(Writable);
this.start = vi.fn();
this.kill = vi.fn();
}
}
export const createFakeProcess = (pid: number): ChildProcess =>
Object.assign(new EventEmitter(), {
pid,
send: vi.fn(),
stdin: new PassThrough(),
stdout: new PassThrough(),
stderr: new PassThrough(),
});
export const createFakeCloseEvent = (overrides?: Partial<CloseEvent>): CloseEvent => ({
command: new FakeCommand(),
index: 0,
killed: false,
exitCode: 0,
timings: {
startDate: new Date(),
endDate: new Date(),
durationSeconds: 0,
},
...overrides,
});
================================================
FILE: lib/assert.spec.ts
================================================
import { afterEach, describe, expect, it, vi } from 'vitest';
import { assertDeprecated } from './assert.js';
describe('#assertDeprecated()', () => {
const consoleMock = vi.spyOn(console, 'warn').mockImplementation(() => {});
afterEach(() => {
vi.clearAllMocks();
});
it('prints warning with name and message when condition is false', () => {
assertDeprecated(false, 'example-flag', 'This is an example message.');
expect(consoleMock).toHaveBeenLastCalledWith(
'[concurrently] example-flag is deprecated. This is an example message.',
);
});
it('prints same warning only once', () => {
assertDeprecated(false, 'example-flag', 'This is an example message.');
assertDeprecated(false, 'different-flag', 'This is another message.');
expect(consoleMock).toBeCalledTimes(1);
expect(consoleMock).toHaveBeenLastCalledWith(
'[concurrently] different-flag is deprecated. This is another message.',
);
});
it('prints nothing if condition is true', () => {
assertDeprecated(true, 'example-flag', 'This is an example message.');
expect(consoleMock).not.toHaveBeenCalled();
});
});
================================================
FILE: lib/assert.ts
================================================
const deprecations = new Set<string>();
/**
* Asserts that some condition is true, and if not, prints a warning about it being deprecated.
* The message is printed only once.
*/
export function assertDeprecated(check: boolean, name: string, message: string) {
if (!check && !deprecations.has(name)) {
// eslint-disable-next-line no-console
console.warn(`[concurrently] ${name} is deprecated. ${message}`);
deprecations.add(name);
}
}
================================================
FILE: lib/command-parser/command-parser.d.ts
================================================
import { CommandInfo } from '../command.js';
/**
* A command parser encapsulates a specific logic for mapping `CommandInfo` objects
* into another `CommandInfo`.
*
* A prime example is turning an abstract `npm:foo` into `npm run foo`, but it could also turn
* the prefix color of a command brighter, or maybe even prefixing each command with `time(1)`.
*/
export interface CommandParser {
/**
* Parses `commandInfo` and returns one or more `CommandInfo`s.
*
* Returning multiple `CommandInfo` is used when there are multiple possibilities of commands to
* run given the original input.
* An example of this is when the command contains a wildcard and it must be expanded into all
* viable options so that the consumer can decide which ones to run.
*/
parse: (commandInfo: CommandInfo) => CommandInfo | CommandInfo[];
}
================================================
FILE: lib/command-parser/expand-arguments.spec.ts
================================================
import { expect, it } from 'vitest';
import { CommandInfo } from '../command.js';
import { ExpandArguments } from './expand-arguments.js';
const createCommandInfo = (command: string): CommandInfo => ({
command,
name: '',
});
it('returns command as is when no placeholders', () => {
const parser = new ExpandArguments(['foo', 'bar']);
const commandInfo = createCommandInfo('echo foo');
expect(parser.parse(commandInfo)).toEqual({ ...commandInfo, command: 'echo foo' });
});
it('single argument placeholder is replaced', () => {
const parser = new ExpandArguments(['foo', 'bar']);
const commandInfo = createCommandInfo('echo {1}');
expect(parser.parse(commandInfo)).toEqual({ ...commandInfo, command: 'echo foo' });
});
it('argument placeholder is replaced and quoted properly', () => {
const parser = new ExpandArguments(['foo bar']);
const commandInfo = createCommandInfo('echo {1}');
expect(parser.parse(commandInfo)).toEqual({ ...commandInfo, command: "echo 'foo bar'" });
});
it('multiple single argument placeholders are replaced', () => {
const parser = new ExpandArguments(['foo', 'bar']);
const commandInfo = createCommandInfo('echo {2} {1}');
expect(parser.parse(commandInfo)).toEqual({ ...commandInfo, command: 'echo bar foo' });
});
it('empty replacement with single placeholder and not enough passthrough arguments', () => {
const parser = new ExpandArguments(['foo', 'bar']);
const commandInfo = createCommandInfo('echo {3}');
expect(parser.parse(commandInfo)).toEqual({ ...commandInfo, command: 'echo ' });
});
it('empty replacement with all placeholder and no passthrough arguments', () => {
const parser = new ExpandArguments([]);
const commandInfo = createCommandInfo('echo {@}');
expect(parser.parse(commandInfo)).toEqual({ ...commandInfo, command: 'echo ' });
});
it('empty replacement with combined placeholder and no passthrough arguments', () => {
const parser = new ExpandArguments([]);
const commandInfo = createCommandInfo('echo {*}');
expect(parser.parse(commandInfo)).toEqual({ ...commandInfo, command: 'echo ' });
});
it('all arguments placeholder is replaced', () => {
const parser = new ExpandArguments(['foo', 'bar']);
const commandInfo = createCommandInfo('echo {@}');
expect(parser.parse(commandInfo)).toEqual({ ...commandInfo, command: 'echo foo bar' });
});
it('combined arguments placeholder is replaced', () => {
const parser = new ExpandArguments(['foo', 'bar']);
const commandInfo = createCommandInfo('echo {*}');
expect(parser.parse(commandInfo)).toEqual({ ...commandInfo, command: "echo 'foo bar'" });
});
it('escaped argument placeholders are not replaced', () => {
const parser = new ExpandArguments(['foo', 'bar']);
// Equals to single backslash on command line
const commandInfo = createCommandInfo('echo \\{1} \\{@} \\{*}');
expect(parser.parse(commandInfo)).toEqual({ ...commandInfo, command: 'echo {1} {@} {*}' });
});
================================================
FILE: lib/command-parser/expand-arguments.ts
================================================
import { quote } from 'shell-quote';
import { CommandInfo } from '../command.js';
import { CommandParser } from './command-parser.js';
/**
* Replace placeholders with additional arguments.
*/
export class ExpandArguments implements CommandParser {
constructor(private readonly additionalArguments: string[]) {}
parse(commandInfo: CommandInfo) {
const command = commandInfo.command.replace(
/\\?\{([@*]|[1-9]\d*)\}/g,
(match, placeholderTarget: string) => {
// Don't replace the placeholder if it is escaped by a backslash.
if (match.startsWith('\\')) {
return match.slice(1);
}
if (this.additionalArguments.length > 0) {
// Replace numeric placeholder if value exists in additional arguments.
if (+placeholderTarget <= this.additionalArguments.length) {
return quote([this.additionalArguments[+placeholderTarget - 1]]);
}
// Replace all arguments placeholder.
if (placeholderTarget === '@') {
return quote(this.additionalArguments);
}
// Replace combined arguments placeholder.
if (placeholderTarget === '*') {
return quote([this.additionalArguments.join(' ')]);
}
}
// Replace placeholder with empty string
// if value doesn't exist in additional arguments.
return '';
},
);
return { ...commandInfo, command };
}
}
================================================
FILE: lib/command-parser/expand-shortcut.spec.ts
================================================
import { describe, expect, it } from 'vitest';
import { CommandInfo } from '../command.js';
import { ExpandShortcut } from './expand-shortcut.js';
const parser = new ExpandShortcut();
const createCommandInfo = (command: string, name = ''): CommandInfo => ({
name,
command,
});
it('returns same command if no prefix is present', () => {
const commandInfo = createCommandInfo('echo foo');
expect(parser.parse(commandInfo)).toBe(commandInfo);
});
describe.each([
['npm', 'npm run'],
['yarn', 'yarn run'],
['pnpm', 'pnpm run'],
['bun', 'bun run'],
['node', 'node --run'],
['deno', 'deno task'],
])(`with '%s:' prefix`, (prefix, command) => {
it(`expands to "${command} <script> <args>"`, () => {
const commandInfo = createCommandInfo(`${prefix}:foo -- bar`, 'echo');
expect(parser.parse(commandInfo)).toEqual({
...commandInfo,
name: 'echo',
command: `${command} foo -- bar`,
});
});
it('sets name to script name if none', () => {
const commandInfo = createCommandInfo(`${prefix}:foo -- bar`);
expect(parser.parse(commandInfo)).toEqual({
...commandInfo,
name: 'foo',
command: `${command} foo -- bar`,
});
});
});
================================================
FILE: lib/command-parser/expand-shortcut.ts
================================================
import { CommandInfo } from '../command.js';
import { CommandParser } from './command-parser.js';
/**
* Expands shortcuts according to the following table:
*
* | Syntax | Expands to |
* | --------------- | --------------------- |
* | `npm:<script>` | `npm run <script>` |
* | `pnpm:<script>` | `pnpm run <script>` |
* | `yarn:<script>` | `yarn run <script>` |
* | `bun:<script>` | `bun run <script>` |
* | `node:<script>` | `node --run <script>` |
* | `deno:<script>` | `deno task <script>` |
*/
export class ExpandShortcut implements CommandParser {
parse(commandInfo: CommandInfo) {
const [, prefix, script, args] =
/^(npm|yarn|pnpm|bun|node|deno):(\S+)(.*)/.exec(commandInfo.command) || [];
if (!script) {
return commandInfo;
}
let command: string;
if (prefix === 'node') {
command = 'node --run';
} else if (prefix === 'deno') {
command = 'deno task';
} else {
command = `${prefix} run`;
}
return {
...commandInfo,
name: commandInfo.name || script,
command: `${command} ${script}${args}`,
};
}
}
================================================
FILE: lib/command-parser/expand-wildcard.spec.ts
================================================
import fs, { PathOrFileDescriptor } from 'node:fs';
import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
import { CommandInfo } from '../command.js';
import { ExpandWildcard } from './expand-wildcard.js';
let parser: ExpandWildcard;
let readPackage: Mock;
let readDeno: Mock;
const createCommandInfo = (command: string): CommandInfo => ({
command,
name: '',
});
beforeEach(() => {
readDeno = vi.fn();
readPackage = vi.fn();
parser = new ExpandWildcard(readDeno, readPackage);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('#readDeno()', () => {
it('can read deno.json', () => {
const expectedDeno = {
name: 'deno',
version: '1.14.0',
};
vi.spyOn(fs, 'existsSync').mockImplementation((path: PathOrFileDescriptor) => {
return path === 'deno.json';
});
vi.spyOn(fs, 'readFileSync').mockImplementation((path: PathOrFileDescriptor) => {
if (path === 'deno.json') {
return JSON.stringify(expectedDeno);
}
return '';
});
const actualReadDeno = ExpandWildcard.readDeno();
expect(actualReadDeno).toEqual(expectedDeno);
});
it('can read deno.jsonc', () => {
const expectedDeno = {
name: 'deno',
version: '1.14.0',
};
vi.spyOn(fs, 'existsSync').mockImplementation((path: PathOrFileDescriptor) => {
return path === 'deno.jsonc';
});
vi.spyOn(fs, 'readFileSync').mockImplementation((path: PathOrFileDescriptor) => {
if (path === 'deno.jsonc') {
return `/* comment */\n${JSON.stringify(expectedDeno)}`;
}
return '';
});
const actualReadDeno = ExpandWildcard.readDeno();
expect(actualReadDeno).toEqual(expectedDeno);
});
it('prefers deno.json over deno.jsonc', () => {
const expectedDeno = {
name: 'deno',
version: '1.14.0',
};
vi.spyOn(fs, 'existsSync').mockImplementation((path: PathOrFileDescriptor) => {
return path === 'deno.json' || path === 'deno.jsonc';
});
vi.spyOn(fs, 'readFileSync').mockImplementation((path: PathOrFileDescriptor) => {
if (path === 'deno.json') {
return JSON.stringify(expectedDeno);
}
return '';
});
const actualReadDeno = ExpandWildcard.readDeno();
expect(actualReadDeno).toEqual(expectedDeno);
});
it('can handle errors reading deno', () => {
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
vi.spyOn(fs, 'readFileSync').mockImplementation(() => {
throw new Error('Error reading deno');
});
expect(() => ExpandWildcard.readDeno()).not.toThrow();
expect(ExpandWildcard.readDeno()).toEqual({});
});
});
describe('#readPackage()', () => {
it('can read package', () => {
const expectedPackage = {
name: 'concurrently',
version: '6.4.0',
};
vi.spyOn(fs, 'readFileSync').mockImplementation((path: PathOrFileDescriptor) => {
if (path === 'package.json') {
return JSON.stringify(expectedPackage);
}
return '';
});
const actualReadPackage = ExpandWildcard.readPackage();
expect(actualReadPackage).toEqual(expectedPackage);
});
it('can handle errors reading package', () => {
vi.spyOn(fs, 'readFileSync').mockImplementation(() => {
throw new Error('Error reading package');
});
expect(() => ExpandWildcard.readPackage()).not.toThrow();
expect(ExpandWildcard.readPackage()).toEqual({});
});
});
it('returns same command if not an npm run command', () => {
const commandInfo = createCommandInfo('npm test');
expect(readDeno).not.toHaveBeenCalled();
expect(readPackage).not.toHaveBeenCalled();
expect(parser.parse(commandInfo)).toBe(commandInfo);
});
it('returns same command if not a deno task command', () => {
const commandInfo = createCommandInfo('deno run');
expect(readDeno).not.toHaveBeenCalled();
expect(readPackage).not.toHaveBeenCalled();
expect(parser.parse(commandInfo)).toBe(commandInfo);
});
it('returns same command if no wildcard present', () => {
const commandInfo = createCommandInfo('npm run foo bar');
expect(readPackage).not.toHaveBeenCalled();
expect(parser.parse(commandInfo)).toBe(commandInfo);
});
it('expands to nothing if no scripts exist in package.json', () => {
readPackage.mockReturnValue({});
expect(parser.parse(createCommandInfo('npm run foo-*-baz qux'))).toEqual([]);
});
it('expands to nothing if no tasks exist in Deno config and no scripts exist in NodeJS config', () => {
readDeno.mockReturnValue({});
readPackage.mockReturnValue({});
expect(parser.parse(createCommandInfo('deno task foo-*-baz qux'))).toEqual([]);
});
describe.each(['npm run', 'yarn run', 'pnpm run', 'bun run', 'node --run'])(
`with a '%s' prefix`,
(command) => {
it('expands to all scripts matching pattern', () => {
readPackage.mockReturnValue({
scripts: {
'foo-bar-baz': '',
'foo--baz': '',
},
});
expect(parser.parse(createCommandInfo(`${command} foo-*-baz qux`))).toEqual([
{ name: 'bar', command: `${command} foo-bar-baz qux` },
{ name: '', command: `${command} foo--baz qux` },
]);
});
it('uses wildcard match of script as command name', () => {
readPackage.mockReturnValue({
scripts: {
'watch-js': '',
'watch-css': '',
},
});
expect(
parser.parse({
name: 'watch-*',
command: `${command} watch-*`,
}),
).toEqual([
{ name: 'js', command: `${command} watch-js` },
{ name: 'css', command: `${command} watch-css` },
]);
});
it('uses existing command name as prefix to the wildcard match', () => {
readPackage.mockReturnValue({
scripts: {
'watch-js': '',
'watch-css': '',
},
});
expect(
parser.parse({
name: 'w:',
command: `${command} watch-*`,
}),
).toEqual([
{ name: 'w:js', command: `${command} watch-js` },
{ name: 'w:css', command: `${command} watch-css` },
]);
});
it('allows negation', () => {
readPackage.mockReturnValue({
scripts: {
'lint:js': '',
'lint:ts': '',
'lint:fix:js': '',
'lint:fix:ts': '',
},
});
expect(parser.parse(createCommandInfo(`${command} lint:*(!fix)`))).toEqual([
{ name: 'js', command: `${command} lint:js` },
{ name: 'ts', command: `${command} lint:ts` },
]);
});
it('caches scripts upon calls', () => {
readPackage.mockReturnValue({});
parser.parse(createCommandInfo(`${command} foo-*-baz qux`));
parser.parse(createCommandInfo(`${command} foo-*-baz qux`));
expect(readPackage).toHaveBeenCalledTimes(1);
});
it("doesn't read Deno config", () => {
readPackage.mockReturnValue({});
parser.parse(createCommandInfo(`${command} foo-*-baz qux`));
expect(readDeno).not.toHaveBeenCalled();
});
},
);
describe(`with a 'deno task' prefix`, () => {
it('expands to all scripts matching pattern', () => {
readDeno.mockReturnValue({
tasks: {
'foo-bar-baz': '',
'foo--baz': '',
},
});
readPackage.mockReturnValue({
scripts: {
'foo-foo-baz': '',
},
});
expect(parser.parse(createCommandInfo(`deno task foo-*-baz qux`))).toEqual([
{ name: 'bar', command: `deno task foo-bar-baz qux` },
{ name: '', command: `deno task foo--baz qux` },
{ name: 'foo', command: `deno task foo-foo-baz qux` },
]);
});
it('uses wildcard match of script as command name', () => {
readDeno.mockReturnValue({
tasks: {
'watch-sass': '',
},
});
readPackage.mockReturnValue({
scripts: {
'watch-js': '',
'watch-css': '',
},
});
expect(
parser.parse({
name: '',
command: `deno task watch-*`,
}),
).toEqual([
{ name: 'sass', command: `deno task watch-sass` },
{ name: 'js', command: `deno task watch-js` },
{ name: 'css', command: `deno task watch-css` },
]);
});
it('uses existing command name as prefix to the wildcard match', () => {
readDeno.mockReturnValue({
tasks: {
'watch-sass': '',
},
});
readPackage.mockReturnValue({
scripts: {
'watch-js': '',
'watch-css': '',
},
});
expect(
parser.parse({
name: 'w:',
command: `deno task watch-*`,
}),
).toEqual([
{ name: 'w:sass', command: `deno task watch-sass` },
{ name: 'w:js', command: `deno task watch-js` },
{ name: 'w:css', command: `deno task watch-css` },
]);
});
it('allows negation', () => {
readDeno.mockReturnValue({
tasks: {
'lint:sass': '',
'lint:fix:sass': '',
},
});
readPackage.mockReturnValue({
scripts: {
'lint:js': '',
'lint:ts': '',
'lint:fix:js': '',
'lint:fix:ts': '',
},
});
expect(parser.parse(createCommandInfo(`deno task lint:*(!fix)`))).toEqual([
{ name: 'sass', command: `deno task lint:sass` },
{ name: 'js', command: `deno task lint:js` },
{ name: 'ts', command: `deno task lint:ts` },
]);
});
it('caches scripts upon calls', () => {
readDeno.mockReturnValue({});
readPackage.mockReturnValue({});
parser.parse(createCommandInfo(`deno task foo-*-baz qux`));
parser.parse(createCommandInfo(`deno task foo-*-baz qux`));
expect(readDeno).toHaveBeenCalledTimes(1);
expect(readPackage).toHaveBeenCalledTimes(1);
});
});
================================================
FILE: lib/command-parser/expand-wildcard.ts
================================================
import fs from 'node:fs';
import { CommandInfo } from '../command.js';
import JSONC from '../jsonc.js';
import { escapeRegExp } from '../utils.js';
import { CommandParser } from './command-parser.js';
// Matches a negative filter surrounded by '(!' and ')'.
const OMISSION = /\(!([^)]+)\)/;
/**
* 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
* configuration files of the current directory.
*/
export class ExpandWildcard implements CommandParser {
static readDeno() {
try {
let json: string = '{}';
if (fs.existsSync('deno.json')) {
json = fs.readFileSync('deno.json', { encoding: 'utf-8' });
} else if (fs.existsSync('deno.jsonc')) {
json = fs.readFileSync('deno.jsonc', { encoding: 'utf-8' });
}
return JSONC.parse(json);
} catch {
return {};
}
}
static readPackage() {
try {
const json = fs.readFileSync('package.json', { encoding: 'utf-8' });
return JSON.parse(json);
} catch {
return {};
}
}
private packageScripts?: string[];
private denoTasks?: string[];
constructor(
private readonly readDeno = ExpandWildcard.readDeno,
private readonly readPackage = ExpandWildcard.readPackage,
) {}
private relevantScripts(command: string): string[] {
if (!this.packageScripts) {
this.packageScripts = Object.keys(this.readPackage().scripts || {});
}
if (command === 'deno task') {
if (!this.denoTasks) {
// If Deno tries to run a task that doesn't exist,
// it can fall back to running a script with the same name.
// Therefore, the actual list of tasks is the union of the tasks and scripts.
this.denoTasks = [
...Object.keys(this.readDeno().tasks || {}),
...this.packageScripts,
];
}
return this.denoTasks;
}
return this.packageScripts;
}
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
if (wildcardPosition === -1) {
return commandInfo;
}
const [, omission] = OMISSION.exec(scriptGlob) || [];
const scriptGlobSansOmission = scriptGlob.replace(OMISSION, '');
const preWildcard = escapeRegExp(scriptGlobSansOmission.slice(0, wildcardPosition));
const postWildcard = escapeRegExp(scriptGlobSansOmission.slice(wildcardPosition + 1));
const wildcardRegex = new RegExp(`^${preWildcard}(.*?)${postWildcard}$`);
// If 'commandInfo.name' doesn't match 'scriptGlob', this means a custom name
// has been specified and thus becomes the prefix (as described in the README).
const prefix = commandInfo.name !== scriptGlob ? commandInfo.name : '';
const commands: CommandInfo[] = [];
for (const script of this.relevantScripts(command)) {
if (omission && new RegExp(omission).test(script)) {
continue;
}
const result = wildcardRegex.exec(script);
const match = result?.[1];
if (match !== undefined) {
commands.push({
...commandInfo,
command: `${command} ${script}${args}`,
// 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,
});
}
}
return commands;
}
}
================================================
FILE: lib/command-parser/strip-quotes.spec.ts
================================================
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);
});
================================================
FILE: lib/command-parser/strip-quotes.ts
================================================
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 };
}
}
================================================
FILE: lib/command.spec.ts
================================================
import { Buffer } from 'node:buffer';
import { SendHandle, SpawnOptions } from 'node:child_process';
import { EventEmitter } from 'node:events';
import { Readable, Writable } from 'node:stream';
import { subscribeSpyTo } from '@hirez_io/observer-spy';
import Rx from 'rxjs';
import { beforeEach, describe, expect, it, Mock, vi } from 'vitest';
import {
ChildProcess,
CloseEvent,
Command,
CommandInfo,
KillProcess,
SpawnCommand,
} from './command.js';
interface CommandValues {
error: unknown;
close: CloseEvent;
timer: unknown[];
}
let process: ChildProcess;
let sendMessage: Mock;
let spawn: Mock<SpawnCommand>;
let killProcess: KillProcess;
const IPC_FD = 3;
beforeEach(() => {
sendMessage = vi.fn();
process = new (class extends EventEmitter {
readonly pid = 1;
send = sendMessage;
readonly stdout = new Readable({
read() {
// do nothing
},
});
readonly stderr = new Readable({
read() {
// do nothing
},
});
readonly stdin = new Writable({
write() {
// do nothing
},
});
})();
spawn = vi.fn().mockReturnValue(process);
killProcess = vi.fn();
});
const createCommand = (overrides?: Partial<CommandInfo>, spawnOpts: SpawnOptions = {}) => {
const command = new Command(
{ index: 0, name: '', command: 'echo foo', ...overrides },
spawnOpts,
spawn,
killProcess,
);
let error: unknown;
let close: CloseEvent;
const timer = subscribeSpyTo(command.timer);
const finished = subscribeSpyTo(
new Rx.Observable((observer) => {
// First event in both subjects means command has finished
command.error.subscribe({
next: (value) => {
error = value;
observer.complete();
},
});
command.close.subscribe({
next: (value) => {
close = value;
observer.complete();
},
});
}),
);
const values = async (): Promise<CommandValues> => {
await finished.onComplete();
return { error, close, timer: timer.getValues() };
};
return { command, values };
};
it('has stopped state by default', () => {
const { command } = createCommand();
expect(command.state).toBe('stopped');
});
describe('#start()', () => {
it('spawns process with given command and options', () => {
const { command } = createCommand({}, { detached: true });
command.start();
expect(spawn).toHaveBeenCalledExactlyOnceWith(command.command, { detached: true });
});
it('sets stdin, process and PID', () => {
const { command } = createCommand();
command.start();
expect(command.process).toBe(process);
expect(command.pid).toBe(process.pid);
expect(command.stdin).toBe(process.stdin);
});
it('handles process with no stdin', () => {
process.stdin = null;
const { command } = createCommand();
command.start();
expect(command.stdin).toBe(undefined);
});
it('changes state to started', () => {
const { command } = createCommand();
const spy = subscribeSpyTo(command.stateChange);
command.start();
expect(command.state).toBe('started');
expect(spy.getFirstValue()).toBe('started');
});
describe('on errors', () => {
it('changes state to errored', () => {
const { command } = createCommand();
command.start();
const spy = subscribeSpyTo(command.stateChange);
process.emit('error', 'foo');
expect(command.state).toBe('errored');
expect(spy.getFirstValue()).toBe('errored');
});
it('shares to the error stream', async () => {
const { command, values } = createCommand();
command.start();
process.emit('error', 'foo');
const { error } = await values();
expect(error).toBe('foo');
expect(command.process).toBeUndefined();
});
it('shares start and error timing events to the timing stream', async () => {
const { command, values } = createCommand();
const startDate = new Date();
const endDate = new Date(startDate.getTime() + 1000);
vi.spyOn(Date, 'now')
.mockReturnValueOnce(startDate.getTime())
.mockReturnValueOnce(endDate.getTime());
command.start();
process.emit('error', 0, null);
const { timer } = await values();
expect(timer[0]).toEqual({ startDate, endDate: undefined });
expect(timer[1]).toEqual({ startDate, endDate });
});
});
describe('on close', () => {
it('changes state to exited', () => {
const { command } = createCommand();
command.start();
const spy = subscribeSpyTo(command.stateChange);
process.emit('close', 0, null);
expect(command.state).toBe('exited');
expect(spy.getFirstValue()).toBe('exited');
});
it('does not change state if there was an error', () => {
const { command } = createCommand();
command.start();
process.emit('error', 'foo');
const spy = subscribeSpyTo(command.stateChange);
process.emit('close', 0, null);
expect(command.state).toBe('errored');
expect(spy.getValuesLength()).toBe(0);
});
it('shares start and close timing events to the timing stream', async () => {
const { command, values } = createCommand();
const startDate = new Date();
const endDate = new Date(startDate.getTime() + 1000);
vi.spyOn(Date, 'now')
.mockReturnValueOnce(startDate.getTime())
.mockReturnValueOnce(endDate.getTime());
command.start();
process.emit('close', 0, null);
const { timer } = await values();
expect(timer[0]).toEqual({ startDate, endDate: undefined });
expect(timer[1]).toEqual({ startDate, endDate });
});
it('shares to the close stream with exit code', async () => {
const { command, values } = createCommand();
command.start();
process.emit('close', 0, null);
const { close } = await values();
expect(close).toMatchObject({ exitCode: 0, killed: false });
expect(command.process).toBeUndefined();
});
it('shares to the close stream with signal', async () => {
const { command, values } = createCommand();
command.start();
process.emit('close', null, 'SIGKILL');
const { close } = await values();
expect(close).toMatchObject({ exitCode: 'SIGKILL', killed: false });
});
it('shares to the close stream with timing information', async () => {
const { command, values } = createCommand();
const startDate = new Date();
const endDate = new Date(startDate.getTime() + 1000);
vi.spyOn(Date, 'now')
.mockReturnValueOnce(startDate.getTime())
.mockReturnValueOnce(endDate.getTime());
vi.spyOn(globalThis.process, 'hrtime')
.mockReturnValueOnce([0, 0])
.mockReturnValueOnce([1, 1e8]);
command.start();
process.emit('close', null, 'SIGKILL');
const { close } = await values();
expect(close.timings).toStrictEqual({
startDate,
endDate,
durationSeconds: 1.1,
});
});
it('shares to the close stream with command info', async () => {
const commandInfo = {
command: 'cmd',
name: 'name',
prefixColor: 'green',
env: { VAR: 'yes' },
};
const { command, values } = createCommand(commandInfo);
command.start();
process.emit('close', 0, null);
const { close } = await values();
expect(close.command).toEqual(expect.objectContaining(commandInfo));
expect(close.killed).toBe(false);
});
});
it('shares stdout to the stdout stream', async () => {
const { command } = createCommand();
const stdout = Rx.firstValueFrom(command.stdout);
command.start();
process.stdout?.emit('data', Buffer.from('hello'));
expect((await stdout).toString()).toBe('hello');
});
it('shares stderr to the stdout stream', async () => {
const { command } = createCommand();
const stderr = Rx.firstValueFrom(command.stderr);
command.start();
process.stderr?.emit('data', Buffer.from('dang'));
expect((await stderr).toString()).toBe('dang');
});
describe('on incoming messages', () => {
it('does not share to the incoming messages stream, if IPC is disabled', () => {
const { command } = createCommand();
const spy = subscribeSpyTo(command.messages.incoming);
command.start();
process.emit('message', {});
expect(spy.getValuesLength()).toBe(0);
});
it('shares to the incoming messages stream, if IPC is enabled', () => {
const { command } = createCommand({ ipc: IPC_FD });
const spy = subscribeSpyTo(command.messages.incoming);
command.start();
const message1 = {};
process.emit('message', message1, undefined);
const message2 = {};
const handle = {} as SendHandle;
process.emit('message', message2, handle);
expect(spy.getValuesLength()).toBe(2);
expect(spy.getValueAt(0)).toEqual({ message: message1, handle: undefined });
expect(spy.getValueAt(1)).toEqual({ message: message2, handle });
});
});
describe('on outgoing messages', () => {
it('calls onSent with an error if the process does not have IPC enabled', () => {
const { command } = createCommand({ ipc: IPC_FD });
command.start();
Object.assign(process, {
// The TS types don't assume `send` can be undefined,
// despite the Node docs saying so
send: undefined,
});
const onSent = vi.fn();
command.messages.outgoing.next({ message: {}, onSent });
expect(onSent).toHaveBeenCalledWith(expect.any(Error));
});
it('sends the message to the process', () => {
const { command } = createCommand({ ipc: IPC_FD });
command.start();
const message1 = {};
command.messages.outgoing.next({ message: message1, onSent() {} });
const message2 = {};
const handle = {} as SendHandle;
command.messages.outgoing.next({ message: message2, handle, onSent() {} });
const message3 = {};
const options = {};
command.messages.outgoing.next({ message: message3, options, onSent() {} });
expect(process.send).toHaveBeenCalledTimes(3);
expect(process.send).toHaveBeenNthCalledWith(
1,
message1,
undefined,
undefined,
expect.any(Function),
);
expect(process.send).toHaveBeenNthCalledWith(
2,
message2,
handle,
undefined,
expect.any(Function),
);
expect(process.send).toHaveBeenNthCalledWith(
3,
message3,
undefined,
options,
expect.any(Function),
);
});
it('sends the message to the process, if it starts late', () => {
const { command } = createCommand({ ipc: IPC_FD });
command.messages.outgoing.next({ message: {}, onSent() {} });
expect(process.send).not.toHaveBeenCalled();
command.start();
expect(process.send).toHaveBeenCalled();
});
it('calls onSent with the result of sending the message', () => {
const { command } = createCommand({ ipc: IPC_FD });
command.start();
const onSent = vi.fn();
command.messages.outgoing.next({ message: {}, onSent });
expect(onSent).not.toHaveBeenCalled();
sendMessage.mock.calls[0][3]();
expect(onSent).toHaveBeenCalledWith(undefined);
const error = new Error('test');
sendMessage.mock.calls[0][3](error);
expect(onSent).toHaveBeenCalledWith(error);
});
});
});
describe('#send()', () => {
it('throws if IPC is not set up', () => {
const { command } = createCommand();
const fn = () => command.send({});
expect(fn).toThrow();
});
it('pushes the message on the outgoing messages stream', () => {
const { command } = createCommand({ ipc: IPC_FD });
const spy = subscribeSpyTo(command.messages.outgoing);
const message1 = { foo: true };
command.send(message1);
const message2 = { bar: 123 };
const handle = {} as SendHandle;
command.send(message2, handle);
const message3 = { baz: 'yes' };
const options = {};
command.send(message3, undefined, options);
expect(spy.getValuesLength()).toBe(3);
expect(spy.getValueAt(0)).toMatchObject({
message: message1,
handle: undefined,
options: undefined,
});
expect(spy.getValueAt(1)).toMatchObject({ message: message2, handle, options: undefined });
expect(spy.getValueAt(2)).toMatchObject({ message: message3, handle: undefined, options });
});
it('resolves when onSent callback is called with no arguments', async () => {
const { command } = createCommand({ ipc: IPC_FD });
const spy = subscribeSpyTo(command.messages.outgoing);
const promise = command.send({});
spy.getFirstValue().onSent();
await expect(promise).resolves.toBeUndefined();
});
it('rejects when onSent callback is called with an argument', async () => {
const { command } = createCommand({ ipc: IPC_FD });
const spy = subscribeSpyTo(command.messages.outgoing);
const promise = command.send({});
spy.getFirstValue().onSent('foo');
await expect(promise).rejects.toBe('foo');
});
});
describe('#kill()', () => {
let createdCommand: { command: Command; values: () => Promise<CommandValues> };
beforeEach(() => {
createdCommand = createCommand();
});
it('kills process', () => {
createdCommand.command.start();
createdCommand.command.kill();
expect(killProcess).toHaveBeenCalledExactlyOnceWith(createdCommand.command.pid, undefined);
});
it('kills process with some signal', () => {
createdCommand.command.start();
createdCommand.command.kill('SIGKILL');
expect(killProcess).toHaveBeenCalledExactlyOnceWith(createdCommand.command.pid, 'SIGKILL');
});
it('does not try to kill inexistent process', () => {
createdCommand.command.start();
process.emit('error');
createdCommand.command.kill();
expect(killProcess).not.toHaveBeenCalled();
});
it('marks the command as killed', async () => {
createdCommand.command.start();
createdCommand.command.kill();
process.emit('close', 1, null);
const { close } = await createdCommand.values();
expect(close).toMatchObject({ exitCode: 1, killed: true });
});
});
describe('.canKill()', () => {
it('returns whether command has both PID and process', () => {
const { command } = createCommand();
expect(Command.canKill(command)).toBe(false);
command.pid = 1;
expect(Command.canKill(command)).toBe(false);
command.process = process;
expect(Command.canKill(command)).toBe(true);
});
});
================================================
FILE: lib/command.ts
================================================
import { Buffer } from 'node:buffer';
import {
ChildProcess as BaseChildProcess,
MessageOptions,
SendHandle,
SpawnOptions,
} from 'node:child_process';
import process from 'node:process';
import { EventEmitter, Writable } from 'node:stream';
import Rx from 'rxjs';
/**
* Identifier for a command; if string, it's the command's name, if number, it's the index.
*/
export type CommandIdentifier = string | number;
export interface CommandInfo {
/**
* Command's name.
*/
name: string;
/**
* Which command line the command has.
*/
command: string;
/**
* Which environment variables should the spawned process have.
*/
env?: Record<string, unknown>;
/**
* The current working directory of the process when spawned.
*/
cwd?: string;
/**
* Color to use on prefix of the command.
*/
prefixColor?: string;
/**
* Whether sending of messages to/from this command (also known as "inter-process communication")
* should be enabled, and using which file descriptor number.
*
* If set, must be > 2.
*/
ipc?: number;
/**
* Output command in raw format.
*/
raw?: boolean;
}
export interface CloseEvent {
command: CommandInfo;
/**
* The command's index among all commands ran.
*/
index: number;
/**
* Whether the command exited because it was killed.
*/
killed: boolean;
/**
* The exit code or signal for the command.
*/
exitCode: string | number;
timings: {
startDate: Date;
endDate: Date;
durationSeconds: number;
};
}
export interface TimerEvent {
startDate: Date;
endDate?: Date;
}
export interface MessageEvent {
message: object;
handle?: SendHandle;
}
interface OutgoingMessageEvent extends MessageEvent {
options?: MessageOptions;
onSent: (error?: unknown) => void;
}
/**
* Subtype of NodeJS's child_process including only what's actually needed for a command to work.
*/
export type ChildProcess = EventEmitter &
Pick<BaseChildProcess, 'pid' | 'stdin' | 'stdout' | 'stderr' | 'send'>;
/**
* Interface for a function that must kill the process with `pid`, optionally sending `signal` to it.
*/
export type KillProcess = (pid: number, signal?: string) => void;
/**
* Interface for a function that spawns a command and returns its child process instance.
*/
export type SpawnCommand = (command: string, options: SpawnOptions) => ChildProcess;
/**
* The state of a command.
*
* - `stopped`: command was never started
* - `started`: command is currently running
* - `errored`: command failed spawning
* - `exited`: command is not running anymore, e.g. it received a close event
*/
type CommandState = 'stopped' | 'started' | 'errored' | 'exited';
export class Command implements CommandInfo {
private readonly killProcess: KillProcess;
private readonly spawn: SpawnCommand;
private readonly spawnOpts: SpawnOptions;
readonly index: number;
/** @inheritdoc */
readonly name: string;
/** @inheritdoc */
readonly command: string;
/** @inheritdoc */
readonly prefixColor?: string;
/** @inheritdoc */
readonly env: Record<string, unknown>;
/** @inheritdoc */
readonly cwd?: string;
/** @inheritdoc */
readonly ipc?: number;
readonly close = new Rx.Subject<CloseEvent>();
readonly error = new Rx.Subject<unknown>();
readonly stdout = new Rx.Subject<Buffer>();
readonly stderr = new Rx.Subject<Buffer>();
readonly timer = new Rx.Subject<TimerEvent>();
/**
* A stream of changes to the `#state` property.
*
* Note that the command never goes back to the `stopped` state, therefore it's not a value
* that's emitted by this stream.
*/
readonly stateChange = new Rx.Subject<Exclude<CommandState, 'stopped'>>();
readonly messages = {
incoming: new Rx.Subject<MessageEvent>(),
outgoing: new Rx.ReplaySubject<OutgoingMessageEvent>(),
};
process?: ChildProcess;
// TODO: Should exit/error/stdio subscriptions be added here?
private subscriptions: readonly Rx.Subscription[] = [];
stdin?: Writable;
pid?: number;
killed = false;
exited = false;
state: CommandState = 'stopped';
constructor(
{ index, name, command, prefixColor, env, cwd, ipc }: CommandInfo & { index: number },
spawnOpts: SpawnOptions,
spawn: SpawnCommand,
killProcess: KillProcess,
) {
this.index = index;
this.name = name;
this.command = command;
this.prefixColor = prefixColor;
this.env = env || {};
this.cwd = cwd;
this.ipc = ipc;
this.killProcess = killProcess;
this.spawn = spawn;
this.spawnOpts = spawnOpts;
}
/**
* Starts this command, piping output, error and close events onto the corresponding observables.
*/
start() {
const child = this.spawn(this.command, this.spawnOpts);
this.changeState('started');
this.process = child;
this.pid = child.pid;
const startDate = new Date(Date.now());
const highResStartTime = process.hrtime();
this.timer.next({ startDate });
this.subscriptions = [...this.maybeSetupIPC(child)];
Rx.fromEvent(child, 'error').subscribe((event) => {
this.cleanUp();
const endDate = new Date(Date.now());
this.timer.next({ startDate, endDate });
this.error.next(event);
this.changeState('errored');
});
Rx.fromEvent(child, 'close')
.pipe(Rx.map((event) => event as [number | null, NodeJS.Signals | null]))
.subscribe(([exitCode, signal]) => {
this.cleanUp();
// Don't override error event
if (this.state !== 'errored') {
this.changeState('exited');
}
const endDate = new Date(Date.now());
this.timer.next({ startDate, endDate });
const [durationSeconds, durationNanoSeconds] = process.hrtime(highResStartTime);
this.close.next({
command: this,
index: this.index,
exitCode: exitCode ?? String(signal),
killed: this.killed,
timings: {
startDate,
endDate,
durationSeconds: durationSeconds + durationNanoSeconds / 1e9,
},
});
});
if (child.stdout) {
pipeTo(
Rx.fromEvent(child.stdout, 'data').pipe(Rx.map((event) => event as Buffer)),
this.stdout,
);
}
if (child.stderr) {
pipeTo(
Rx.fromEvent(child.stderr, 'data').pipe(Rx.map((event) => event as Buffer)),
this.stderr,
);
}
this.stdin = child.stdin || undefined;
}
private changeState(state: Exclude<CommandState, 'stopped'>) {
this.state = state;
this.stateChange.next(state);
}
private maybeSetupIPC(child: ChildProcess) {
if (!this.ipc) {
return [];
}
return [
pipeTo(
Rx.fromEvent(child, 'message').pipe(
Rx.map((event) => {
const [message, handle] = event as [object, SendHandle | undefined];
return { message, handle };
}),
),
this.messages.incoming,
),
this.messages.outgoing.subscribe((message) => {
if (!child.send) {
return message.onSent(new Error('Command does not have an IPC channel'));
}
child.send(message.message, message.handle, message.options, (error) => {
message.onSent(error);
});
}),
];
}
/**
* Sends a message to the underlying process once it starts.
*
* @throws If the command doesn't have an IPC channel enabled
* @returns Promise that resolves when the message is sent,
* or rejects if it fails to deliver the message.
*/
send(message: object, handle?: SendHandle, options?: MessageOptions): Promise<void> {
if (this.ipc == null) {
throw new Error('Command IPC is disabled');
}
return new Promise((resolve, reject) => {
this.messages.outgoing.next({
message,
handle,
options,
onSent(error) {
if (error) {
reject(error);
} else {
resolve();
}
},
});
});
}
/**
* Kills this command, optionally specifying a signal to send to it.
*/
kill(code?: string) {
if (Command.canKill(this)) {
this.killed = true;
this.killProcess(this.pid, code);
}
}
private cleanUp() {
this.subscriptions?.forEach((sub) => sub.unsubscribe());
this.messages.outgoing = new Rx.ReplaySubject();
this.process = undefined;
}
/**
* Detects whether a command can be killed.
*
* Also works as a type guard on the input `command`.
*/
static canKill(command: Command): command is Command & { pid: number; process: ChildProcess } {
return !!command.pid && !!command.process;
}
}
/**
* Pipes all events emitted by `stream` into `subject`.
*/
function pipeTo<T>(stream: Rx.Observable<T>, subject: Rx.Subject<T>) {
return stream.subscribe((event) => subject.next(event));
}
================================================
FILE: lib/completion-listener.spec.ts
================================================
import { getEventListeners } from 'node:events';
import { TestScheduler } from 'rxjs/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createFakeCloseEvent, FakeCommand } from './__fixtures__/fake-command.js';
import { CloseEvent } from './command.js';
import { CompletionListener, SuccessCondition } from './completion-listener.js';
let commands: FakeCommand[];
let scheduler: TestScheduler;
beforeEach(() => {
commands = [
new FakeCommand('foo', 'echo', 0),
new FakeCommand('bar', 'echo', 1),
new FakeCommand('baz', 'echo', 2),
];
scheduler = new TestScheduler(() => true);
});
const createController = (successCondition?: SuccessCondition) =>
new CompletionListener({
successCondition,
scheduler,
});
const emitFakeCloseEvent = (command: FakeCommand, event?: Partial<CloseEvent>) => {
const fakeEvent = createFakeCloseEvent({ ...event, command, index: command.index });
command.state = 'exited';
command.close.next(fakeEvent);
return fakeEvent;
};
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0));
describe('listen', () => {
it('resolves when there are no commands', async () => {
const result = createController().listen([]);
await expect(result).resolves.toHaveLength(0);
});
it('completes only when commands emit a close event, returns close event', async () => {
const abortCtrl = new AbortController();
const result = createController('all').listen(commands, abortCtrl.signal);
commands[0].state = 'started';
abortCtrl.abort();
const event = emitFakeCloseEvent(commands[0]);
scheduler.flush();
await expect(result).resolves.toHaveLength(1);
await expect(result).resolves.toEqual([event]);
});
it('completes when abort signal is received and command is stopped, returns nothing', async () => {
const abortCtrl = new AbortController();
// Use success condition = first to test index access when there are no close events
const result = createController('first').listen([new FakeCommand()], abortCtrl.signal);
abortCtrl.abort();
scheduler.flush();
await expect(result).resolves.toHaveLength(0);
});
it('does not leak memory when listening for abort signals', () => {
const abortCtrl = new AbortController();
createController().listen(
Array.from({ length: 10 }, () => new FakeCommand()),
abortCtrl.signal,
);
expect(getEventListeners(abortCtrl.signal, 'abort')).toHaveLength(1);
});
it('check for success once all commands have emitted at least a single close event', async () => {
const finallyCallback = vi.fn();
const result = createController().listen(commands).finally(finallyCallback);
// Emitting multiple close events to mimic calling command `kill/start` APIs.
emitFakeCloseEvent(commands[0]);
emitFakeCloseEvent(commands[0]);
emitFakeCloseEvent(commands[0]);
scheduler.flush();
// A broken implementation will have called finallyCallback only after flushing promises
await flushPromises();
expect(finallyCallback).not.toHaveBeenCalled();
emitFakeCloseEvent(commands[1]);
emitFakeCloseEvent(commands[2]);
scheduler.flush();
await expect(result).resolves.toEqual(expect.anything());
expect(finallyCallback).toHaveBeenCalled();
});
it('takes last event emitted from each command', async () => {
const result = createController().listen(commands);
emitFakeCloseEvent(commands[0], { exitCode: 0 });
emitFakeCloseEvent(commands[0], { exitCode: 1 });
emitFakeCloseEvent(commands[1], { exitCode: 0 });
emitFakeCloseEvent(commands[2], { exitCode: 0 });
scheduler.flush();
await expect(result).rejects.toEqual(expect.anything());
});
it('waits for manually restarted events to close', async () => {
const finallyCallback = vi.fn();
const result = createController().listen(commands).finally(finallyCallback);
emitFakeCloseEvent(commands[0]);
commands[0].state = 'started';
emitFakeCloseEvent(commands[1]);
emitFakeCloseEvent(commands[2]);
scheduler.flush();
// A broken implementation will have called finallyCallback only after flushing promises
await flushPromises();
expect(finallyCallback).not.toHaveBeenCalled();
commands[0].state = 'exited';
emitFakeCloseEvent(commands[0]);
scheduler.flush();
await expect(result).resolves.toEqual(expect.anything());
expect(finallyCallback).toHaveBeenCalled();
});
});
describe('detect commands exit conditions', () => {
describe('with default success condition set', () => {
it('succeeds if all processes exited with code 0', () => {
const result = createController().listen(commands);
commands[0].close.next(createFakeCloseEvent({ exitCode: 0 }));
commands[1].close.next(createFakeCloseEvent({ exitCode: 0 }));
commands[2].close.next(createFakeCloseEvent({ exitCode: 0 }));
scheduler.flush();
return expect(result).resolves.toEqual(expect.anything());
});
it('fails if one of the processes exited with non-0 code', () => {
const result = createController().listen(commands);
commands[0].close.next(createFakeCloseEvent({ exitCode: 0 }));
commands[1].close.next(createFakeCloseEvent({ exitCode: 1 }));
commands[2].close.next(createFakeCloseEvent({ exitCode: 0 }));
scheduler.flush();
return expect(result).rejects.toEqual(expect.anything());
});
});
describe('with success condition set to first', () => {
it('succeeds if first process to exit has code 0', () => {
const result = createController('first').listen(commands);
commands[1].close.next(createFakeCloseEvent({ exitCode: 0 }));
commands[0].close.next(createFakeCloseEvent({ exitCode: 1 }));
commands[2].close.next(createFakeCloseEvent({ exitCode: 1 }));
scheduler.flush();
return expect(result).resolves.toEqual(expect.anything());
});
it('fails if first process to exit has non-0 code', () => {
const result = createController('first').listen(commands);
commands[1].close.next(createFakeCloseEvent({ exitCode: 1 }));
commands[0].close.next(createFakeCloseEvent({ exitCode: 0 }));
commands[2].close.next(createFakeCloseEvent({ exitCode: 0 }));
scheduler.flush();
return expect(result).rejects.toEqual(expect.anything());
});
});
describe('with success condition set to last', () => {
it('succeeds if last process to exit has code 0', () => {
const result = createController('last').listen(commands);
commands[1].close.next(createFakeCloseEvent({ exitCode: 1 }));
commands[0].close.next(createFakeCloseEvent({ exitCode: 0 }));
commands[2].close.next(createFakeCloseEvent({ exitCode: 0 }));
scheduler.flush();
return expect(result).resolves.toEqual(expect.anything());
});
it('fails if last process to exit has non-0 code', () => {
const result = createController('last').listen(commands);
commands[1].close.next(createFakeCloseEvent({ exitCode: 0 }));
commands[0].close.next(createFakeCloseEvent({ exitCode: 1 }));
commands[2].close.next(createFakeCloseEvent({ exitCode: 1 }));
scheduler.flush();
return expect(result).rejects.toEqual(expect.anything());
});
});
describe.each([
// Use the middle command for both cases to make it more difficult to make a mess up
// in the implementation cause false passes.
['command-bar' as const, 'bar'],
['command-1' as const, 1],
])('with success condition set to %s', (condition, nameOrIndex) => {
it(`succeeds if command ${nameOrIndex} exits with code 0`, () => {
const result = createController(condition).listen(commands);
emitFakeCloseEvent(commands[0], { exitCode: 1 });
emitFakeCloseEvent(commands[1], { exitCode: 0 });
emitFakeCloseEvent(commands[2], { exitCode: 1 });
scheduler.flush();
return expect(result).resolves.toEqual(expect.anything());
});
it(`succeeds if all commands ${nameOrIndex} exit with code 0`, () => {
commands = [commands[0], commands[1], commands[1]];
const result = createController(condition).listen(commands);
emitFakeCloseEvent(commands[0], { exitCode: 1 });
emitFakeCloseEvent(commands[1], { exitCode: 0 });
emitFakeCloseEvent(commands[2], { exitCode: 0 });
scheduler.flush();
return expect(result).resolves.toEqual(expect.anything());
});
it(`fails if command ${nameOrIndex} exits with non-0 code`, () => {
const result = createController(condition).listen(commands);
emitFakeCloseEvent(commands[0], { exitCode: 0 });
emitFakeCloseEvent(commands[1], { exitCode: 1 });
emitFakeCloseEvent(commands[2], { exitCode: 0 });
scheduler.flush();
return expect(result).rejects.toEqual(expect.anything());
});
it(`fails if some commands ${nameOrIndex} exit with non-0 code`, () => {
const result = createController(condition).listen(commands);
emitFakeCloseEvent(commands[0], { exitCode: 1 });
emitFakeCloseEvent(commands[1], { exitCode: 0 });
emitFakeCloseEvent(commands[2], { exitCode: 1 });
scheduler.flush();
return expect(result).resolves.toEqual(expect.anything());
});
it(`fails if command ${nameOrIndex} doesn't exist`, () => {
const result = createController(condition).listen([commands[0]]);
emitFakeCloseEvent(commands[0], { exitCode: 0 });
scheduler.flush();
return expect(result).rejects.toEqual(expect.anything());
});
});
describe.each([
// Use the middle command for both cases to make it more difficult to make a mess up
// in the implementation cause false passes.
['!command-bar' as const, 'bar'],
['!command-1' as const, 1],
])('with success condition set to %s', (condition, nameOrIndex) => {
it(`succeeds if all commands but ${nameOrIndex} exit with code 0`, () => {
const result = createController(condition).listen(commands);
emitFakeCloseEvent(commands[0], { exitCode: 0 });
emitFakeCloseEvent(commands[1], { exitCode: 1 });
emitFakeCloseEvent(commands[2], { exitCode: 0 });
scheduler.flush();
return expect(result).resolves.toEqual(expect.anything());
});
it(`fails if any commands but ${nameOrIndex} exit with non-0 code`, () => {
const result = createController(condition).listen(commands);
emitFakeCloseEvent(commands[0], { exitCode: 1 });
emitFakeCloseEvent(commands[1], { exitCode: 1 });
emitFakeCloseEvent(commands[2], { exitCode: 0 });
scheduler.flush();
return expect(result).rejects.toEqual(expect.anything());
});
it(`succeeds if command ${nameOrIndex} doesn't exist`, () => {
const result = createController(condition).listen([commands[0]]);
emitFakeCloseEvent(commands[0], { exitCode: 0 });
scheduler.flush();
return expect(result).resolves.toEqual(expect.anything());
});
});
});
================================================
FILE: lib/completion-listener.ts
================================================
import Rx from 'rxjs';
import { delay, filter, map, share, switchMap, take } from 'rxjs/operators';
import { CloseEvent, Command } from './command.js';
/**
* Defines which command(s) in a list must exit successfully (with an exit code of `0`):
*
* - `first`: only the first specified command;
* - `last`: only the last specified command;
* - `all`: all commands.
* - `command-{name|index}`: only the commands with the specified names or index.
* - `!command-{name|index}`: all commands but the ones with the specified names or index.
*/
export type SuccessCondition =
| 'first'
| 'last'
| 'all'
| `command-${string | number}`
| `!command-${string | number}`;
/**
* Provides logic to determine whether lists of commands ran successfully.
*/
export class CompletionListener {
private readonly successCondition: SuccessCondition;
private readonly scheduler?: Rx.SchedulerLike;
constructor({
successCondition = 'all',
scheduler,
}: {
/**
* How this instance will define that a list of commands ran successfully.
* Defaults to `all`.
*
* @see {SuccessCondition}
*/
successCondition?: SuccessCondition;
/**
* For testing only.
*/
scheduler?: Rx.SchedulerLike;
}) {
this.successCondition = successCondition;
this.scheduler = scheduler;
}
private isSuccess(events: CloseEvent[]) {
if (!events.length) {
// When every command was aborted, consider a success.
return true;
}
if (this.successCondition === 'first') {
return events[0].exitCode === 0;
} else if (this.successCondition === 'last') {
return events[events.length - 1].exitCode === 0;
}
const commandSyntaxMatch = this.successCondition.match(/^!?command-(.+)$/);
if (commandSyntaxMatch == null) {
// If not a `command-` syntax, then it's an 'all' condition or it's treated as such.
return events.every(({ exitCode }) => exitCode === 0);
}
// Check `command-` syntax condition.
// Note that a command's `name` is not necessarily unique,
// in which case all of them must meet the success condition.
const nameOrIndex = commandSyntaxMatch[1];
const targetCommandsEvents = events.filter(
({ command, index }) => command.name === nameOrIndex || index === Number(nameOrIndex),
);
if (this.successCondition.startsWith('!')) {
// All commands except the specified ones must exit successfully
return events.every(
(event) => targetCommandsEvents.includes(event) || event.exitCode === 0,
);
}
// Only the specified commands must exit successfully
return (
targetCommandsEvents.length > 0 &&
targetCommandsEvents.every((event) => event.exitCode === 0)
);
}
/**
* Given a list of commands, wait for all of them to exit and then evaluate their exit codes.
*
* @returns A Promise that resolves if the success condition is met, or rejects otherwise.
* In either case, the value is a list of close events for commands that spawned.
* Commands that didn't spawn are filtered out.
*/
listen(commands: Command[], abortSignal?: AbortSignal): Promise<CloseEvent[]> {
if (!commands.length) {
return Promise.resolve([]);
}
const abort =
abortSignal &&
Rx.fromEvent(abortSignal, 'abort', { once: true }).pipe(
// The abort signal must happen before commands are killed, otherwise new commands
// might spawn. Because of this, it's not be possible to capture the close events
// without an immediate delay
delay(0, this.scheduler),
map(() => undefined),
// #502 - node might warn of too many active listeners on this object if it isn't shared,
// as each command subscribes to abort event over and over
share(),
);
const closeStreams = commands.map((command) =>
abort
? // Commands that have been started must close.
Rx.race(command.close, abort.pipe(filter(() => command.state === 'stopped')))
: command.close,
);
return Rx.lastValueFrom(
Rx.combineLatest(closeStreams).pipe(
filter(() => commands.every((command) => command.state !== 'started')),
map((events) =>
events
// Filter out aborts, since they cannot be sorted and are considered success condition anyways
.filter((event): event is CloseEvent => event != null)
// Sort according to exit time
.sort(
(first, second) =>
first.timings.endDate.getTime() - second.timings.endDate.getTime(),
),
),
switchMap((events) =>
this.isSuccess(events)
? this.emitWithScheduler(Rx.of(events))
: this.emitWithScheduler(Rx.throwError(() => events)),
),
take(1),
),
);
}
private emitWithScheduler<O>(input: Rx.Observable<O>): Rx.Observable<O> {
return this.scheduler ? input.pipe(Rx.observeOn(this.scheduler)) : input;
}
}
================================================
FILE: lib/concurrently.spec.ts
================================================
import type { CpuInfo } from 'node:os';
import os from 'node:os';
import { Writable } from 'node:stream';
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 { concurrently, ConcurrentlyCommandInput, ConcurrentlyOptions } from './concurrently.js';
import { FlowController } from './flow-control/flow-controller.js';
import { Logger } from './logger.js';
let spawn: SpawnCommand;
let kill: KillProcess;
let onFinishHooks: Mock[];
let controllers: MockedObject<FlowController>[];
let processes: ChildProcess[];
const create = (commands: ConcurrentlyCommandInput[], options: Partial<ConcurrentlyOptions> = {}) =>
concurrently(commands, Object.assign(options, { controllers, spawn, kill }));
beforeEach(() => {
vi.resetAllMocks();
processes = [];
spawn = vi.fn(() => {
const process = createFakeProcess(processes.length);
processes.push(process);
return process;
});
kill = vi.fn();
onFinishHooks = [vi.fn(), vi.fn()];
controllers = [
{ handle: vi.fn((commands) => ({ commands, onFinish: onFinishHooks[0] })) },
{ handle: vi.fn((commands) => ({ commands, onFinish: onFinishHooks[1] })) },
];
});
it('fails if commands is not an array', () => {
const bomb = () => create('foo' as never);
expect(bomb).toThrow();
});
it('fails if no commands were provided', () => {
const bomb = () => create([]);
expect(bomb).toThrow();
});
it('spawns all commands', () => {
create(['echo', 'kill']);
expect(spawn).toHaveBeenCalledTimes(2);
expect(spawn).toHaveBeenCalledWith('echo', expect.objectContaining({}));
expect(spawn).toHaveBeenCalledWith('kill', expect.objectContaining({}));
});
it('log output is passed to output stream if logger is specified in options', () => {
const logger = new Logger({ hide: [] });
const outputStream = createMockInstance(Writable);
create(['foo'], { logger, outputStream });
logger.log('foo', 'bar');
expect(outputStream.write).toHaveBeenCalledTimes(2);
expect(outputStream.write).toHaveBeenCalledWith('foo');
expect(outputStream.write).toHaveBeenCalledWith('bar');
});
it('log output is not passed to output stream after it has errored', () => {
const logger = new Logger({ hide: [] });
const outputStream = new Writable();
vi.spyOn(outputStream, 'write');
create(['foo'], { logger, outputStream });
outputStream.emit('error', new Error('test'));
logger.log('foo', 'bar');
expect(outputStream.write).not.toHaveBeenCalled();
});
it('spawns commands up to configured limit at once', () => {
create(['foo', 'bar', 'baz', 'qux'], { maxProcesses: 2 });
expect(spawn).toHaveBeenCalledTimes(2);
expect(spawn).toHaveBeenCalledWith('foo', expect.objectContaining({}));
expect(spawn).toHaveBeenCalledWith('bar', expect.objectContaining({}));
// Test out of order completion picking up new processes in-order
processes[1].emit('close', 1, null);
expect(spawn).toHaveBeenCalledTimes(3);
expect(spawn).toHaveBeenCalledWith('baz', expect.objectContaining({}));
processes[0].emit('close', null, 'SIGINT');
expect(spawn).toHaveBeenCalledTimes(4);
expect(spawn).toHaveBeenCalledWith('qux', expect.objectContaining({}));
// Shouldn't attempt to spawn anything else.
processes[2].emit('close', 1, null);
expect(spawn).toHaveBeenCalledTimes(4);
});
it('spawns commands up to percent based limit at once', () => {
// Mock architecture with 4 cores
const cpusSpy = vi.spyOn(os, 'cpus');
cpusSpy.mockReturnValue(
Array.from<CpuInfo>({ length: 4 }).fill({
model: 'Intel',
speed: 0,
times: { user: 0, nice: 0, sys: 0, idle: 0, irq: 0 },
}),
);
create(['foo', 'bar', 'baz', 'qux'], { maxProcesses: '50%' });
// Max parallel processes should be 2 (50% of 4 cores)
expect(spawn).toHaveBeenCalledTimes(2);
expect(spawn).toHaveBeenCalledWith('foo', expect.objectContaining({}));
expect(spawn).toHaveBeenCalledWith('bar', expect.objectContaining({}));
// Close first process and expect third to be spawned
processes[0].emit('close', 1, null);
expect(spawn).toHaveBeenCalledTimes(3);
expect(spawn).toHaveBeenCalledWith('baz', expect.objectContaining({}));
// Close second process and expect fourth to be spawned
processes[1].emit('close', 1, null);
expect(spawn).toHaveBeenCalledTimes(4);
expect(spawn).toHaveBeenCalledWith('qux', expect.objectContaining({}));
});
it('does not spawn further commands on abort signal aborted', () => {
const abortController = new AbortController();
create(['foo', 'bar'], { maxProcesses: 1, abortSignal: abortController.signal });
expect(spawn).toHaveBeenCalledTimes(1);
abortController.abort();
processes[0].emit('close', 0, null);
expect(spawn).toHaveBeenCalledTimes(1);
});
it('runs controllers with the commands', () => {
create(['echo', '"echo wrapped"']);
controllers.forEach((controller) => {
expect(controller.handle).toHaveBeenCalledWith([
expect.objectContaining({ command: 'echo', index: 0 }),
expect.objectContaining({ command: 'echo wrapped', index: 1 }),
]);
});
});
it('runs commands with a name or prefix color', () => {
create([{ command: 'echo', prefixColor: 'red', name: 'foo' }, 'kill']);
controllers.forEach((controller) => {
expect(controller.handle).toHaveBeenCalledWith([
expect.objectContaining({ command: 'echo', index: 0, name: 'foo', prefixColor: 'red' }),
expect.objectContaining({ command: 'kill', index: 1, name: '', prefixColor: '' }),
]);
});
});
it('runs commands with a list of colors', () => {
create(['echo', 'kill'], {
prefixColors: ['red'],
});
controllers.forEach((controller) => {
expect(controller.handle).toHaveBeenCalledWith([
expect.objectContaining({ command: 'echo', prefixColor: 'red' }),
expect.objectContaining({ command: 'kill', prefixColor: 'red' }),
]);
});
});
it('passes commands wrapped from a controller to the next one', () => {
const fakeCommand = new FakeCommand('banana', 'banana');
controllers[0].handle.mockReturnValue({ commands: [fakeCommand] });
create(['echo']);
expect(controllers[0].handle).toHaveBeenCalledWith([
expect.objectContaining({ command: 'echo', index: 0 }),
]);
expect(controllers[1].handle).toHaveBeenCalledWith([fakeCommand]);
expect(fakeCommand.start).toHaveBeenCalledTimes(1);
});
it('merges extra env vars into each command', () => {
create([
{ command: 'echo', env: { foo: 'bar' } },
{ command: 'echo', env: { foo: 'baz' } },
'kill',
]);
expect(spawn).toHaveBeenCalledTimes(3);
expect(spawn).toHaveBeenCalledWith(
'echo',
expect.objectContaining({
env: expect.objectContaining({ foo: 'bar' }),
}),
);
expect(spawn).toHaveBeenCalledWith(
'echo',
expect.objectContaining({
env: expect.objectContaining({ foo: 'baz' }),
}),
);
expect(spawn).toHaveBeenCalledWith(
'kill',
expect.objectContaining({
env: expect.not.objectContaining({ foo: expect.anything() }),
}),
);
});
it('uses cwd from options for each command', () => {
create(
[
{ command: 'echo', env: { foo: 'bar' } },
{ command: 'echo', env: { foo: 'baz' } },
'kill',
],
{
cwd: 'foobar',
},
);
expect(spawn).toHaveBeenCalledTimes(3);
expect(spawn).toHaveBeenCalledWith(
'echo',
expect.objectContaining({
env: expect.objectContaining({ foo: 'bar' }),
cwd: 'foobar',
}),
);
expect(spawn).toHaveBeenCalledWith(
'echo',
expect.objectContaining({
env: expect.objectContaining({ foo: 'baz' }),
cwd: 'foobar',
}),
);
expect(spawn).toHaveBeenCalledWith(
'kill',
expect.objectContaining({
env: expect.not.objectContaining({ foo: expect.anything() }),
cwd: 'foobar',
}),
);
});
it('uses overridden cwd option for each command if specified', () => {
create(
[
{ command: 'echo', env: { foo: 'bar' }, cwd: 'baz' },
{ command: 'echo', env: { foo: 'baz' } },
],
{
cwd: 'foobar',
},
);
expect(spawn).toHaveBeenCalledTimes(2);
expect(spawn).toHaveBeenCalledWith(
'echo',
expect.objectContaining({
env: expect.objectContaining({ foo: 'bar' }),
cwd: 'baz',
}),
);
expect(spawn).toHaveBeenCalledWith(
'echo',
expect.objectContaining({
env: expect.objectContaining({ foo: 'baz' }),
cwd: 'foobar',
}),
);
});
it('uses raw from options for each command', () => {
create([{ command: 'echo' }, 'kill'], {
raw: true,
});
expect(spawn).toHaveBeenCalledTimes(2);
expect(spawn).toHaveBeenCalledWith(
'echo',
expect.objectContaining({
stdio: ['inherit', 'inherit', 'inherit'],
}),
);
expect(spawn).toHaveBeenCalledWith(
'kill',
expect.objectContaining({
stdio: ['inherit', 'inherit', 'inherit'],
}),
);
});
it('uses overridden raw option for each command if specified', () => {
create([{ command: 'echo', raw: false }, { command: 'echo' }], {
raw: true,
});
expect(spawn).toHaveBeenCalledTimes(2);
expect(spawn).toHaveBeenCalledWith(
'echo',
expect.objectContaining({
stdio: ['pipe', 'pipe', 'pipe'],
}),
);
expect(spawn).toHaveBeenCalledWith(
'echo',
expect.objectContaining({
stdio: ['inherit', 'inherit', 'inherit'],
}),
);
});
it('uses hide from options for each command', () => {
create([{ command: 'echo' }, 'kill'], {
hide: [1],
});
expect(spawn).toHaveBeenCalledTimes(2);
expect(spawn).toHaveBeenCalledWith(
'echo',
expect.objectContaining({
stdio: ['pipe', 'pipe', 'pipe'],
}),
);
expect(spawn).toHaveBeenCalledWith(
'kill',
expect.objectContaining({
stdio: ['pipe', 'ignore', 'ignore'],
}),
);
});
it('hides output for commands even if raw option is on', () => {
create([{ command: 'echo' }, 'kill'], {
hide: [1],
raw: true,
});
expect(spawn).toHaveBeenCalledTimes(2);
expect(spawn).toHaveBeenCalledWith(
'echo',
expect.objectContaining({
stdio: ['inherit', 'inherit', 'inherit'],
}),
);
expect(spawn).toHaveBeenCalledWith(
'kill',
expect.objectContaining({
stdio: ['pipe', 'ignore', 'ignore'],
}),
);
});
it('argument placeholders are properly replaced when additional arguments are passed', () => {
create(
[
{ command: 'echo {1}' },
{ command: 'echo {@}' },
{ command: 'echo {*}' },
{ command: 'echo \\{@}' },
],
{
additionalArguments: ['foo', 'bar'],
},
);
expect(spawn).toHaveBeenCalledTimes(4);
expect(spawn).toHaveBeenCalledWith('echo foo', expect.objectContaining({}));
expect(spawn).toHaveBeenCalledWith('echo foo bar', expect.objectContaining({}));
expect(spawn).toHaveBeenCalledWith("echo 'foo bar'", expect.objectContaining({}));
expect(spawn).toHaveBeenCalledWith('echo {@}', expect.objectContaining({}));
});
it('argument placeholders are not replaced when additional arguments are not defined', () => {
create([
{ command: 'echo {1}' },
{ command: 'echo {@}' },
{ command: 'echo {*}' },
{ command: 'echo \\{@}' },
]);
expect(spawn).toHaveBeenCalledTimes(4);
expect(spawn).toHaveBeenCalledWith('echo {1}', expect.objectContaining({}));
expect(spawn).toHaveBeenCalledWith('echo {@}', expect.objectContaining({}));
expect(spawn).toHaveBeenCalledWith('echo {*}', expect.objectContaining({}));
expect(spawn).toHaveBeenCalledWith('echo {@}', expect.objectContaining({}));
});
it('runs onFinish hook after all commands run', async () => {
const promise = create(['foo', 'bar'], { maxProcesses: 1 });
expect(spawn).toHaveBeenCalledTimes(1);
expect(onFinishHooks[0]).not.toHaveBeenCalled();
expect(onFinishHooks[1]).not.toHaveBeenCalled();
processes[0].emit('close', 0, null);
expect(spawn).toHaveBeenCalledTimes(2);
expect(onFinishHooks[0]).not.toHaveBeenCalled();
expect(onFinishHooks[1]).not.toHaveBeenCalled();
processes[1].emit('close', 0, null);
await promise.result;
expect(onFinishHooks[0]).toHaveBeenCalled();
expect(onFinishHooks[1]).toHaveBeenCalled();
});
// This test should time out if broken
it('waits for onFinish hooks to complete before resolving', async () => {
onFinishHooks[0].mockResolvedValue(undefined);
const { result } = create(['foo', 'bar']);
processes[0].emit('close', 0, null);
processes[1].emit('close', 0, null);
await expect(result).resolves.toBeDefined();
});
it('rejects if onFinish hooks reject', async () => {
onFinishHooks[0].mockRejectedValue('error');
const { result } = create(['foo', 'bar']);
processes[0].emit('close', 0, null);
processes[1].emit('close', 0, null);
await expect(result).rejects.toBe('error');
});
================================================
FILE: lib/concurrently.ts
================================================
import assert from 'node:assert';
import os from 'node:os';
import { Writable } from 'node:stream';
import { takeUntil } from 'rxjs';
import treeKill from 'tree-kill';
import {
CloseEvent,
Command,
CommandIdentifier,
CommandInfo,
KillProcess,
SpawnCommand,
} from './command.js';
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';
import { OutputWriter } from './output-writer.js';
import { PrefixColorSelector } from './prefix-color-selector.js';
import { getSpawnOpts, spawn } from './spawn.js';
import { castArray } from './utils.js';
const defaults: ConcurrentlyOptions = {
spawn,
kill: treeKill,
raw: false,
controllers: [],
cwd: undefined,
};
/**
* A command that is to be passed into `concurrently()`.
* If value is a string, then that's the command's command line.
* Fine grained options can be defined by using the object format.
*/
export type ConcurrentlyCommandInput = string | ({ command: string } & Partial<CommandInfo>);
export interface ConcurrentlyResult {
/**
* All commands created and ran by concurrently.
*/
commands: Command[];
/**
* A promise that resolves when concurrently ran successfully according to the specified
* success condition, or reject otherwise.
*
* Both the resolved and rejected value is a list of all the close events for commands that
* spawned; commands that didn't spawn are filtered out.
*/
result: Promise<CloseEvent[]>;
}
export interface ConcurrentlyOptions {
logger?: Logger;
/**
* Which stream should the commands output be written to.
*/
outputStream?: Writable;
/**
* Whether the output should be ordered as if the commands were run sequentially.
*/
group?: boolean;
/**
* A comma-separated list of Chalk colors or a string for available styles listed below to use on prefixes.
* If there are more commands than colors, the last color will be repeated.
*
* Available modifiers:
* - `reset`, `bold`, `dim`, `italic`, `underline`, `inverse`, `hidden`, `strikethrough`
*
* Available colors:
* - `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`, `gray`,
* any hex values for colors (e.g. `#23de43`) or `auto` for an automatically picked color
*
* Available background colors:
* - `bgBlack`, `bgRed`, `bgGreen`, `bgYellow`, `bgBlue`, `bgMagenta`, `bgCyan`, `bgWhite`
*
* Set to `false` to disable colors.
*
* @see {@link https://www.npmjs.com/package/chalk} for more information.
*/
prefixColors?: string | string[] | false;
/**
* Maximum number of commands to run at once.
* Exact number or a percent of CPUs available (for example "50%").
*
* If undefined, then all processes will start in parallel.
* Setting this value to 1 will achieve sequential running.
*/
maxProcesses?: number | string;
/**
* Whether commands should be spawned in raw mode.
* Defaults to false.
*/
raw?: boolean;
/**
* Which commands should have their output hidden.
*/
hide?: CommandIdentifier[];
/**
* The current working directory of commands which didn't specify one.
* Defaults to `process.cwd()`.
*/
cwd?: string;
/**
* @see CompletionListener
*/
successCondition?: SuccessCondition;
/**
* A signal to stop spawning further processes.
*/
abortSignal?: AbortSignal;
/**
* Which flow controllers should be applied on commands spawned by concurrently.
* Defaults to an empty array.
*/
controllers: FlowController[];
/**
* A function that will spawn commands.
* Defaults to a function that spawns using either `cmd.exe` or `/bin/sh`.
*/
spawn: SpawnCommand;
/**
* A function that will kill processes.
* Defaults to the `tree-kill` module.
*/
kill: KillProcess;
/**
* List of additional arguments passed that will get replaced in each command.
* If not defined, no argument replacing will happen.
*
* @see ExpandArguments
*/
additionalArguments?: string[];
}
/**
* Core concurrently functionality -- spawns the given commands concurrently and
* returns the commands themselves + the result according to the specified success condition.
*
* @see CompletionListener
*/
export function concurrently(
baseCommands: ConcurrentlyCommandInput[],
baseOptions?: Partial<ConcurrentlyOptions>,
): ConcurrentlyResult {
assert.ok(Array.isArray(baseCommands), '[concurrently] commands should be an array');
assert.notStrictEqual(baseCommands.length, 0, '[concurrently] no commands provided');
const options = { ...defaults, ...baseOptions };
const prefixColorSelector = new PrefixColorSelector(options.prefixColors || []);
const commandParsers: CommandParser[] = [
new StripQuotes(),
new ExpandShortcut(),
new ExpandWildcard(),
];
if (options.additionalArguments) {
commandParsers.push(new ExpandArguments(options.additionalArguments));
}
const hide = (options.hide || []).map(String);
let commands = baseCommands
.map(mapToCommandInfo)
.flatMap((command) => parseCommand(command, commandParsers))
.map((command, index) => {
const hidden = hide.includes(command.name) || hide.includes(String(index));
return new Command(
{
index,
prefixColor: prefixColorSelector.getNextColor(),
...command,
},
getSpawnOpts({
ipc: command.ipc,
stdio: hidden ? 'hidden' : (command.raw ?? options.raw) ? 'raw' : 'normal',
env: command.env,
cwd: command.cwd || options.cwd,
}),
options.spawn,
options.kill,
);
});
const handleResult = options.controllers.reduce(
({ commands: prevCommands, onFinishCallbacks }, controller) => {
const { commands, onFinish } = controller.handle(prevCommands);
return {
commands,
onFinishCallbacks: onFinishCallbacks.concat(onFinish ? [onFinish] : []),
};
},
{ commands, onFinishCallbacks: [] } as {
commands: Command[];
onFinishCallbacks: (() => void)[];
},
);
commands = handleResult.commands;
if (options.logger && options.outputStream) {
const outputWriter = new OutputWriter({
outputStream: options.outputStream,
group: !!options.group,
commands,
});
options.logger.output
// Stop trying to write after there's been an error.
.pipe(takeUntil(outputWriter.error))
.subscribe(({ command, text }) => outputWriter.write(command, text));
}
const commandsLeft = commands.slice();
const maxProcesses = Math.max(
1,
(typeof options.maxProcesses === 'string' && options.maxProcesses.endsWith('%')
? Math.round((os.cpus().length * Number(options.maxProcesses.slice(0, -1))) / 100)
: Number(options.maxProcesses)) || commandsLeft.length,
);
for (let i = 0; i < maxProcesses; i++) {
maybeRunMore(commandsLeft, options.abortSignal);
}
const result = new CompletionListener({ successCondition: options.successCondition })
.listen(commands, options.abortSignal)
.finally(() => Promise.all(handleResult.onFinishCallbacks.map((onFinish) => onFinish())));
return {
result,
commands,
};
}
function mapToCommandInfo(command: ConcurrentlyCommandInput): CommandInfo {
if (typeof command === 'string') {
return mapToCommandInfo({ command });
}
assert.ok(command.command, '[concurrently] command cannot be empty');
return {
command: command.command,
name: command.name || '',
env: command.env || {},
cwd: command.cwd || '',
ipc: command.ipc,
...(command.prefixColor
? {
prefixColor: command.prefixColor,
}
: {}),
...(command.raw !== undefined
? {
raw: command.raw,
}
: {}),
};
}
function parseCommand(command: CommandInfo, parsers: CommandParser[]) {
return parsers.reduce(
(commands, parser) => commands.flatMap((command) => parser.parse(command)),
castArray(command),
);
}
function maybeRunMore(commandsLeft: Command[], abortSignal?: AbortSignal) {
const command = commandsLeft.shift();
if (!command || abortSignal?.aborted) {
return;
}
command.start();
command.close.subscribe(() => {
maybeRunMore(commandsLeft, abortSignal);
});
}
================================================
FILE: lib/date-format.spec.ts
================================================
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { DateFormatter, FormatterOptions } from './date-format.js';
const withTime = (time: string) => `2000-01-01T${time}`;
const withDate = (date: string) => `${date}T00:00:00`;
type TokenTests = undefined | { input: string; expected: string }[];
/**
* Generates a suite of tests for token `token`.
*
* Each entry in `patternTests` makes the token longer, e.g.
* ```
* makeTests('year', 'y', [
* [{ expected: '2', input: withDate('0002-01-01') }], // y
* [{ expected: '02', input: withDate('0002-01-01') }], // yy
* // ...
* ]);
* ```
*/
const makeTests = (
name: string,
token: string,
patternTests: TokenTests[],
options?: FormatterOptions,
) =>
describe(`${name}`, () => {
patternTests.forEach((tests, i) => {
const pattern = token.repeat(i + 1);
if (!tests) {
return it(`is not implemented for ${pattern}`, () => {
expect(() => new DateFormatter(pattern)).toThrow(RangeError);
});
} else if (!tests.length) {
return;
}
it.each(tests)(
`for pattern ${pattern} and input "$input" returns "$expected"`,
({ expected, input }) => {
const formatter = new DateFormatter(pattern, {
locale: 'en',
calendar: 'gregory',
...options,
});
expect(formatter.format(new Date(input))).toBe(expected);
},
);
});
});
describe('combined', () => {
it('works with tokens and punctuation', () => {
const formatter = new DateFormatter('yyyy-MM-dd HH:mm:ss', { locale: 'en' });
const date = new Date(2024, 8, 1, 15, 30, 50);
expect(formatter.format(date)).toBe('2024-09-01 15:30:50');
});
it('works with tokens and literals', () => {
const formatter = new DateFormatter("HH 'o''clock'", { locale: 'en' });
const date = new Date();
date.setHours(10);
expect(formatter.format(date)).toBe("10 o'clock");
});
});
describe('literals', () => {
it.each([
["'", "''"],
['foo bar', "'foo bar'"],
["foo ' bar", "'foo '' bar'"],
["foo bar'?", "'foo bar'''?"],
])('returns "%s" for pattern "%s"', (expected, pattern) => {
const formatter = new DateFormatter(pattern);
expect(formatter.format(new Date())).toBe(expected);
});
});
describe('tokens', () => {
it('throws if a token does not exist', () => {
expect(() => new DateFormatter('t')).toThrow(SyntaxError);
});
makeTests('era', 'G', [
[
{ input: withDate('-000001-01-01'), expected: 'BC' },
{ input: withDate('0001-01-01'), expected: 'AD' },
],
[
{ input: withDate('-000001-01-01'), expected: 'BC' },
{ input: withDate('0001-01-01'), expected: 'AD' },
],
[
{ input: withDate('-000001-01-01'), expected: 'BC' },
{ input: withDate('0001-01-01'), expected: 'AD' },
],
[
{ input: withDate('-000001-01-01'), expected: 'Before Christ' },
{ input: withDate('0001-01-01'), expected: 'Anno Domini' },
],
[
{ input: withDate('-000001-01-01'), expected: 'B' },
{ input: withDate('0001-01-01'), expected: 'A' },
],
]);
makeTests('year', 'y', [
[
{ expected: '2', input: withDate('0002-01-01') },
{ expected: '20', input: withDate('0020-01-01') },
{ expected: '200', input: withDate('0200-01-01') },
{ expected: '2000', input: withDate('2000-01-01') },
],
[
{ expected: '02', input: withDate('0002-01-01') },
{ expected: '20', input: withDate('0020-01-01') },
{ expected: '00', input: withDate('0200-01-01') },
{ expected: '05', input: withDate('0205-01-01') },
{ expected: '00', input: withDate('2000-01-01') },
{ expected: '05', input: withDate('2005-01-01') },
],
[
{ expected: '002', input: withDate('0002-01-01') },
{ expected: '020', input: withDate('0020-01-01') },
{ expected: '200', input: withDate('0200-01-01') },
{ expected: '2000', input: withDate('2000-01-01') },
],
[
{ expected: '0002', input: withDate('0002-01-01') },
{ expected: '0020', input: withDate('0020-01-01') },
{ expected: '0200', input: withDate('0200-01-01') },
{ expected: '2000', input: withDate('2000-01-01') },
],
[
{ expected: '00002', input: withDate('0002-01-01') },
{ expected: '00020', input: withDate('0020-01-01') },
{ expected: '00200', input: withDate('0200-01-01') },
{ expected: '02000', input: withDate('2000-01-01') },
],
]);
describe('year name', () => {
makeTests('with en locale', 'U', [[{ expected: '2024', input: withDate('2024-01-01') }]], {
locale: 'en',
});
makeTests(
'with zh-CN locale + chinese calendar',
'U',
[[{ expected: '癸卯', input: withDate('2024-01-01') }]],
{ locale: 'zh-CN', calendar: 'chinese' },
);
});
describe('related year', () => {
makeTests('with en locale', 'r', [[{ expected: '2024', input: withDate('2024-01-01') }]], {
locale: 'en',
});
makeTests(
'with zh-CN locale + chinese calendar',
'r',
[
[
{ expected: '2023', input: withDate('2024-01-01') },
{ expected: '2024', input: withDate('2024-03-01') },
],
],
{ locale: 'zh-CN', calendar: 'chinese' },
);
});
describe('week year', () => {
makeTests(
'with en locale',
'Y',
[
[
{ expected: '2023', input: withDate('2023-01-01') },
{ expected: '2024', input: withDate('2023-12-31') },
{ expected: '2024', input: withDate('2024-01-01') },
{ expected: '2025', input: withDate('2024-12-31') },
],
[
{ expected: '23', input: withDate('2023-01-01') },
{ expected: '24', input: withDate('2023-12-31') },
{ expected: '24', input: withDate('2024-01-01') },
{ expected: '25', input: withDate('2024-12-31') },
],
],
{ locale: 'en' },
);
makeTests(
'with de-DE locale',
'Y',
[
[
{ expected: '2022', input: withDate('2023-01-01') },
{ expected: '2023', input: withDate('2023-12-31') },
{ expected: '2024', input: withDate('2024-01-01') },
{ expected: '2025', input: withDate('2024-12-31') },
],
[
{ expected: '22', input: withDate('2023-01-01') },
{ expected: '23', input: withDate('2023-12-31') },
{ expected: '24', input: withDate('2024-01-01') },
{ expected: '25', input: withDate('2024-12-31') },
],
],
{ locale: 'de-DE' },
);
describe(`when minimalDays is missing`, () => {
beforeAll(() => {
if (typeof Intl.Locale.prototype.getWeekInfo === 'function') {
Intl.Locale.prototype.getWeekInfoOrig = Intl.Locale.prototype.getWeekInfo;
}
Intl.Locale.prototype.getWeekInfo = function () {
const data =
typeof Intl.Locale.prototype.getWeekInfoOrig === 'function'
? this.getWeekInfoOrig()
: this.weekInfo;
delete data.minimalDays;
return data;
};
});
afterAll(() => {
if (Intl.Locale.prototype.getWeekInfoOrig) {
Intl.Locale.prototype.getWeekInfo = Intl.Locale.prototype.getWeekInfoOrig;
delete Intl.Locale.prototype.getWeekInfoOrig;
}
});
makeTests(
// Needs to be a different locale than in tests above to not use cached weekInfo
'with de-CH locale',
'Y',
[
[
{ expected: '2022', input: withDate('2023-01-01') },
{ expected: '2023', input: withDate('2023-12-31') },
{ expected: '2024', input: withDate('2024-01-01') },
{ expected: '2025', input: withDate('2024-12-31') },
],
[
{ expected: '22', input: withDate('2023-01-01') },
{ expected: '23', input: withDate('2023-12-31') },
{ expected: '24', input: withDate('2024-01-01') },
{ expected: '25', input: withDate('2024-12-31') },
],
],
{ locale: 'de-CH' },
);
});
});
makeTests('quarter', 'Q', [
[
{ expected: '1', input: withDate('2000-01-01') },
{ expected: '2', input: withDate('2000-04-01') },
{ expected: '3', input: withDate('2000-07-01') },
{ expected: '4', input: withDate('2000-10-01') },
],
[
{ expected: '01', input: withDate('2000-01-01') },
{ expected: '02', input: withDate('2000-04-01') },
{ expected: '03', input: withDate('2000-07-01') },
{ expected: '04', input: withDate('2000-10-01') },
],
undefined,
undefined,
[
{ expected: '1', input: withDate('2000-01-01') },
{ expected: '2', input: withDate('2000-04-01') },
{ expected: '3', input: withDate('2000-07-01') },
{ expected: '4', input: withDate('2000-10-01') },
],
]);
makeTests('quarter - stand-alone', 'q', [
[
{ expected: '1', input: withDate('2000-01-01') },
{ expected: '2', input: withDate('2000-04-01') },
{ expected: '3', input: withDate('2000-07-01') },
{ expected: '4', input: withDate('2000-10-01') },
],
[
{ expected: '01', input: withDate('2000-01-01') },
{ expected: '02', input: withDate('2000-04-01') },
{ expected: '03', input: withDate('2000-07-01') },
{ expected: '04', input: withDate('2000-10-01') },
],
undefined,
undefined,
[
{ expected: '1', input: withDate('2000-01-01') },
{ expected: '2', input: withDate('2000-04-01') },
{ expected: '3', input: withDate('2000-07-01') },
{ expected: '4', input: withDate('2000-10-01') },
],
]);
describe('month', () => {
makeTests(
'with en locale',
'M',
[
[{ expected: '1', input: withDate('2000-01-01') }],
[{ expected: '01', input: withDate('2000-01-01') }],
[{ expected: 'Jan', input: withDate('2000-01-01') }],
[{ expected: 'January', input: withDate('2000-01-01') }],
[{ expected: 'J', input: withDate('2000-01-01') }],
],
{ locale: 'en' },
);
makeTests(
'with pl locale',
'M',
[
[{ expected: '1', input: withDate('2000-01-01') }],
[{ expected: '01', input: withDate('2000-01-01') }],
[{ expected: 'sty', input: withDate('2000-01-01') }],
[{ expected: 'stycznia', input: withDate('2000-01-01') }],
[{ expected: 's', input: withDate('2000-01-01') }],
],
{ locale: 'pl' },
);
});
describe('month - stand-alone', () => {
makeTests(
'with en locale',
'L',
[
[{ expected: '1', input: withDate('2000-01-01') }],
[{ expected: '01', input: withDate('2000-01-01') }],
[{ expected: 'Jan', input: withDate('2000-01-01') }],
[{ expected: 'January', input: withDate('2000-01-01') }],
[{ expected: 'J', input: withDate('2000-01-01') }],
],
{ locale: 'en' },
);
makeTests(
'with pl locale',
'L',
[
[{ expected: '1', input: withDate('2000-01-01') }],
[{ expected: '01', input: withDate('2000-01-01') }],
[{ expected: 'sty', input: withDate('2000-01-01') }],
[{ expected: 'styczeń', input: withDate('2000-01-01') }],
[{ expected: 'S', input: withDate('2000-01-01') }],
],
{ locale: 'pl' },
);
});
describe('week of year', () => {
makeTests(
'with en locale',
'w',
[
[
{ expected: '1', input: withDate('2023-01-01') },
{ expected: '1', input: withDate('2023-12-31') },
{ expected: '1', input: withDate('2024-01-01') },
{ expected: '1', input: withDate('2024-12-31') },
],
[
{ expected: '01', input: withDate('2023-01-01') },
{ expected: '01', input: withDate('2023-12-31') },
{ expected: '01', input: withDate('2024-01-01') },
{ expected: '01', input: withDate('2024-12-31') },
],
],
{ locale: 'en' },
);
makeTests(
'with de-DE locale',
'w',
[
[
{ expected: '52', input: withDate('2023-01-01') },
{ expected: '52', input: withDate('2023-12-31') },
{ expected: '1', input: withDate('2024-01-01') },
{ expected: '1', input: withDate('2024-12-31') },
],
[
{ expected: '52', input: withDate('2023-01-01') },
{ expected: '52', input: withDate('2023-12-31') },
{ expected: '01', input: withDate('2024-01-01') },
{ expected: '01', input: withDate('2024-12-31') },
],
],
{ locale: 'de-DE' },
);
});
describe('week of month', () => {
makeTests(
'with en locale',
'W',
[
[
{ expected: '6', input: withDate('2021-01-31') },
{ expected: '5', input: withDate('2021-02-28') },
],
],
{ locale: 'en' },
);
makeTests(
'with de-DE locale',
'W',
[
[
{ expected: '5', input: withDate('2021-01-31') },
{ expected: '4', input: withDate('2021-02-28') },
],
],
{ locale: 'de-DE' },
);
});
makeTests('day', 'd', [
[
{ expected: '1', input: withDate('2000-01-01') },
{ expected: '10', input: withDate('2000-01-10') },
],
[
{ expected: '01', input: withDate('2000-01-01') },
{ expected: '10', input: withDate('2000-01-10') },
],
]);
makeTests('day of week in month', 'F', [
[
{ expected: '1', input: withDate('2024-09-01') },
{ expected: '2', input: withDate('2024-09-08') },
],
]);
makeTests('day of year', 'D', [
[
{ expected: '1', input: withDate('2024-01-01') },
{ expected: '32', input: withDate('2024-02-01') },
{ expected: '366', input: withDate('2024-12-31') },
],
[
{ expected: '01', input: withDate('2024-01-01') },
{ expected: '32', input: withDate('2024-02-01') },
{ expected: '366', input: withDate('2024-12-31') },
],
[
{ expected: '001', input: withDate('2024-01-01') },
{ expected: '032', input: withDate('2024-02-01') },
{ expected: '366', input: withDate('2024-12-31') },
],
]);
makeTests('week day', 'E', [
[{ expected: 'Sat', input: withDate('2024-09-07') }],
[{ expected: 'Sat', input: withDate('2024-09-07') }],
[{ expected: 'Sat', input: withDate('2024-09-07') }],
[{ expected: 'Saturday', input: withDate('2024-09-07') }],
]);
makeTests('local week day', 'e', [
undefined,
undefined,
[{ expected: 'Sat', input: withDate('2024-09-07') }],
[{ expected: 'Saturday', input: withDate('2024-09-07') }],
]);
makeTests('period', 'a', [
[
{ expected: 'AM', input: withTime('10:00:00') },
{ expected: 'PM', input: withTime('12:00:00') },
],
[
{ expected: 'AM', input: withTime('10:00:00') },
{ expected: 'PM', input: withTime('12:00:00') },
],
[
{ expected: 'AM', input: withTime('10:00:00') },
{ expected: 'PM', input: withTime('12:00:00') },
],
]);
makeTests('flexible day period', 'B', [
[
{ expected: 'in the morning', input: withTime('06:00:00') },
{ expected: 'noon', input: withTime('12:00:00') },
{ expected: 'in the afternoon', input: withTime('16:00:00') },
{ expected: 'at night', input: withTime('23:00:00') },
],
[],
[],
[
{ expected: 'in the morning', input: withTime('06:00:00') },
{ expected: 'noon', input: withTime('12:00:00') },
{ expected: 'in the afternoon', input: withTime('16:00:00') },
{ expected: 'at night', input: withTime('23:00:00') },
],
]);
describe('hour', () => {
makeTests('1-12 format (1 PM)', 'h', [
[{ expected: '1', input: withTime('13:00:00') }],
[{ expected: '01', input: withTime('13:00:00') }],
]);
makeTests('1-12 format (12 PM)', 'h', [
[{ expected: '12', input: withTime('00:00:00') }],
[{ expected: '12', input: withTime('00:00:00') }],
]);
makeTests('0-23 format', 'H', [
[
{ expected: '0', input: withTime('00:00:00') },
{ expected: '13', input: withTime('13:00:00') },
],
[
{ expected: '00', input: withTime('00:00:00') },
{ expected: '13', input: withTime('13:00:00') },
],
]);
makeTests('0-11 format', 'K', [
[
{ expected: '0', input: withTime('00:00:00') },
{ expected: '1', input: withTime('13:00:00') },
],
[
{ expected: '00', input: withTime('00:00:00') },
{ expected: '01', input: withTime('13:00:00') },
],
]);
makeTests('1-24 format', 'k', [
[
{ expected: '13', input: withTime('13:00:00') },
{ expected: '24', input: withTime('00:00:00') },
],
[
{ expected: '13', input: withTime('13:00:00') },
{ expected: '24', input: withTime('00:00:00') },
],
]);
});
makeTests('minute', 'm', [
[
{ expected: '0', input: withTime('00:00:00') },
{ expected: '59', input: withTime('00:59:00') },
],
[
{ expected: '00', input: withTime('00:00:00') },
{ expected: '59', input: withTime('00:59:00') },
],
]);
makeTests('seconds', 's', [
[
{ expected: '0', input: withTime('00:00:00') },
{ expected: '59', input: withTime('00:00:59') },
],
[
{ expected: '00', input: withTime('00:00:00') },
{ expected: '59', input: withTime('00:00:59') },
],
]);
makeTests('fractional seconds', 'S', [
[
{ expected: '0', input: withTime('00:00:00.000') },
{ expected: '0', input: withTime('00:00:00.001') },
{ expected: '0', input: withTime('00:00:00.010') },
{ expected: '1', input: withTime('00:00:00.100') },
],
[
{ expected: '00', input: withTime('00:00:00.000') },
{ expected: '00', input: withTime('00:00:00.001') },
{ expected: '01', input: withTime('00:00:00.010') },
{ expected: '10', input: withTime('00:00:00.100') },
],
[
{ expected: '000', input: withTime('00:00:00.000') },
{ expected: '001', input: withTime('00:00:00.001') },
{ expected: '010', input: withTime('00:00:00.010') },
{ expected: '100', input: withTime('00:00:00.100') },
],
]);
});
================================================
FILE: lib/date-format.ts
================================================
export interface FormatterOptions {
locale?: string;
calendar?: string;
}
type TokenFormatter = (date: Date, options: FormatterOptions) => string | number;
/**
* A map of token to its implementations by length.
* If an index is undefined, then that token length is unsupported.
*/
const tokens = new Map<string, (TokenFormatter | undefined)[]>()
// era
.set('G', [
makeTokenFn({ era: 'short' }, 'era'),
makeTokenFn({ era: 'short' }, 'era'),
makeTokenFn({ era: 'short' }, 'era'),
makeTokenFn({ era: 'long' }, 'era'),
makeTokenFn({ era: 'narrow' }, 'era'),
])
// year
.set('y', [
// TODO: does not support BC years.
// https://stackoverflow.com/a/41345095/2083599
(date) => date.getFullYear(),
(date) => pad(2, date.getFullYear()).slice(-2),
(date) => pad(3, date.getFullYear()),
(date) => pad(4, date.getFullYear()),
(date) => pad(5, date.getFullYear()),
])
.set('Y', [
getWeekYear,
(date, options) => pad(2, getWeekYear(date, options)).slice(-2),
(date, options) => pad(3, getWeekYear(date, options)),
(date, options) => pad(4, getWeekYear(date, options)),
(date, options) => pad(5, getWeekYear(date, options)),
])
.set('u', [])
.set('U', [
// Fallback implemented as yearName is not available in gregorian calendars, for instance.
makeTokenFn({ dateStyle: 'full' }, 'yearName', (date) => String(date.getFullYear())),
])
.set('r', [
// Fallback implemented as relatedYear is not available in gregorian calendars, for instance.
makeTokenFn({ dateStyle: 'full' }, 'relatedYear', (date) => String(date.getFullYear())),
])
// quarter
.set('Q', [
(date) => Math.floor(date.getMonth() / 3) + 1,
(date) => pad(2, Math.floor(date.getMonth() / 3) + 1),
// these aren't localized in Intl.DateTimeFormat.
undefined,
undefined,
(date) => Math.floor(date.getMonth() / 3) + 1,
])
.set('q', [
(date) => Math.floor(date.getMonth() / 3) + 1,
(date) => pad(2, Math.floor(dat
gitextract_injgqv22/ ├── .editorconfig ├── .gitattributes ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug-report.yml │ │ ├── config.yml │ │ └── feature-request.yml │ ├── actions/ │ │ └── setup/ │ │ └── action.yml │ └── workflows/ │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .husky/ │ └── pre-commit ├── .node-version ├── .prettierignore ├── .prettierrc.json ├── .vscode/ │ ├── extensions.json │ └── settings.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── bin/ │ ├── __fixtures__/ │ │ ├── read-echo.js │ │ └── sleep.js │ ├── index.spec.ts │ ├── index.ts │ └── read-package-json.ts ├── docs/ │ ├── README.md │ └── cli/ │ ├── configuration.md │ ├── input-handling.md │ ├── output-control.md │ ├── passthrough-arguments.md │ ├── prefixing.md │ ├── restarting.md │ ├── shortcuts.md │ ├── success.md │ └── terminating.md ├── eslint.config.js ├── lib/ │ ├── __fixtures__/ │ │ ├── create-mock-instance.ts │ │ └── fake-command.ts │ ├── assert.spec.ts │ ├── assert.ts │ ├── command-parser/ │ │ ├── command-parser.d.ts │ │ ├── expand-arguments.spec.ts │ │ ├── expand-arguments.ts │ │ ├── expand-shortcut.spec.ts │ │ ├── expand-shortcut.ts │ │ ├── expand-wildcard.spec.ts │ │ ├── expand-wildcard.ts │ │ ├── strip-quotes.spec.ts │ │ └── strip-quotes.ts │ ├── command.spec.ts │ ├── command.ts │ ├── completion-listener.spec.ts │ ├── completion-listener.ts │ ├── concurrently.spec.ts │ ├── concurrently.ts │ ├── date-format.spec.ts │ ├── date-format.ts │ ├── declarations/ │ │ └── intl.d.ts │ ├── defaults.ts │ ├── flow-control/ │ │ ├── flow-controller.d.ts │ │ ├── input-handler.spec.ts │ │ ├── input-handler.ts │ │ ├── kill-on-signal.spec.ts │ │ ├── kill-on-signal.ts │ │ ├── kill-others.spec.ts │ │ ├── kill-others.ts │ │ ├── log-error.spec.ts │ │ ├── log-error.ts │ │ ├── log-exit.spec.ts │ │ ├── log-exit.ts │ │ ├── log-output.spec.ts │ │ ├── log-output.ts │ │ ├── log-timings.spec.ts │ │ ├── log-timings.ts │ │ ├── logger-padding.spec.ts │ │ ├── logger-padding.ts │ │ ├── output-error-handler.spec.ts │ │ ├── output-error-handler.ts │ │ ├── restart-process.spec.ts │ │ ├── restart-process.ts │ │ ├── teardown.spec.ts │ │ └── teardown.ts │ ├── index.ts │ ├── jsonc.spec.ts │ ├── jsonc.ts │ ├── logger.spec.ts │ ├── logger.ts │ ├── observables.spec.ts │ ├── observables.ts │ ├── output-writer.spec.ts │ ├── output-writer.ts │ ├── prefix-color-selector.spec.ts │ ├── prefix-color-selector.ts │ ├── spawn.spec.ts │ ├── spawn.ts │ ├── utils.spec.ts │ └── utils.ts ├── package.json ├── pnpm-workspace.yaml ├── tests/ │ ├── cjs-import/ │ │ ├── package.json │ │ ├── smoke-test.ts │ │ └── tsconfig.json │ ├── cjs-require/ │ │ ├── package.json │ │ ├── smoke-test.ts │ │ └── tsconfig.json │ ├── esm/ │ │ ├── package.json │ │ ├── smoke-test.ts │ │ └── tsconfig.json │ ├── package.json │ └── smoke-tests.spec.ts ├── tsconfig.json └── vitest.config.ts
SYMBOL INDEX (161 symbols across 37 files)
FILE: bin/read-package-json.ts
function readPackageJson (line 7) | function readPackageJson(): Record<string, unknown> {
FILE: lib/__fixtures__/create-mock-instance.ts
function createMockInstance (line 4) | function createMockInstance<T>(constructor: new (...args: any[]) => T): ...
FILE: lib/__fixtures__/fake-command.ts
class FakeCommand (line 9) | class FakeCommand extends Command {
method constructor (line 10) | constructor(name = 'foo', command = 'echo foo', index = 0, info?: Part...
FILE: lib/assert.ts
function assertDeprecated (line 7) | function assertDeprecated(check: boolean, name: string, message: string) {
FILE: lib/command-parser/command-parser.d.ts
type CommandParser (line 10) | interface CommandParser {
FILE: lib/command-parser/expand-arguments.ts
class ExpandArguments (line 9) | class ExpandArguments implements CommandParser {
method constructor (line 10) | constructor(private readonly additionalArguments: string[]) {}
method parse (line 12) | parse(commandInfo: CommandInfo) {
FILE: lib/command-parser/expand-shortcut.ts
class ExpandShortcut (line 16) | class ExpandShortcut implements CommandParser {
method parse (line 17) | parse(commandInfo: CommandInfo) {
FILE: lib/command-parser/expand-wildcard.ts
constant OMISSION (line 9) | const OMISSION = /\(!([^)]+)\)/;
class ExpandWildcard (line 16) | class ExpandWildcard implements CommandParser {
method readDeno (line 17) | static readDeno() {
method readPackage (line 33) | static readPackage() {
method constructor (line 45) | constructor(
method relevantScripts (line 50) | private relevantScripts(command: string): string[] {
method parse (line 72) | parse(commandInfo: CommandInfo) {
FILE: lib/command-parser/strip-quotes.ts
class StripQuotes (line 7) | class StripQuotes implements CommandParser {
method parse (line 8) | parse(commandInfo: CommandInfo) {
FILE: lib/command.spec.ts
type CommandValues (line 19) | interface CommandValues {
constant IPC_FD (line 30) | const IPC_FD = 3;
method read (line 38) | read() {
method read (line 43) | read() {
method write (line 48) | write() {
method onSent (line 329) | onSent() {}
method onSent (line 333) | onSent() {}
method onSent (line 337) | onSent() {}
method onSent (line 365) | onSent() {}
FILE: lib/command.ts
type CommandIdentifier (line 16) | type CommandIdentifier = string | number;
type CommandInfo (line 18) | interface CommandInfo {
type CloseEvent (line 58) | interface CloseEvent {
type TimerEvent (line 83) | interface TimerEvent {
type MessageEvent (line 88) | interface MessageEvent {
type OutgoingMessageEvent (line 93) | interface OutgoingMessageEvent extends MessageEvent {
type ChildProcess (line 101) | type ChildProcess = EventEmitter &
type KillProcess (line 107) | type KillProcess = (pid: number, signal?: string) => void;
type SpawnCommand (line 112) | type SpawnCommand = (command: string, options: SpawnOptions) => ChildPro...
type CommandState (line 122) | type CommandState = 'stopped' | 'started' | 'errored' | 'exited';
class Command (line 124) | class Command implements CommandInfo {
method constructor (line 177) | constructor(
method start (line 198) | start() {
method changeState (line 255) | private changeState(state: Exclude<CommandState, 'stopped'>) {
method maybeSetupIPC (line 260) | private maybeSetupIPC(child: ChildProcess) {
method send (line 294) | send(message: object, handle?: SendHandle, options?: MessageOptions): ...
method kill (line 317) | kill(code?: string) {
method cleanUp (line 324) | private cleanUp() {
method canKill (line 335) | static canKill(command: Command): command is Command & { pid: number; ...
function pipeTo (line 343) | function pipeTo<T>(stream: Rx.Observable<T>, subject: Rx.Subject<T>) {
FILE: lib/completion-listener.ts
type SuccessCondition (line 15) | type SuccessCondition =
class CompletionListener (line 25) | class CompletionListener {
method constructor (line 29) | constructor({
method isSuccess (line 50) | private isSuccess(events: CloseEvent[]) {
method listen (line 95) | listen(commands: Command[], abortSignal?: AbortSignal): Promise<CloseE...
method emitWithScheduler (line 143) | private emitWithScheduler<O>(input: Rx.Observable<O>): Rx.Observable<O> {
FILE: lib/concurrently.ts
type ConcurrentlyCommandInput (line 42) | type ConcurrentlyCommandInput = string | ({ command: string } & Partial<...
type ConcurrentlyResult (line 44) | interface ConcurrentlyResult {
type ConcurrentlyOptions (line 60) | interface ConcurrentlyOptions {
function concurrently (line 162) | function concurrently(
function mapToCommandInfo (line 254) | function mapToCommandInfo(command: ConcurrentlyCommandInput): CommandInfo {
function parseCommand (line 279) | function parseCommand(command: CommandInfo, parsers: CommandParser[]) {
function maybeRunMore (line 286) | function maybeRunMore(commandsLeft: Command[], abortSignal?: AbortSignal) {
FILE: lib/date-format.spec.ts
type TokenTests (line 8) | type TokenTests = undefined | { input: string; expected: string }[];
FILE: lib/date-format.ts
type FormatterOptions (line 1) | interface FormatterOptions {
type TokenFormatter (line 6) | type TokenFormatter = (date: Date, options: FormatterOptions) => string ...
function getLocale (line 171) | function getLocale(options: FormatterOptions): Intl.Locale {
class DateFormatter (line 186) | class DateFormatter {
method constructor (line 191) | constructor(
method compileLiteral (line 209) | private compileLiteral(pattern: string, offset: number) {
method compileOther (line 233) | private compileOther(pattern: string, offset: number) {
method compileToken (line 242) | private compileToken(pattern: string, offset: number) {
method format (line 262) | format(date: Date): string {
function getWeekInfo (line 276) | function getWeekInfo(locale: Intl.Locale): Intl.WeekInfo {
function makeTokenFn (line 300) | function makeTokenFn(
function startOfWeek (line 326) | function startOfWeek(date: Date, options: FormatterOptions) {
function getWeekYear (line 337) | function getWeekYear(date: Date, options: FormatterOptions) {
function getWeek (line 355) | function getWeek(date: Date, options: FormatterOptions) {
function getWeekOfMonth (line 368) | function getWeekOfMonth(date: Date, options: FormatterOptions) {
function getDayOfYear (line 378) | function getDayOfYear(date: Date) {
function pad (line 387) | function pad(length: number, val: string | number) {
FILE: lib/declarations/intl.d.ts
type DateTimeFormatPartTypesRegistry (line 4) | interface DateTimeFormatPartTypesRegistry {
type WeekInfo (line 12) | interface WeekInfo {
type Locale (line 18) | interface Locale {
FILE: lib/flow-control/flow-controller.d.ts
type FlowController (line 9) | interface FlowController {
FILE: lib/flow-control/input-handler.ts
class InputHandler (line 20) | class InputHandler implements FlowController {
method constructor (line 26) | constructor({
method handle (line 43) | handle(commands: Command[]): {
FILE: lib/flow-control/kill-on-signal.ts
constant SIGNALS (line 8) | const SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const;
class KillOnSignal (line 14) | class KillOnSignal implements FlowController {
method constructor (line 18) | constructor({
method handle (line 29) | handle(commands: Command[]) {
FILE: lib/flow-control/kill-others.ts
type ProcessCloseCondition (line 8) | type ProcessCloseCondition = 'failure' | 'success';
class KillOthers (line 13) | class KillOthers implements FlowController {
method constructor (line 20) | constructor({
method handle (line 40) | handle(commands: Command[]) {
method maybeForceKill (line 76) | private maybeForceKill(commands: Command[]) {
FILE: lib/flow-control/log-error.ts
class LogError (line 8) | class LogError implements FlowController {
method constructor (line 11) | constructor({ logger }: { logger: Logger }) {
method handle (line 15) | handle(commands: Command[]) {
FILE: lib/flow-control/log-exit.ts
class LogExit (line 8) | class LogExit implements FlowController {
method constructor (line 11) | constructor({ logger }: { logger: Logger }) {
method handle (line 15) | handle(commands: Command[]) {
FILE: lib/flow-control/log-output.ts
class LogOutput (line 8) | class LogOutput implements FlowController {
method constructor (line 10) | constructor({ logger }: { logger: Logger }) {
method handle (line 14) | handle(commands: Command[]) {
FILE: lib/flow-control/log-timings.ts
type TimingInfo (line 12) | type TimingInfo = {
class LogTimings (line 23) | class LogTimings implements FlowController {
method mapCloseEventToTimingInfo (line 24) | static mapCloseEventToTimingInfo({
method constructor (line 45) | constructor({
method printExitInfoTimingTable (line 56) | private printExitInfoTimingTable(exitInfos: CloseEvent[]) {
method handle (line 68) | handle(commands: Command[]) {
FILE: lib/flow-control/logger-padding.ts
class LoggerPadding (line 5) | class LoggerPadding implements FlowController {
method constructor (line 8) | constructor({ logger }: { logger: Logger }) {
method handle (line 12) | handle(commands: Command[]): { commands: Command[]; onFinish: () => vo...
FILE: lib/flow-control/output-error-handler.ts
class OutputErrorHandler (line 10) | class OutputErrorHandler implements FlowController {
method constructor (line 14) | constructor({
method handle (line 25) | handle(commands: Command[]): { commands: Command[]; onFinish: () => vo...
FILE: lib/flow-control/restart-process.ts
type RestartDelay (line 9) | type RestartDelay = number | 'exponential';
class RestartProcess (line 14) | class RestartProcess implements FlowController {
method constructor (line 20) | constructor({
method handle (line 38) | handle(commands: Command[]) {
FILE: lib/flow-control/teardown.ts
class Teardown (line 8) | class Teardown implements FlowController {
method constructor (line 13) | constructor({
method handle (line 31) | handle(commands: Command[]): { commands: Command[]; onFinish: () => Pr...
FILE: lib/index.ts
type ConcurrentlyOptions (line 27) | type ConcurrentlyOptions = Omit<BaseConcurrentlyOptions, 'abortSignal' |...
function concurrently (line 125) | function concurrently(
FILE: lib/jsonc.ts
constant JSONC (line 12) | const JSONC = {
FILE: lib/logger.ts
function getChalkPath (line 12) | function getChalkPath(chalk: ChalkInstance, path: string): ChalkInstance...
class Logger (line 21) | class Logger {
method constructor (line 48) | constructor({
method toggleColors (line 93) | toggleColors(on: boolean) {
method shortenText (line 97) | private shortenText(text: string) {
method getPrefixesFor (line 112) | private getPrefixesFor(command: Command): Record<string, string> {
method getPrefixContent (line 124) | getPrefixContent(
method getPrefix (line 144) | getPrefix(command: Command): string {
method setPrefixLength (line 155) | setPrefixLength(length: number) {
method colorText (line 159) | colorText(command: Command, text: string) {
method logCommandEvent (line 180) | logCommandEvent(text: string, command: Command) {
method logCommandText (line 195) | logCommandText(text: string, command: Command) {
method logGlobalEvent (line 209) | logGlobalEvent(text: string) {
method logTable (line 222) | logTable(tableContents: Record<string, unknown>[]) {
method log (line 278) | log(prefix: string, text: string, command?: Command) {
method emit (line 300) | emit(command: Command | undefined, text: string) {
FILE: lib/observables.ts
function fromSharedEvent (line 12) | function fromSharedEvent(emitter: EventEmitter, event: string): Observab...
FILE: lib/output-writer.spec.ts
function createWriter (line 12) | function createWriter(overrides?: { group: boolean }) {
function closeCommand (line 22) | function closeCommand(command: FakeCommand) {
FILE: lib/output-writer.ts
class OutputWriter (line 11) | class OutputWriter {
method errored (line 18) | private get errored() {
method constructor (line 22) | constructor({
method ensureWritable (line 55) | private ensureWritable() {
method write (line 61) | write(command: Command | undefined, text: string) {
method flushBuffer (line 75) | private flushBuffer(index: number) {
FILE: lib/prefix-color-selector.ts
function getConsoleColorsWithoutCustomColors (line 3) | function getConsoleColorsWithoutCustomColors(customColors: string[]): st...
class PrefixColorSelector (line 58) | class PrefixColorSelector {
method constructor (line 61) | constructor(customColors: string | string[] = []) {
method ACCEPTABLE_CONSOLE_COLORS (line 67) | public static get ACCEPTABLE_CONSOLE_COLORS() {
method getNextColor (line 98) | getNextColor(): string {
FILE: lib/spawn.ts
function spawn (line 11) | function spawn(
FILE: lib/utils.ts
function escapeRegExp (line 4) | function escapeRegExp(str: string) {
type CastArrayResult (line 8) | type CastArrayResult<T> = T extends undefined | null ? never[] : T exten...
function castArray (line 13) | function castArray<T = never[]>(value?: T) {
Condensed preview — 111 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (355K chars).
[
{
"path": ".editorconfig",
"chars": 276,
"preview": "# EditorConfig is awesome: https://editorconfig.org\n\nroot = true\n\n[*]\nend_of_line = lf\ninsert_final_newline = true\nchars"
},
{
"path": ".gitattributes",
"chars": 19,
"preview": "* text=auto eol=lf\n"
},
{
"path": ".github/FUNDING.yml",
"chars": 49,
"preview": "# Thank you! <3\n\ngithub: [gustavohenke, paescuj]\n"
},
{
"path": ".github/ISSUE_TEMPLATE/bug-report.yml",
"chars": 887,
"preview": "name: Bug Report\ndescription: File a bug report\nbody:\n - type: textarea\n attributes:\n label: Describe the bug\n "
},
{
"path": ".github/ISSUE_TEMPLATE/config.yml",
"chars": 28,
"preview": "blank_issues_enabled: false\n"
},
{
"path": ".github/ISSUE_TEMPLATE/feature-request.yml",
"chars": 789,
"preview": "name: Feature Request\ndescription: Open a feature request\nbody:\n - type: textarea\n attributes:\n label: Describe"
},
{
"path": ".github/actions/setup/action.yml",
"chars": 1106,
"preview": "name: Setup\ndescription: Setup the environment for the project\n\ninputs:\n node-version:\n description: Node.js version"
},
{
"path": ".github/workflows/ci.yml",
"chars": 1772,
"preview": "name: CI\n\non:\n push:\n branches:\n - main\n pull_request:\n branches:\n - main\n\nconcurrency:\n group: ${{ g"
},
{
"path": ".github/workflows/release.yml",
"chars": 917,
"preview": "name: Release\n\non:\n push:\n tags:\n - 'v*'\n\njobs:\n gh-release:\n name: Create GitHub Release\n runs-on: ubun"
},
{
"path": ".gitignore",
"chars": 143,
"preview": "# Outputs\ndist\n\n# Logs\n*.log\n\n# Coverage directory used by tools like istanbul\ncoverage\n\n# Dependency directory\nnode_mod"
},
{
"path": ".husky/pre-commit",
"chars": 32,
"preview": "./node_modules/.bin/lint-staged\n"
},
{
"path": ".node-version",
"chars": 3,
"preview": "22\n"
},
{
"path": ".prettierignore",
"chars": 29,
"preview": "dist\ncoverage\npnpm-lock.yaml\n"
},
{
"path": ".prettierrc.json",
"chars": 26,
"preview": "{\n \"singleQuote\": true\n}\n"
},
{
"path": ".vscode/extensions.json",
"chars": 146,
"preview": "{\n \"recommendations\": [\n \"dbaeumer.vscode-eslint\",\n \"esbenp.prettier-vscode\",\n \"editorconfig.editorconfig\",\n "
},
{
"path": ".vscode/settings.json",
"chars": 470,
"preview": "{\n // For contributors with the Jest extension installed,\n // it might incorrectly report errors in the suite\n \"jest."
},
{
"path": "CONTRIBUTING.md",
"chars": 998,
"preview": "# Contributing\n\nPull requests and contributions are warmly welcome.\nPlease follow existing code style and commit message"
},
{
"path": "LICENSE",
"chars": 1082,
"preview": "The MIT License (MIT)\n\nCopyright (c) 2015 Kimmo Brunfeldt\n\nPermission is hereby granted, free of charge, to any person o"
},
{
"path": "README.md",
"chars": 12302,
"preview": "# concurrently\n\n[](h"
},
{
"path": "bin/__fixtures__/read-echo.js",
"chars": 228,
"preview": "import process from 'node:process';\n\nprocess.stdin.on('data', (chunk) => {\n const line = chunk.toString().trim();\n "
},
{
"path": "bin/__fixtures__/sleep.js",
"chars": 538,
"preview": "/*\n * Platform independent implementation of 'sleep' used as a command in tests\n *\n * (Windows doesn't provide the 'slee"
},
{
"path": "bin/index.spec.ts",
"chars": 20032,
"preview": "import { spawn } from 'node:child_process';\nimport fs from 'node:fs';\nimport os from 'node:os';\nimport path from 'node:p"
},
{
"path": "bin/index.ts",
"chars": 10628,
"preview": "#!/usr/bin/env node\nimport process from 'node:process';\n\nimport yargs from 'yargs';\nimport { hideBin } from 'yargs/helpe"
},
{
"path": "bin/read-package-json.ts",
"chars": 484,
"preview": "import { readFileSync } from 'node:fs';\nimport { createRequire } from 'node:module';\n\n/**\n * Read the package.json file "
},
{
"path": "docs/README.md",
"chars": 481,
"preview": "# Concurrently Documentation\n\n## CLI\n\nThese articles cover using concurrently through CLI:\n\n- [Prefixing](./cli/prefixin"
},
{
"path": "docs/cli/configuration.md",
"chars": 410,
"preview": "# Configuration\n\nYou might want to configure concurrently to always have certain flags on.\nAny of concurrently's flags c"
},
{
"path": "docs/cli/input-handling.md",
"chars": 1163,
"preview": "# Input Handling\n\nBy default, concurrently doesn't send input to any commands it spawns.<br/>\nIn the below example, typi"
},
{
"path": "docs/cli/output-control.md",
"chars": 1132,
"preview": "# Output Control\n\nconcurrently offers a few ways to control a command's output.\n\n## Hiding\n\nA command's outputs (and all"
},
{
"path": "docs/cli/passthrough-arguments.md",
"chars": 2600,
"preview": "# Passthrough Arguments\n\nIf you have a shortcut for running a specific combination of commands through concurrently,\nyou"
},
{
"path": "docs/cli/prefixing.md",
"chars": 4217,
"preview": "# Prefixing\n\n## Prefix Styles\n\nconcurrently will by default prefix each command's outputs with a zero-based index, wrapp"
},
{
"path": "docs/cli/restarting.md",
"chars": 1479,
"preview": "# Restarting Commands\n\nSometimes it's useful to have commands that exited with a non-zero status to restart automaticall"
},
{
"path": "docs/cli/shortcuts.md",
"chars": 2273,
"preview": "# Command Shortcuts\n\nPackage managers that execute scripts from a `package.json` or `deno.(json|jsonc)` file can be shor"
},
{
"path": "docs/cli/success.md",
"chars": 2135,
"preview": "# Success Conditions\n\nWhen you're using concurrently in shell scripts or CI pipelines, the exit code matters. \nIt deter"
},
{
"path": "docs/cli/terminating.md",
"chars": 1705,
"preview": "# Terminating Commands\n\nIt's possible to have concurrently terminate other commands when one of them exits.<br/>\nThis ca"
},
{
"path": "eslint.config.js",
"chars": 2081,
"preview": "// @ts-check\n\nimport eslint from '@eslint/js';\nimport pluginVitest from '@vitest/eslint-plugin';\nimport { defineConfig }"
},
{
"path": "lib/__fixtures__/create-mock-instance.ts",
"chars": 272,
"preview": "import { MockedObject, vi } from 'vitest';\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport functi"
},
{
"path": "lib/__fixtures__/fake-command.ts",
"chars": 1289,
"preview": "import EventEmitter from 'node:events';\nimport { PassThrough, Writable } from 'node:stream';\n\nimport { vi } from 'vitest"
},
{
"path": "lib/assert.spec.ts",
"chars": 1225,
"preview": "import { afterEach, describe, expect, it, vi } from 'vitest';\n\nimport { assertDeprecated } from './assert.js';\n\ndescribe"
},
{
"path": "lib/assert.ts",
"chars": 470,
"preview": "const deprecations = new Set<string>();\n\n/**\n * Asserts that some condition is true, and if not, prints a warning about "
},
{
"path": "lib/command-parser/command-parser.d.ts",
"chars": 869,
"preview": "import { CommandInfo } from '../command.js';\n\n/**\n * A command parser encapsulates a specific logic for mapping `Command"
},
{
"path": "lib/command-parser/expand-arguments.spec.ts",
"chars": 3010,
"preview": "import { expect, it } from 'vitest';\n\nimport { CommandInfo } from '../command.js';\nimport { ExpandArguments } from './ex"
},
{
"path": "lib/command-parser/expand-arguments.ts",
"chars": 1698,
"preview": "import { quote } from 'shell-quote';\n\nimport { CommandInfo } from '../command.js';\nimport { CommandParser } from './comm"
},
{
"path": "lib/command-parser/expand-shortcut.spec.ts",
"chars": 1290,
"preview": "import { describe, expect, it } from 'vitest';\n\nimport { CommandInfo } from '../command.js';\nimport { ExpandShortcut } f"
},
{
"path": "lib/command-parser/expand-shortcut.ts",
"chars": 1231,
"preview": "import { CommandInfo } from '../command.js';\nimport { CommandParser } from './command-parser.js';\n\n/**\n * Expands shortc"
},
{
"path": "lib/command-parser/expand-wildcard.spec.ts",
"chars": 11090,
"preview": "import fs, { PathOrFileDescriptor } from 'node:fs';\n\nimport { afterEach, beforeEach, describe, expect, it, Mock, vi } fr"
},
{
"path": "lib/command-parser/expand-wildcard.ts",
"chars": 4330,
"preview": "import fs from 'node:fs';\n\nimport { CommandInfo } from '../command.js';\nimport JSONC from '../jsonc.js';\nimport { escape"
},
{
"path": "lib/command-parser/strip-quotes.spec.ts",
"chars": 1234,
"preview": "import { expect, it } from 'vitest';\n\nimport { CommandInfo } from '../command.js';\nimport { StripQuotes } from './strip-"
},
{
"path": "lib/command-parser/strip-quotes.ts",
"chars": 555,
"preview": "import { CommandInfo } from '../command.js';\nimport { CommandParser } from './command-parser.js';\n\n/**\n * Strips quotes "
},
{
"path": "lib/command.spec.ts",
"chars": 16560,
"preview": "import { Buffer } from 'node:buffer';\nimport { SendHandle, SpawnOptions } from 'node:child_process';\nimport { EventEmitt"
},
{
"path": "lib/command.ts",
"chars": 9936,
"preview": "import { Buffer } from 'node:buffer';\nimport {\n ChildProcess as BaseChildProcess,\n MessageOptions,\n SendHandle,"
},
{
"path": "lib/completion-listener.spec.ts",
"chars": 12053,
"preview": "import { getEventListeners } from 'node:events';\n\nimport { TestScheduler } from 'rxjs/testing';\nimport { beforeEach, des"
},
{
"path": "lib/completion-listener.ts",
"chars": 5664,
"preview": "import Rx from 'rxjs';\nimport { delay, filter, map, share, switchMap, take } from 'rxjs/operators';\n\nimport { CloseEvent"
},
{
"path": "lib/concurrently.spec.ts",
"chars": 13920,
"preview": "import type { CpuInfo } from 'node:os';\nimport os from 'node:os';\nimport { Writable } from 'node:stream';\n\nimport { befo"
},
{
"path": "lib/concurrently.ts",
"chars": 9452,
"preview": "import assert from 'node:assert';\nimport os from 'node:os';\nimport { Writable } from 'node:stream';\n\nimport { takeUntil "
},
{
"path": "lib/date-format.spec.ts",
"chars": 21664,
"preview": "import { afterAll, beforeAll, describe, expect, it } from 'vitest';\n\nimport { DateFormatter, FormatterOptions } from './"
},
{
"path": "lib/date-format.ts",
"chars": 13446,
"preview": "export interface FormatterOptions {\n locale?: string;\n calendar?: string;\n}\n\ntype TokenFormatter = (date: Date, op"
},
{
"path": "lib/declarations/intl.d.ts",
"chars": 606,
"preview": "// TODO: Delete this file once Typescript has added these.\ndeclare namespace Intl {\n // https://github.com/tc39/ecma4"
},
{
"path": "lib/defaults.ts",
"chars": 2140,
"preview": "// This file is meant to be a shared place for default configs.\n// It's read by the flow controllers, the executable, et"
},
{
"path": "lib/flow-control/flow-controller.d.ts",
"chars": 390,
"preview": "import { Command } from '../command.js';\n\n/**\n * Interface for a class that controls and/or watches the behavior of comm"
},
{
"path": "lib/flow-control/input-handler.spec.ts",
"chars": 5820,
"preview": "import { Buffer } from 'node:buffer';\nimport { PassThrough } from 'node:stream';\n\nimport { beforeEach, expect, it } from"
},
{
"path": "lib/flow-control/input-handler.ts",
"chars": 3306,
"preview": "import { Readable } from 'node:stream';\n\nimport Rx from 'rxjs';\nimport { map } from 'rxjs/operators';\n\nimport { Command,"
},
{
"path": "lib/flow-control/kill-on-signal.spec.ts",
"chars": 2972,
"preview": "import { EventEmitter } from 'node:events';\n\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { cr"
},
{
"path": "lib/flow-control/kill-on-signal.ts",
"chars": 2234,
"preview": "import EventEmitter from 'node:events';\n\nimport { map } from 'rxjs/operators';\n\nimport { Command } from '../command.js';"
},
{
"path": "lib/flow-control/kill-others.spec.ts",
"chars": 4789,
"preview": "import { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { createMockInstance } from '../__fixtures__/crea"
},
{
"path": "lib/flow-control/kill-others.ts",
"chars": 3118,
"preview": "import { filter, map } from 'rxjs/operators';\n\nimport { Command } from '../command.js';\nimport { Logger } from '../logge"
},
{
"path": "lib/flow-control/log-error.spec.ts",
"chars": 1792,
"preview": "import { beforeEach, expect, it } from 'vitest';\n\nimport { createMockInstance } from '../__fixtures__/create-mock-instan"
},
{
"path": "lib/flow-control/log-error.ts",
"chars": 937,
"preview": "import { Command } from '../command.js';\nimport { Logger } from '../logger.js';\nimport { FlowController } from './flow-c"
},
{
"path": "lib/flow-control/log-exit.spec.ts",
"chars": 1216,
"preview": "import { beforeEach, expect, it } from 'vitest';\n\nimport { createMockInstance } from '../__fixtures__/create-mock-instan"
},
{
"path": "lib/flow-control/log-exit.ts",
"chars": 717,
"preview": "import { Command } from '../command.js';\nimport { Logger } from '../logger.js';\nimport { FlowController } from './flow-c"
},
{
"path": "lib/flow-control/log-output.spec.ts",
"chars": 1457,
"preview": "import { Buffer } from 'node:buffer';\n\nimport { beforeEach, expect, it } from 'vitest';\n\nimport { createMockInstance } f"
},
{
"path": "lib/flow-control/log-output.ts",
"chars": 757,
"preview": "import { Command } from '../command.js';\nimport { Logger } from '../logger.js';\nimport { FlowController } from './flow-c"
},
{
"path": "lib/flow-control/log-timings.spec.ts",
"chars": 4710,
"preview": "import { beforeEach, expect, it } from 'vitest';\n\nimport { createMockInstance } from '../__fixtures__/create-mock-instan"
},
{
"path": "lib/flow-control/log-timings.ts",
"chars": 3497,
"preview": "import assert from 'node:assert';\n\nimport Rx from 'rxjs';\nimport { bufferCount, combineLatestWith, take } from 'rxjs/ope"
},
{
"path": "lib/flow-control/logger-padding.spec.ts",
"chars": 2406,
"preview": "import { beforeEach, expect, it, MockedObject } from 'vitest';\n\nimport { createMockInstance } from '../__fixtures__/crea"
},
{
"path": "lib/flow-control/logger-padding.ts",
"chars": 1639,
"preview": "import { Command } from '../command.js';\nimport { Logger } from '../logger.js';\nimport { FlowController } from './flow-c"
},
{
"path": "lib/flow-control/output-error-handler.spec.ts",
"chars": 1589,
"preview": "import { Writable } from 'node:stream';\n\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { FakeCo"
},
{
"path": "lib/flow-control/output-error-handler.ts",
"chars": 1199,
"preview": "import { Writable } from 'node:stream';\n\nimport { Command } from '../command.js';\nimport { fromSharedEvent } from '../ob"
},
{
"path": "lib/flow-control/restart-process.spec.ts",
"chars": 6087,
"preview": "import { VirtualTimeScheduler } from 'rxjs';\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { cr"
},
{
"path": "lib/flow-control/restart-process.ts",
"chars": 3738,
"preview": "import Rx from 'rxjs';\nimport { defaultIfEmpty, delayWhen, filter, map, skip, take, takeWhile } from 'rxjs/operators';\n\n"
},
{
"path": "lib/flow-control/teardown.spec.ts",
"chars": 4694,
"preview": "import { ChildProcess } from 'node:child_process';\n\nimport { afterEach, describe, expect, it, Mock, vi } from 'vitest';\n"
},
{
"path": "lib/flow-control/teardown.ts",
"chars": 2527,
"preview": "import Rx from 'rxjs';\n\nimport { Command, SpawnCommand } from '../command.js';\nimport { Logger } from '../logger.js';\nim"
},
{
"path": "lib/index.ts",
"chars": 7326,
"preview": "import process from 'node:process';\nimport { Readable } from 'node:stream';\n\nimport { assertDeprecated } from './assert."
},
{
"path": "lib/jsonc.spec.ts",
"chars": 2017,
"preview": "/*\nORIGINAL https://www.npmjs.com/package/tiny-jsonc\nBY Fabio Spampinato\nMIT license\n\nCopied due to the dependency not b"
},
{
"path": "lib/jsonc.ts",
"chars": 779,
"preview": "/*\nORIGINAL https://www.npmjs.com/package/tiny-jsonc\nBY Fabio Spampinato\nMIT license\n\nCopied due to the dependency not b"
},
{
"path": "lib/logger.spec.ts",
"chars": 16302,
"preview": "import { subscribeSpyTo } from '@hirez_io/observer-spy';\nimport chalk from 'chalk';\nimport { beforeEach, describe, expec"
},
{
"path": "lib/logger.ts",
"chars": 10559,
"preview": "import chalk, { Chalk, ChalkInstance } from 'chalk';\nimport Rx from 'rxjs';\n\nimport { Command, CommandIdentifier } from "
},
{
"path": "lib/observables.spec.ts",
"chars": 1267,
"preview": "import EventEmitter from 'node:events';\n\nimport { describe, expect, it } from 'vitest';\n\nimport { fromSharedEvent } from"
},
{
"path": "lib/observables.ts",
"chars": 850,
"preview": "import EventEmitter from 'node:events';\n\nimport { fromEvent, Observable, share } from 'rxjs';\n\nconst sharedEvents = new "
},
{
"path": "lib/output-writer.spec.ts",
"chars": 4281,
"preview": "import { Writable } from 'node:stream';\n\nimport { beforeEach, describe, expect, it, MockedObject } from 'vitest';\n\nimpor"
},
{
"path": "lib/output-writer.ts",
"chars": 2376,
"preview": "import { Writable } from 'node:stream';\n\nimport Rx from 'rxjs';\n\nimport { Command } from './command.js';\nimport { fromSh"
},
{
"path": "lib/prefix-color-selector.spec.ts",
"chars": 6712,
"preview": "import { ChalkInstance } from 'chalk';\nimport { afterEach, describe, expect, it, vi } from 'vitest';\n\nimport { PrefixCol"
},
{
"path": "lib/prefix-color-selector.ts",
"chars": 3798,
"preview": "import { ChalkInstance } from 'chalk';\n\nfunction getConsoleColorsWithoutCustomColors(customColors: string[]): string[] {"
},
{
"path": "lib/spawn.spec.ts",
"chars": 2506,
"preview": "import { describe, expect, it, vi } from 'vitest';\n\nimport { getSpawnOpts, spawn } from './spawn.js';\n\nconst baseProcess"
},
{
"path": "lib/spawn.ts",
"chars": 3058,
"preview": "import assert from 'node:assert';\nimport { ChildProcess, IOType, spawn as baseSpawn, SpawnOptions } from 'node:child_pro"
},
{
"path": "lib/utils.spec.ts",
"chars": 1167,
"preview": "import { describe, expect, it } from 'vitest';\n\nimport { castArray, escapeRegExp } from './utils.js';\n\ndescribe('#escape"
},
{
"path": "lib/utils.ts",
"chars": 464,
"preview": "/**\n * Escapes a string for use in a regular expression.\n */\nexport function escapeRegExp(str: string) {\n return str."
},
{
"path": "package.json",
"chars": 2559,
"preview": "{\n \"name\": \"concurrently\",\n \"type\": \"module\",\n \"version\": \"9.2.1\",\n \"packageManager\": \"pnpm@10.18.2+sha512.9fb969fa7"
},
{
"path": "pnpm-workspace.yaml",
"chars": 88,
"preview": "packages:\n - tests\n\ninjectWorkspacePackages: false\n\nonlyBuiltDependencies:\n - esbuild\n"
},
{
"path": "tests/cjs-import/package.json",
"chars": 25,
"preview": "{\n \"type\": \"commonjs\"\n}\n"
},
{
"path": "tests/cjs-import/smoke-test.ts",
"chars": 449,
"preview": "import type { ConcurrentlyResult } from 'concurrently';\nimport concurrently, { concurrently as concurrently2, createConc"
},
{
"path": "tests/cjs-import/tsconfig.json",
"chars": 104,
"preview": "{\n \"compilerOptions\": {\n \"module\": \"commonjs\",\n \"outDir\": \"dist\",\n \"skipLibCheck\": true\n }\n}\n"
},
{
"path": "tests/cjs-require/package.json",
"chars": 25,
"preview": "{\n \"type\": \"commonjs\"\n}\n"
},
{
"path": "tests/cjs-require/smoke-test.ts",
"chars": 532,
"preview": "// eslint-disable-next-line @typescript-eslint/no-require-imports\nimport concurrently = require('concurrently');\n\nconst "
},
{
"path": "tests/cjs-require/tsconfig.json",
"chars": 104,
"preview": "{\n \"compilerOptions\": {\n \"module\": \"commonjs\",\n \"outDir\": \"dist\",\n \"skipLibCheck\": true\n }\n}\n"
},
{
"path": "tests/esm/package.json",
"chars": 23,
"preview": "{\n \"type\": \"module\"\n}\n"
},
{
"path": "tests/esm/smoke-test.ts",
"chars": 449,
"preview": "import type { ConcurrentlyResult } from 'concurrently';\nimport concurrently, { concurrently as concurrently2, createConc"
},
{
"path": "tests/esm/tsconfig.json",
"chars": 102,
"preview": "{\n \"compilerOptions\": {\n \"module\": \"node20\",\n \"outDir\": \"dist\",\n \"skipLibCheck\": true\n }\n}\n"
},
{
"path": "tests/package.json",
"chars": 129,
"preview": "{\n \"dependencies\": {\n \"concurrently\": \"workspace:*\"\n },\n \"scripts\": {\n \"test\": \"pnpm --workspace-root test:smok"
},
{
"path": "tests/smoke-tests.spec.ts",
"chars": 802,
"preview": "import { exec as originalExec } from 'node:child_process';\nimport util from 'node:util';\n\nimport { beforeAll, expect, it"
},
{
"path": "tsconfig.json",
"chars": 205,
"preview": "{\n \"compilerOptions\": {\n \"lib\": [\"es2023\"],\n \"module\": \"node20\",\n\n \"outDir\": \"dist\",\n \"declaration\": true,\n"
},
{
"path": "vitest.config.ts",
"chars": 692,
"preview": "import { defineConfig } from 'vitest/config';\n\nexport default defineConfig({\n test: {\n coverage: {\n "
}
]
About this extraction
This page contains the full source code of the open-cli-tools/concurrently GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 111 files (330.1 KB), approximately 80.8k tokens, and a symbol index with 161 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.