@oclif/multi-stage-output
Version:
Terminal output for oclif commands with multiple stages
457 lines (456 loc) • 17.8 kB
JavaScript
import { ux } from '@oclif/core/ux';
import { render } from 'ink';
import { env } from 'node:process';
import React from 'react';
import { Stages, } from './components/stages.js';
import { constructDesignParams } from './design.js';
import { StageTracker } from './stage-tracker.js';
import { readableTime } from './utils.js';
function isTruthy(value) {
return value !== '0' && value !== 'false';
}
/**
* Determines whether the CI mode should be used.
*
* If the MSO_DISABLE_CI_MODE environment variable is set to a truthy value, CI mode will be disabled.
*
* If the CI environment variable is set, CI mode will be enabled.
*
* If the DEBUG environment variable is set, CI mode will be enabled.
*
* @returns {boolean} True if CI mode should be used, false otherwise.
*/
function shouldUseCIMode() {
if (env.MSO_DISABLE_CI_MODE && isTruthy(env.MSO_DISABLE_CI_MODE))
return false;
// Inspired by https://github.com/sindresorhus/is-in-ci
if (isTruthy(env.CI) &&
('CI' in env || 'CONTINUOUS_INTEGRATION' in env || Object.keys(env).some((key) => key.startsWith('CI_'))))
return true;
if (env.DEBUG && isTruthy(env.DEBUG))
return true;
return false;
}
const isInCi = shouldUseCIMode();
class CIMultiStageOutput {
completedStages = new Set();
data;
design;
hasElapsedTime;
hasStageTime;
/**
* Amount of time (in milliseconds) between heartbeat updates
*/
heartbeat = Number.parseInt(env.OCLIF_CI_HEARTBEAT_FREQUENCY_MS ?? env.SF_CI_HEARTBEAT_FREQUENCY_MS ?? '300000', 10) ?? 300_000;
/**
* Time of the last heartbeat
*/
lastHeartbeatTime;
/**
* Map of the last time a specific piece of info was updated. This is used for throttling messages
*/
lastUpdateByInfo = new Map();
postStagesBlock;
preStagesBlock;
seenStrings = new Set();
stages;
stageSpecificBlock;
startTime;
startTimes = new Map();
/**
* Amount of time (in milliseconds) between throttled updates
*/
throttle = Number.parseInt(env.OCLIF_CI_UPDATE_FREQUENCY_MS ?? env.SF_CI_UPDATE_FREQUENCY_MS ?? '5000', 10) ?? 5000;
timerUnit;
/**
* Map of intervals used to trigger heartbeat updates
*/
updateIntervals = new Map();
constructor({ data, design, postStagesBlock, preStagesBlock, showElapsedTime, showStageTime, stages, stageSpecificBlock, timerUnit, title, }) {
this.design = constructDesignParams(design);
this.stages = stages;
this.postStagesBlock = postStagesBlock;
this.preStagesBlock = preStagesBlock;
this.hasElapsedTime = showElapsedTime ?? true;
this.hasStageTime = showStageTime ?? true;
this.stageSpecificBlock = stageSpecificBlock;
this.timerUnit = timerUnit ?? 'ms';
this.data = data;
this.lastHeartbeatTime = Date.now();
if (title)
ux.stdout(`───── ${title} ─────`);
ux.stdout('Stages:');
for (const stage of this.stages) {
ux.stdout(`${this.stages.indexOf(stage) + 1}. ${stage}`);
}
ux.stdout();
if (this.hasElapsedTime) {
this.startTime = Date.now();
}
}
stop(stageTracker) {
this.update(stageTracker);
ux.stdout();
this.maybePrintInfo(this.preStagesBlock, 0, true);
this.maybePrintInfo(this.postStagesBlock, 0, true);
if (this.startTime) {
const elapsedTime = Date.now() - this.startTime;
ux.stdout();
const displayTime = readableTime(elapsedTime, this.timerUnit);
ux.stdout(`Elapsed time: ${displayTime}`);
}
for (const interval of this.updateIntervals.values()) {
clearInterval(interval);
}
}
// eslint-disable-next-line complexity
update(stageTracker, data) {
this.data = { ...this.data, ...data };
for (const [stage, status] of stageTracker.entries()) {
// no need to re-render completed, failed, or skipped stages
if (this.completedStages.has(stage))
continue;
switch (status) {
case 'aborted':
case 'async':
case 'completed':
case 'failed':
case 'paused':
case 'skipped':
case 'warning': {
// clear the heartbeat interval since it's no longer needed
const interval = this.updateIntervals.get(stage);
if (interval) {
clearInterval(interval);
this.updateIntervals.delete(stage);
}
// clear all throttled messages since the stage is done
for (const key of this.lastUpdateByInfo.keys()) {
this.lastUpdateByInfo.delete(key);
}
const stageInfos = this.stageSpecificBlock?.filter((info) => info.stage === stage);
this.completedStages.add(stage);
if (this.hasStageTime && status !== 'skipped') {
const startTime = this.startTimes.get(stage);
const elapsedTime = startTime ? Date.now() - startTime : 0;
const displayTime = readableTime(elapsedTime, this.timerUnit);
this.maybeStdout(`${this.design.icons[status].figure} ${stage} (${displayTime})`);
this.maybePrintInfo(this.preStagesBlock, 3);
this.maybePrintInfo(stageInfos, 3);
this.maybePrintInfo(this.postStagesBlock, 3);
}
else if (status === 'skipped') {
this.maybeStdout(`${this.design.icons[status].figure} ${stage} - Skipped`);
}
else {
this.maybeStdout(`${this.design.icons[status].figure} ${stage}`);
this.maybePrintInfo(this.preStagesBlock, 3);
this.maybePrintInfo(stageInfos, 3);
this.maybePrintInfo(this.postStagesBlock, 3);
}
break;
}
case 'current': {
if (!this.startTimes.has(stage))
this.startTimes.set(stage, Date.now());
const stageInfos = this.stageSpecificBlock?.filter((info) => info.stage === stage);
const iconAndStage = `${this.design.icons.current.figure} ${stage}…`;
if (Date.now() - this.lastHeartbeatTime < this.heartbeat) {
// only print if it hasn't been seen before
this.maybeStdout(iconAndStage);
this.maybePrintInfo(this.preStagesBlock, 3);
this.maybePrintInfo(stageInfos, 3);
this.maybePrintInfo(this.postStagesBlock, 3);
}
else {
// force a reprint if it's been too long
this.lastHeartbeatTime = Date.now();
if (stageInfos?.length) {
// only reprint the stage infos if it has them
this.maybePrintInfo(stageInfos, 3, true);
}
else {
// only reprint the stage
this.maybeStdout(iconAndStage, 0, true);
}
}
if (!this.updateIntervals.has(stage)) {
// set interval to update the stage message - this is used for long running stages in CI environments that timeout after a certain period without output
this.updateIntervals.set(stage, setInterval(() => {
this.update(stageTracker);
}, this.heartbeat));
}
break;
}
case 'pending': {
// do nothing
break;
}
default:
// do nothing
}
}
}
maybePrintInfo(infoBlock, indent = 0, force = false) {
if (infoBlock?.length) {
for (const info of infoBlock) {
if (info.onlyShowAtEndInCI && !force)
continue;
const formattedData = info.get ? info.get(this.data) : undefined;
if (!formattedData)
continue;
const key = info.type === 'message' ? formattedData : info.label;
const str = info.type === 'message' ? formattedData : `${info.label}: ${formattedData}`;
if (!info.alwaysPrintInCI) {
const lastUpdateTime = this.lastUpdateByInfo.get(key);
// Skip if the info has been printed before the throttle time
if (lastUpdateTime && Date.now() - lastUpdateTime < this.throttle && !force)
continue;
}
const didPrint = this.maybeStdout(str, indent, force);
if (didPrint)
this.lastUpdateByInfo.set(key, Date.now());
}
}
}
maybeStdout(str, indent = 0, force = false) {
const spaces = ' '.repeat(indent);
if (!force && this.seenStrings.has(str))
return false;
ux.stdout(`${spaces}${str}`);
this.seenStrings.add(str);
return true;
}
}
class MultiStageOutputBase {
ciInstance;
data;
design;
hasElapsedTime;
hasStageTime;
inkInstance;
postStagesBlock;
preStagesBlock;
stages;
stageSpecificBlock;
stageTracker;
stopped = false;
timerUnit;
title;
constructor({ data, design, jsonEnabled = false, postStagesBlock, preStagesBlock, showElapsedTime, showStageTime, stages, stageSpecificBlock, timerUnit, title, }, allowParallelTasks) {
this.data = data;
this.design = constructDesignParams(design);
this.stages = stages;
this.title = title;
this.postStagesBlock = postStagesBlock;
this.preStagesBlock = preStagesBlock;
this.hasElapsedTime = showElapsedTime ?? true;
this.hasStageTime = showStageTime ?? true;
this.timerUnit = timerUnit ?? 'ms';
this.stageTracker = new StageTracker(stages, { allowParallelTasks });
this.stageSpecificBlock = stageSpecificBlock;
if (jsonEnabled)
return;
if (isInCi) {
this.ciInstance = new CIMultiStageOutput({
data,
design,
jsonEnabled,
postStagesBlock,
preStagesBlock,
showElapsedTime,
showStageTime,
stages,
stageSpecificBlock,
timerUnit,
title,
});
}
else {
this.inkInstance = render(React.createElement(Stages, { ...this.generateStagesInput() }));
}
}
/**
* Stop multi-stage output from running with a failed status.
*/
error() {
this.stop('failed');
}
formatKeyValuePairs(infoBlock) {
return (infoBlock?.map((info) => {
const formattedData = info.get ? info.get(this.data) : undefined;
return {
color: info.color,
isBold: info.bold,
neverCollapse: info.neverCollapse,
type: info.type,
value: formattedData,
...(info.type === 'message' ? {} : { label: info.label }),
...('stage' in info ? { stage: info.stage } : {}),
};
}) ?? []);
}
/** shared method to populate everything needed for Stages cmp */
generateStagesInput(opts) {
const { compactionLevel } = opts ?? {};
return {
compactionLevel,
design: this.design,
hasElapsedTime: this.hasElapsedTime,
hasStageTime: this.hasStageTime,
postStagesBlock: this.formatKeyValuePairs(this.postStagesBlock),
preStagesBlock: this.formatKeyValuePairs(this.preStagesBlock),
stageSpecificBlock: this.formatKeyValuePairs(this.stageSpecificBlock),
stageTracker: this.stageTracker,
timerUnit: this.timerUnit,
title: this.title,
};
}
rerender() {
if (isInCi) {
this.ciInstance?.update(this.stageTracker, this.data);
}
else {
this.inkInstance?.rerender(React.createElement(Stages, { ...this.generateStagesInput() }));
}
}
/**
* Stop multi-stage output from running.
*
* The stage currently running will be changed to the provided `finalStatus`.
*
* @param finalStatus - The status to set the current stage to.
* @returns void
*/
stop(finalStatus = 'completed') {
if (this.stopped)
return;
this.stopped = true;
this.stageTracker.stop(this.stageTracker.current[0] ?? this.stages[0], finalStatus);
if (isInCi) {
this.ciInstance?.stop(this.stageTracker);
return;
}
// The underlying components expect an Error, although they don't currently use anything on the error - they check if it exists.
// Instead of refactoring the components to take a boolean, we pass in a placeholder Error,
// which, gives us the flexibility in the future to pass in an actual Error if we want
const error = finalStatus === 'failed' ? new Error('Error') : undefined;
const stagesInput = { ...this.generateStagesInput({ compactionLevel: 0 }), ...(error ? { error } : {}) };
this.inkInstance?.rerender(React.createElement(Stages, { ...stagesInput, compactionLevel: 0 }));
this.inkInstance?.unmount();
}
[Symbol.dispose]() {
this.inkInstance?.unmount();
}
/**
* Updates the data of the component.
*
* @param data - The partial data object to update the component's data with.
* @returns void
*/
updateData(data) {
if (this.stopped)
return;
this.data = { ...this.data, ...data };
this.rerender();
}
}
export class MultiStageOutput extends MultiStageOutputBase {
constructor(options) {
super(options);
}
/**
* Go to a stage, marking any stages in between the current stage and the provided stage as completed.
*
* If the stage does not exist or is before the current stage, nothing will happen.
*
* If the stage is the same as the current stage, the data will be updated.
*
* @param stage Stage to go to
* @param data - Optional data to pass to the next stage.
* @returns void
*/
goto(stage, data) {
if (this.stopped)
return;
// ignore non-existent stages
if (!this.stages.includes(stage))
return;
// prevent going to a previous stage
if (this.stages.indexOf(stage) < this.stages.indexOf(this.stageTracker.current[0] ?? this.stages[0]))
return;
this.update(stage, 'completed', data);
}
/**
* Moves to the next stage of the process.
*
* @param data - Optional data to pass to the next stage.
* @returns void
*/
next(data) {
if (this.stopped)
return;
const nextStageIndex = this.stages.indexOf(this.stageTracker.current[0] ?? this.stages[0]) + 1;
if (nextStageIndex < this.stages.length) {
this.update(this.stages[nextStageIndex], 'completed', data);
}
}
/**
* Go to a stage, marking any stages in between the current stage and the provided stage as skipped.
*
* If the stage does not exist or is before the current stage, nothing will happen.
*
* If the stage is the same as the current stage, the data will be updated.
*
* @param stage Stage to go to
* @param data - Optional data to pass to the next stage.
* @returns void
*/
skipTo(stage, data) {
if (this.stopped)
return;
// ignore non-existent stages
if (!this.stages.includes(stage))
return;
// prevent going to a previous stage
if (this.stages.indexOf(stage) < this.stages.indexOf(this.stageTracker.current[0] ?? this.stages[0]))
return;
this.update(stage, 'skipped', data);
}
update(stage, bypassStatus, data) {
this.data = { ...this.data, ...data };
this.stageTracker.refresh(stage, { bypassStatus });
this.rerender();
}
}
export class ParallelMultiStageOutput extends MultiStageOutputBase {
constructor(options) {
super(options, true);
}
pauseStage(stage, data) {
this.update(stage, 'paused', data);
}
resumeStage(stage, data) {
this.update(stage, 'current', data);
}
startStage(stage, data) {
this.update(stage, 'current', data);
}
stopStage(stage, data) {
this.update(stage, 'completed', data);
}
updateStage(stage, status, data) {
this.update(stage, status, data);
}
update(stage, status, data) {
if (this.stopped)
return;
if (!this.stages.includes(stage))
return;
if (this.stageTracker.get(stage) === 'completed')
return;
this.data = { ...this.data, ...data };
this.stageTracker.update(stage, status);
this.rerender();
}
}