Initial project import

This commit is contained in:
drjones
2026-06-13 17:36:44 -07:00
commit ad2a18cc8d
18471 changed files with 4497570 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
import type { Command } from '../command.js';
/**
* Interface for a class that controls and/or watches the behavior of commands.
*
* This may include logging their output, creating interactions between them, or changing when they
* actually finish.
*/
export interface FlowController {
handle(commands: Command[]): {
commands: Command[];
onFinish?: () => void | Promise<void>;
};
}

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,29 @@
import { Readable } from 'node:stream';
import { Command, CommandIdentifier } from '../command.js';
import { Logger } from '../logger.js';
import { FlowController } from './flow-controller.js';
/**
* Sends input from concurrently through to commands.
*
* Input can start with a command identifier, in which case it will be sent to that specific command.
* For instance, `0:bla` will send `bla` to command at index `0`, and `server:stop` will send `stop`
* to command with name `server`.
*
* If the input doesn't start with a command identifier, it is then always sent to the default target.
*/
export declare class InputHandler implements FlowController {
private readonly logger;
private readonly defaultInputTarget;
private readonly inputStream?;
private readonly pauseInputStreamOnFinish;
constructor({ defaultInputTarget, inputStream, pauseInputStreamOnFinish, logger, }: {
inputStream?: Readable;
logger: Logger;
defaultInputTarget?: CommandIdentifier;
pauseInputStreamOnFinish?: boolean;
});
handle(commands: Command[]): {
commands: Command[];
onFinish?: () => void | undefined;
};
}

View File

@@ -0,0 +1,68 @@
import Rx from 'rxjs';
import { map } from 'rxjs/operators';
import * as defaults from '../defaults.js';
/**
* Sends input from concurrently through to commands.
*
* Input can start with a command identifier, in which case it will be sent to that specific command.
* For instance, `0:bla` will send `bla` to command at index `0`, and `server:stop` will send `stop`
* to command with name `server`.
*
* If the input doesn't start with a command identifier, it is then always sent to the default target.
*/
export class InputHandler {
logger;
defaultInputTarget;
inputStream;
pauseInputStreamOnFinish;
constructor({ defaultInputTarget, inputStream, pauseInputStreamOnFinish, logger, }) {
this.logger = logger;
this.defaultInputTarget = defaultInputTarget || defaults.defaultInputTarget;
this.inputStream = inputStream;
this.pauseInputStreamOnFinish = pauseInputStreamOnFinish !== false;
}
handle(commands) {
const { inputStream } = this;
if (!inputStream) {
return { commands };
}
const commandsMap = new Map();
for (const command of commands) {
commandsMap.set(command.index.toString(), command);
commandsMap.set(command.name, command);
}
Rx.fromEvent(inputStream, 'data')
.pipe(map((data) => String(data)))
.subscribe((data) => {
const dataParts = data.split(/:(.+)/s);
let target = dataParts[0];
let command = commandsMap.get(target);
let input;
if (dataParts.length > 1 && command) {
input = dataParts[1];
}
else {
// If `target` does not match a registered command,
// fallback to `defaultInputTarget` and forward the whole input data
target = this.defaultInputTarget.toString();
command = commandsMap.get(target);
input = data;
}
if (command?.stdin) {
command.stdin.write(input);
}
else {
this.logger.logGlobalEvent(`Unable to find command "${target}", or it has no stdin open\n`);
}
});
return {
commands,
onFinish: () => {
if (this.pauseInputStreamOnFinish) {
// https://github.com/kimmobrunfeldt/concurrently/issues/252
inputStream.pause();
}
},
};
}
}

View File

@@ -0,0 +1,19 @@
import EventEmitter from 'node:events';
import { Command } from '../command.js';
import { FlowController } from './flow-controller.js';
/**
* Watches the main concurrently process for signals and sends the same signal down to each spawned
* command.
*/
export declare class KillOnSignal implements FlowController {
private readonly process;
private readonly abortController?;
constructor({ process, abortController, }: {
process: EventEmitter;
abortController?: AbortController;
});
handle(commands: Command[]): {
commands: Command[];
onFinish: () => void;
};
}

View File

@@ -0,0 +1,43 @@
import { map } from 'rxjs/operators';
const SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
/**
* Watches the main concurrently process for signals and sends the same signal down to each spawned
* command.
*/
export class KillOnSignal {
process;
abortController;
constructor({ process, abortController, }) {
this.process = process;
this.abortController = abortController;
}
handle(commands) {
let caughtSignal;
const signalListener = (signal) => {
caughtSignal = signal;
this.abortController?.abort();
commands.forEach((command) => command.kill(signal));
};
SIGNALS.forEach((signal) => this.process.on(signal, signalListener));
return {
commands: commands.map((command) => {
const closeStream = command.close.pipe(map((exitInfo) => {
const exitCode = caughtSignal === 'SIGINT' ? 0 : exitInfo.exitCode;
return { ...exitInfo, exitCode };
}));
// Return a proxy so that mutations happen on the original Command object.
// If either `Object.assign()` or `Object.create()` were used, it'd be hard to
// reflect the mutations on Command objects referenced by previous flow controllers.
return new Proxy(command, {
get(target, prop) {
return prop === 'close' ? closeStream : target[prop];
},
});
}),
onFinish: () => {
// Avoids MaxListenersExceededWarning when running programmatically
SIGNALS.forEach((signal) => this.process.off(signal, signalListener));
},
};
}
}

View File

@@ -0,0 +1,25 @@
import { Command } from '../command.js';
import { Logger } from '../logger.js';
import { FlowController } from './flow-controller.js';
export type ProcessCloseCondition = 'failure' | 'success';
/**
* Sends a SIGTERM signal to all commands when one of the commands exits with a matching condition.
*/
export declare class KillOthers implements FlowController {
private readonly logger;
private readonly abortController?;
private readonly conditions;
private readonly killSignal;
private readonly timeoutMs?;
constructor({ logger, abortController, conditions, killSignal, timeoutMs, }: {
logger: Logger;
abortController?: AbortController;
conditions: ProcessCloseCondition | ProcessCloseCondition[];
killSignal: string | undefined;
timeoutMs?: number;
});
handle(commands: Command[]): {
commands: Command[];
};
private maybeForceKill;
}

View File

@@ -0,0 +1,50 @@
import { filter, map } from 'rxjs/operators';
import { Command } from '../command.js';
import { castArray } from '../utils.js';
/**
* Sends a SIGTERM signal to all commands when one of the commands exits with a matching condition.
*/
export class KillOthers {
logger;
abortController;
conditions;
killSignal;
timeoutMs;
constructor({ logger, abortController, conditions, killSignal, timeoutMs, }) {
this.logger = logger;
this.abortController = abortController;
this.conditions = castArray(conditions);
this.killSignal = killSignal;
this.timeoutMs = timeoutMs;
}
handle(commands) {
const conditions = this.conditions.filter((condition) => condition === 'failure' || condition === 'success');
if (!conditions.length) {
return { commands };
}
const closeStates = commands.map((command) => command.close.pipe(map(({ exitCode }) => exitCode === 0 ? 'success' : 'failure'), filter((state) => conditions.includes(state))));
closeStates.forEach((closeState) => closeState.subscribe(() => {
this.abortController?.abort();
const killableCommands = commands.filter((command) => Command.canKill(command));
if (killableCommands.length) {
this.logger.logGlobalEvent(`Sending ${this.killSignal || 'SIGTERM'} to other processes..`);
killableCommands.forEach((command) => command.kill(this.killSignal));
this.maybeForceKill(killableCommands);
}
}));
return { commands };
}
maybeForceKill(commands) {
// No need to force kill when the signal already is SIGKILL.
if (!this.timeoutMs || this.killSignal === 'SIGKILL') {
return;
}
setTimeout(() => {
const killableCommands = commands.filter((command) => Command.canKill(command));
if (killableCommands) {
this.logger.logGlobalEvent(`Sending SIGKILL to ${killableCommands.length} processes..`);
killableCommands.forEach((command) => command.kill('SIGKILL'));
}
}, this.timeoutMs);
}
}

View File

@@ -0,0 +1,15 @@
import { Command } from '../command.js';
import { Logger } from '../logger.js';
import { FlowController } from './flow-controller.js';
/**
* Logs when commands failed executing, e.g. due to the executable not existing in the system.
*/
export declare class LogError implements FlowController {
private readonly logger;
constructor({ logger }: {
logger: Logger;
});
handle(commands: Command[]): {
commands: Command[];
};
}

View File

@@ -0,0 +1,17 @@
/**
* Logs when commands failed executing, e.g. due to the executable not existing in the system.
*/
export class LogError {
logger;
constructor({ logger }) {
this.logger = logger;
}
handle(commands) {
commands.forEach((command) => command.error.subscribe((event) => {
this.logger.logCommandEvent(`Error occurred when executing command: ${command.command}`, command);
const errorText = String(event instanceof Error ? event.stack || event : event);
this.logger.logCommandEvent(errorText, command);
}));
return { commands };
}
}

View File

@@ -0,0 +1,15 @@
import { Command } from '../command.js';
import { Logger } from '../logger.js';
import { FlowController } from './flow-controller.js';
/**
* Logs the exit code/signal of commands.
*/
export declare class LogExit implements FlowController {
private readonly logger;
constructor({ logger }: {
logger: Logger;
});
handle(commands: Command[]): {
commands: Command[];
};
}

View File

@@ -0,0 +1,15 @@
/**
* Logs the exit code/signal of commands.
*/
export class LogExit {
logger;
constructor({ logger }) {
this.logger = logger;
}
handle(commands) {
commands.forEach((command) => command.close.subscribe(({ exitCode }) => {
this.logger.logCommandEvent(`${command.command} exited with code ${exitCode}`, command);
}));
return { commands };
}
}

View File

@@ -0,0 +1,15 @@
import { Command } from '../command.js';
import { Logger } from '../logger.js';
import { FlowController } from './flow-controller.js';
/**
* Logs the stdout and stderr output of commands.
*/
export declare class LogOutput implements FlowController {
private readonly logger;
constructor({ logger }: {
logger: Logger;
});
handle(commands: Command[]): {
commands: Command[];
};
}

View File

@@ -0,0 +1,16 @@
/**
* Logs the stdout and stderr output of commands.
*/
export class LogOutput {
logger;
constructor({ logger }) {
this.logger = logger;
}
handle(commands) {
commands.forEach((command) => {
command.stdout.subscribe((text) => this.logger.logCommandText(text.toString(), command));
command.stderr.subscribe((text) => this.logger.logCommandText(text.toString(), command));
});
return { commands };
}
}

View File

@@ -0,0 +1,31 @@
import { CloseEvent, Command } from '../command.js';
import { Logger } from '../logger.js';
import { FlowController } from './flow-controller.js';
type TimingInfo = {
name: string;
duration: string;
'exit code': string | number;
killed: boolean;
command: string;
};
/**
* Logs timing information about commands as they start/stop and then a summary when all commands finish.
*/
export declare class LogTimings implements FlowController {
static mapCloseEventToTimingInfo({ command, timings, killed, exitCode, }: CloseEvent): TimingInfo;
private readonly logger?;
private readonly dateFormatter;
constructor({ logger, timestampFormat, }: {
logger?: Logger;
timestampFormat?: string;
});
private printExitInfoTimingTable;
handle(commands: Command[]): {
commands: Command[];
onFinish?: undefined;
} | {
commands: Command[];
onFinish: () => void;
};
}
export {};

View File

@@ -0,0 +1,61 @@
import assert from 'node:assert';
import Rx from 'rxjs';
import { bufferCount, combineLatestWith, take } from 'rxjs/operators';
import { DateFormatter } from '../date-format.js';
import * as defaults from '../defaults.js';
/**
* Logs timing information about commands as they start/stop and then a summary when all commands finish.
*/
export class LogTimings {
static mapCloseEventToTimingInfo({ command, timings, killed, exitCode, }) {
const readableDurationMs = (timings.endDate.getTime() - timings.startDate.getTime()).toLocaleString();
return {
name: command.name,
duration: readableDurationMs,
'exit code': exitCode,
killed,
command: command.command,
};
}
logger;
dateFormatter;
constructor({ logger, timestampFormat = defaults.timestampFormat, }) {
this.logger = logger;
this.dateFormatter = new DateFormatter(timestampFormat);
}
printExitInfoTimingTable(exitInfos) {
assert.ok(this.logger);
const exitInfoTable = exitInfos
.sort((a, b) => b.timings.durationSeconds - a.timings.durationSeconds)
.map(LogTimings.mapCloseEventToTimingInfo);
this.logger.logGlobalEvent('Timings:');
this.logger.logTable(exitInfoTable);
return exitInfos;
}
handle(commands) {
const { logger } = this;
if (!logger) {
return { commands };
}
// individual process timings
commands.forEach((command) => {
command.timer.subscribe(({ startDate, endDate }) => {
if (!endDate) {
const formattedStartDate = this.dateFormatter.format(startDate);
logger.logCommandEvent(`${command.command} started at ${formattedStartDate}`, command);
}
else {
const durationMs = endDate.getTime() - startDate.getTime();
const formattedEndDate = this.dateFormatter.format(endDate);
logger.logCommandEvent(`${command.command} stopped at ${formattedEndDate} after ${durationMs.toLocaleString()}ms`, command);
}
});
});
// overall summary timings
const closeStreams = commands.map((command) => command.close);
const finished = new Rx.Subject();
const allProcessesClosed = Rx.merge(...closeStreams).pipe(bufferCount(closeStreams.length), take(1), combineLatestWith(finished));
allProcessesClosed.subscribe(([exitInfos]) => this.printExitInfoTimingTable(exitInfos));
return { commands, onFinish: () => finished.next() };
}
}

View File

@@ -0,0 +1,13 @@
import { Command } from '../command.js';
import { Logger } from '../logger.js';
import { FlowController } from './flow-controller.js';
export declare class LoggerPadding implements FlowController {
private readonly logger;
constructor({ logger }: {
logger: Logger;
});
handle(commands: Command[]): {
commands: Command[];
onFinish: () => void;
};
}

View File

@@ -0,0 +1,35 @@
import { COLOR_MARKER_RE } from '../logger.js';
function visibleLength(value) {
return value ? value.replace(COLOR_MARKER_RE, '').length : 0;
}
export class LoggerPadding {
logger;
constructor({ logger }) {
this.logger = logger;
}
handle(commands) {
// Sometimes there's limited concurrency, so not all commands will spawn straight away.
// Compute the prefix length now, which works for all styles but those with a PID.
let length = commands.reduce((length, command) => {
const content = this.logger.getPrefixContent(command);
return Math.max(length, visibleLength(content?.value));
}, 0);
this.logger.setPrefixLength(length);
// The length of prefixes is somewhat stable, except for PIDs, which might change when a
// process spawns (e.g. PIDs might look like 1, 10 or 100), therefore listen to command starts
// and update the prefix length when this happens.
const subs = commands.map((command) => command.timer.subscribe((event) => {
if (!event.endDate) {
const content = this.logger.getPrefixContent(command);
length = Math.max(length, visibleLength(content?.value));
this.logger.setPrefixLength(length);
}
}));
return {
commands,
onFinish() {
subs.forEach((sub) => sub.unsubscribe());
},
};
}
}

View File

@@ -0,0 +1,18 @@
import { Writable } from 'node:stream';
import { Command } from '../command.js';
import { FlowController } from './flow-controller.js';
/**
* Kills processes and aborts further command spawning on output stream error (namely, SIGPIPE).
*/
export declare class OutputErrorHandler implements FlowController {
private readonly outputStream;
private readonly abortController;
constructor({ abortController, outputStream, }: {
abortController: AbortController;
outputStream: Writable;
});
handle(commands: Command[]): {
commands: Command[];
onFinish: () => void;
};
}

View File

@@ -0,0 +1,23 @@
import { fromSharedEvent } from '../observables.js';
/**
* Kills processes and aborts further command spawning on output stream error (namely, SIGPIPE).
*/
export class OutputErrorHandler {
outputStream;
abortController;
constructor({ abortController, outputStream, }) {
this.abortController = abortController;
this.outputStream = outputStream;
}
handle(commands) {
const subscription = fromSharedEvent(this.outputStream, 'error').subscribe(() => {
commands.forEach((command) => command.kill());
// Avoid further commands from spawning, e.g. if `RestartProcess` is used.
this.abortController.abort();
});
return {
commands,
onFinish: () => subscription.unsubscribe(),
};
}
}

View File

@@ -0,0 +1,23 @@
import Rx from 'rxjs';
import { Command } from '../command.js';
import { Logger } from '../logger.js';
import { FlowController } from './flow-controller.js';
export type RestartDelay = number | 'exponential';
/**
* Restarts commands that fail up to a defined number of times.
*/
export declare class RestartProcess implements FlowController {
private readonly logger;
private readonly scheduler?;
private readonly delay;
readonly tries: number;
constructor({ delay, tries, logger, scheduler, }: {
delay?: RestartDelay;
tries?: number;
logger: Logger;
scheduler?: Rx.SchedulerLike;
});
handle(commands: Command[]): {
commands: Command[];
};
}

View File

@@ -0,0 +1,61 @@
import Rx from 'rxjs';
import { defaultIfEmpty, delayWhen, filter, map, skip, take, takeWhile } from 'rxjs/operators';
import * as defaults from '../defaults.js';
/**
* Restarts commands that fail up to a defined number of times.
*/
export class RestartProcess {
logger;
scheduler;
delay;
tries;
constructor({ delay, tries, logger, scheduler, }) {
this.logger = logger;
this.delay = delay ?? 0;
this.tries = tries != null ? +tries : defaults.restartTries;
this.tries = this.tries < 0 ? Infinity : this.tries;
this.scheduler = scheduler;
}
handle(commands) {
if (this.tries === 0) {
return { commands };
}
const delayOperator = delayWhen((_, index) => {
const { delay } = this;
const value = delay === 'exponential' ? 2 ** index * 1000 : delay;
return Rx.timer(value, this.scheduler);
});
commands
.map((command) => command.close.pipe(take(this.tries), takeWhile(({ exitCode }) => exitCode !== 0)))
.forEach((failure, index) => Rx.merge(
// Delay the emission (so that the restarts happen on time),
// explicitly telling the subscriber that a restart is needed
failure.pipe(delayOperator, map(() => true)),
// Skip the first N emissions (as these would be duplicates of the above),
// meaning it will be empty because of success, or failed all N times,
// and no more restarts should be attempted.
failure.pipe(skip(this.tries), map(() => false), defaultIfEmpty(false))).subscribe((restart) => {
const command = commands[index];
if (restart) {
this.logger.logCommandEvent(`${command.command} restarted`, command);
command.start();
}
}));
return {
commands: commands.map((command) => {
const closeStream = command.close.pipe(filter(({ exitCode }, emission) => {
// We let all success codes pass, and failures only after restarting won't happen again
return exitCode === 0 || emission >= this.tries;
}));
// Return a proxy so that mutations happen on the original Command object.
// If either `Object.assign()` or `Object.create()` were used, it'd be hard to
// reflect the mutations on Command objects referenced by previous flow controllers.
return new Proxy(command, {
get(target, prop) {
return prop === 'close' ? closeStream : target[prop];
},
});
}),
};
}
}

View File

@@ -0,0 +1,20 @@
import { Command, SpawnCommand } from '../command.js';
import { Logger } from '../logger.js';
import { FlowController } from './flow-controller.js';
export declare class Teardown implements FlowController {
private readonly logger;
private readonly spawn;
private readonly teardown;
constructor({ logger, spawn, commands, }: {
logger: Logger;
/**
* Which function to use to spawn commands.
*/
spawn: SpawnCommand;
commands: readonly string[];
});
handle(commands: Command[]): {
commands: Command[];
onFinish: () => Promise<void>;
};
}

View File

@@ -0,0 +1,45 @@
import Rx from 'rxjs';
import { getSpawnOpts } from '../spawn.js';
export class Teardown {
logger;
spawn;
teardown;
constructor({ logger, spawn, commands, }) {
this.logger = logger;
this.spawn = spawn;
this.teardown = commands;
}
handle(commands) {
const { logger, teardown, spawn } = this;
const onFinish = async () => {
if (!teardown.length) {
return;
}
for (const command of teardown) {
logger.logGlobalEvent(`Running teardown command "${command}"`);
const child = spawn(command, getSpawnOpts({ stdio: 'raw' }));
const error = Rx.fromEvent(child, 'error');
const close = Rx.fromEvent(child, 'close');
try {
const [exitCode, signal] = await Promise.race([
Rx.firstValueFrom(error).then((event) => {
throw event;
}),
Rx.firstValueFrom(close).then((event) => event),
]);
logger.logGlobalEvent(`Teardown command "${command}" exited with code ${exitCode ?? signal}`);
if (signal === 'SIGINT') {
break;
}
}
catch (error) {
const errorText = String(error instanceof Error ? error.stack || error : error);
logger.logGlobalEvent(`Teardown command "${command}" errored:`);
logger.logGlobalEvent(errorText);
return Promise.reject(error);
}
}
};
return { commands, onFinish };
}
}