concurrently
Version:
Run commands concurrently
301 lines (300 loc) • 12.2 kB
JavaScript
import chalk, { Chalk } from 'chalk';
import Rx from 'rxjs';
import { DateFormatter } from './date-format.js';
import * as defaults from './defaults.js';
import { escapeRegExp, splitOutsideParens } from './utils.js';
const defaultChalk = chalk;
const noColorChalk = new Chalk({ level: 0 });
const HEX_PATTERN = /^#[0-9A-Fa-f]{3,6}$/;
const COLOR_OPEN = '{color}';
const COLOR_CLOSE = '{/color}';
export const COLOR_MARKER_RE = /\{\/?color\}/g;
/**
* Applies a single color segment to a chalk instance.
* Handles: function calls (hex, bgHex, rgb, bgRgb, ansi256, bgAnsi256, etc.),
* shorthands (#HEX, bg#HEX), and named colors/modifiers.
*/
function applySegment(color, segment) {
// Function call: name(args) - handles chalk color functions
const fnMatch = segment.match(/^(\w+)\((.+)\)$/);
if (fnMatch) {
const [, fnName, argsStr] = fnMatch;
const args = argsStr.split(',').map((a) => {
const t = a.trim();
return /^\d+$/.test(t) ? parseInt(t, 10) : t;
});
// Explicit function calls for known chalk color functions
switch (fnName) {
case 'rgb':
return color.rgb(args[0], args[1], args[2]);
case 'bgRgb':
return color.bgRgb(args[0], args[1], args[2]);
case 'hex':
if (!HEX_PATTERN.test(args[0]))
return undefined;
return color.hex(args[0]);
case 'bgHex':
if (!HEX_PATTERN.test(args[0]))
return undefined;
return color.bgHex(args[0]);
case 'ansi256':
return color.ansi256(args[0]);
case 'bgAnsi256':
return color.bgAnsi256(args[0]);
default:
return undefined;
}
}
// Shorthands
if (segment.startsWith('bg#'))
return color.bgHex(segment.slice(2));
if (segment.startsWith('#'))
return color.hex(segment);
// Property: black, bold, dim, etc.
return color[segment] ?? undefined;
}
/**
* Applies a color string to chalk, supporting chained colors and modifiers.
* Returns undefined if any segment is invalid (triggers fallback to default).
*/
function applyColor(chalkInstance, colorString) {
const segments = splitOutsideParens(colorString, '.');
if (segments.length === 0)
return undefined;
let color = chalkInstance;
for (const segment of segments) {
const next = applySegment(color, segment);
if (!next)
return undefined;
color = next;
}
return color;
}
export class Logger {
hide;
raw;
prefixFormat;
commandLength;
dateFormatter;
chalk = defaultChalk;
/**
* How many characters should a prefix have.
* Prefixes shorter than this will be padded with spaces to the right.
*/
prefixLength = 0;
/**
* Last character emitted, and from which command.
* If `undefined`, then nothing has been logged yet.
*/
lastWrite;
/**
* Observable that emits when there's been output logged.
* If `command` is is `undefined`, then the log is for a global event.
*/
output = new Rx.Subject();
constructor({ hide, prefixFormat, commandLength, raw = false, timestampFormat, }) {
this.hide = (hide || []).map(String);
this.raw = raw;
this.prefixFormat = prefixFormat;
this.commandLength = commandLength || defaults.prefixLength;
this.dateFormatter = new DateFormatter(timestampFormat || defaults.timestampFormat);
}
/**
* Toggles colors on/off globally.
*/
toggleColors(on) {
this.chalk = on ? defaultChalk : noColorChalk;
}
shortenText(text) {
if (!text || text.length <= this.commandLength) {
return text;
}
const ellipsis = '..';
const prefixLength = this.commandLength - ellipsis.length;
const endLength = Math.floor(prefixLength / 2);
const beginningLength = prefixLength - endLength;
const beginning = text.slice(0, beginningLength);
const end = text.slice(text.length - endLength, text.length);
return beginning + ellipsis + end;
}
getPrefixesFor(command) {
return {
// When there's limited concurrency, the PID might not be immediately available,
// so avoid the string 'undefined' from becoming a prefix
pid: command.pid != null ? String(command.pid) : '',
index: String(command.index),
name: command.name,
command: this.shortenText(command.command),
time: this.dateFormatter.format(new Date()),
};
}
getPrefixContent(command) {
const prefix = this.prefixFormat || (command.name ? 'name' : 'index');
if (prefix === 'none') {
return;
}
const prefixes = this.getPrefixesFor(command);
if (Object.keys(prefixes).includes(prefix)) {
return { type: 'default', value: prefixes[prefix] };
}
const value = Object.entries(prefixes).reduce((prev, [key, val]) => {
const keyRegex = new RegExp(escapeRegExp(`{${key}}`), 'g');
return prev.replace(keyRegex, String(val));
}, prefix);
return { type: 'template', value };
}
getPrefix(command) {
const content = this.getPrefixContent(command);
if (!content) {
return '';
}
const visibleLength = content.value.replace(COLOR_MARKER_RE, '').length;
const padding = ' '.repeat(Math.max(0, this.prefixLength - visibleLength));
return content.type === 'template'
? content.value + padding
: `[${content.value}${padding}]`;
}
setPrefixLength(length) {
this.prefixLength = length;
}
colorText(command, text) {
const prefixColor = command.prefixColor ?? '';
const defaultColor = applyColor(this.chalk, defaults.prefixColors) ?? this.chalk.reset;
const color = applyColor(this.chalk, prefixColor) ?? defaultColor;
// Segment the text around `{color}` / `{/color}` markers and only apply `color`
// inside opened regions. If either marker is missing, it's implicitly added to
// the start or end respectively — so a marker-free input stays fully colored,
// preserving backward compatibility.
let normalized = text;
if (!normalized.includes(COLOR_OPEN))
normalized = COLOR_OPEN + normalized;
if (!normalized.includes(COLOR_CLOSE))
normalized = normalized + COLOR_CLOSE;
let output = '';
let rest = normalized;
let inColorRegion = false;
while (rest.length > 0) {
const marker = inColorRegion ? COLOR_CLOSE : COLOR_OPEN;
const idx = rest.indexOf(marker);
if (idx === -1) {
// Tail after the last closing marker: normalization guarantees a
// `{/color}` exists, so once opened a region always finds its close —
// reaching here implies `inColorRegion` is false and the tail is plain.
output += rest;
break;
}
const segment = rest.slice(0, idx);
output += inColorRegion ? color(segment) : segment;
rest = rest.slice(idx + marker.length);
inColorRegion = !inColorRegion;
}
return output;
}
/**
* Logs an event for a command (e.g. start, stop).
*
* If raw mode is on, then nothing is logged.
*/
logCommandEvent(text, command) {
if (this.raw) {
return;
}
// Last write was from this command, but it didn't end with a line feed.
// Prepend one, otherwise the event's text will be concatenated to that write.
// A line feed is otherwise inserted anyway.
let prefix = '';
if (this.lastWrite?.command === command && this.lastWrite.char !== '\n') {
prefix = '\n';
}
this.logCommandText(`${prefix}${this.chalk.reset(text)}\n`, command);
}
logCommandText(text, command) {
if (this.hide.includes(String(command.index)) || this.hide.includes(command.name)) {
return;
}
const prefix = this.colorText(command, this.getPrefix(command));
return this.log(prefix + (prefix ? ' ' : ''), text, command);
}
/**
* Logs a global event (e.g. sending signals to processes).
*
* If raw mode is on, then nothing is logged.
*/
logGlobalEvent(text) {
if (this.raw) {
return;
}
this.log(`${this.chalk.reset('-->')} `, `${this.chalk.reset(text)}\n`);
}
/**
* Logs a table from an input object array, like `console.table`.
*
* Each row is a single input item, and they are presented in the input order.
*/
logTable(tableContents) {
// For now, can only print array tables with some content.
if (this.raw || !Array.isArray(tableContents) || !tableContents.length) {
return;
}
let nextColIndex = 0;
const headers = {};
const contentRows = tableContents.map((row) => {
const rowContents = [];
Object.keys(row).forEach((col) => {
if (!headers[col]) {
headers[col] = {
index: nextColIndex++,
length: col.length,
};
}
const colIndex = headers[col].index;
const formattedValue = String(row[col] == null ? '' : row[col]);
// Update the column length in case this rows value is longer than the previous length for the column.
headers[col].length = Math.max(formattedValue.length, headers[col].length);
rowContents[colIndex] = formattedValue;
return rowContents;
});
return rowContents;
});
const headersFormatted = Object.keys(headers).map((header) => header.padEnd(headers[header].length, ' '));
if (!headersFormatted.length) {
// No columns exist.
return;
}
const borderRowFormatted = headersFormatted.map((header) => '─'.padEnd(header.length, '─'));
this.logGlobalEvent(`┌─${borderRowFormatted.join('─┬─')}─┐`);
this.logGlobalEvent(`│ ${headersFormatted.join(' │ ')} │`);
this.logGlobalEvent(`├─${borderRowFormatted.join('─┼─')}─┤`);
contentRows.forEach((contentRow) => {
const contentRowFormatted = headersFormatted.map((header, colIndex) => {
// If the table was expanded after this row was processed, it won't have this column.
// Use an empty string in this case.
const col = contentRow[colIndex] || '';
return col.padEnd(header.length, ' ');
});
this.logGlobalEvent(`│ ${contentRowFormatted.join(' │ ')} │`);
});
this.logGlobalEvent(`└─${borderRowFormatted.join('─┴─')}─┘`);
}
log(prefix, text, command) {
if (this.raw) {
return this.emit(command, text);
}
// #70 - replace some ANSI code that would impact clearing lines
text = text.replace(/\u2026/g, '...');
// This write's interrupting another command, emit a line feed to start clean.
if (this.lastWrite && this.lastWrite.command !== command && this.lastWrite.char !== '\n') {
this.emit(this.lastWrite.command, '\n');
}
// Clean lines should emit a prefix
if (!this.lastWrite || this.lastWrite.char === '\n') {
this.emit(command, prefix);
}
const textToWrite = text.replaceAll('\n', (lf, i) => lf + (text[i + 1] ? prefix : ''));
this.emit(command, textToWrite);
}
emit(command, text) {
this.lastWrite = { command, char: text[text.length - 1] };
this.output.next({ command, text });
}
}