2026-05-13 20:18:52 +00:00
|
|
|
import * as plugins from './smartcli.plugins.js';
|
|
|
|
|
|
2026-05-13 14:52:18 +00:00
|
|
|
export type TSmartcliTerminalTaskStatus = 'running' | 'completed' | 'failed';
|
2026-05-13 18:33:06 +00:00
|
|
|
export type TSmartcliTerminalSymbolMode = 'auto' | 'unicode' | 'ascii';
|
2026-05-13 14:52:18 +00:00
|
|
|
|
|
|
|
|
export interface ISmartcliWritable {
|
|
|
|
|
isTTY?: boolean;
|
|
|
|
|
columns?: number;
|
|
|
|
|
write(chunk: string): void | boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ISmartcliTerminalOptions {
|
|
|
|
|
stream?: ISmartcliWritable;
|
|
|
|
|
interactive?: boolean;
|
|
|
|
|
colors?: boolean;
|
2026-05-13 18:33:06 +00:00
|
|
|
symbols?: TSmartcliTerminalSymbolMode;
|
|
|
|
|
cleanup?: boolean;
|
|
|
|
|
nonInteractiveThrottleMs?: number;
|
2026-05-13 14:52:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ISmartcliTerminalTaskOptions {
|
|
|
|
|
job: string;
|
|
|
|
|
rows?: number;
|
|
|
|
|
logLimit?: number;
|
2026-05-13 20:18:52 +00:00
|
|
|
showTimer?: boolean;
|
|
|
|
|
showSpinner?: boolean;
|
|
|
|
|
timer?: boolean;
|
|
|
|
|
spinner?: boolean;
|
|
|
|
|
spinnerFrames?: string[];
|
|
|
|
|
spinnerIntervalMs?: number;
|
2026-05-13 14:52:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ISmartcliTerminalAttachErrorOptions {
|
|
|
|
|
keepOpen?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 18:33:06 +00:00
|
|
|
export interface ISmartcliTerminalTaskRunOptions {
|
|
|
|
|
successMessage?: string;
|
|
|
|
|
errorKeepOpen?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface INonInteractiveLogState {
|
|
|
|
|
message: string;
|
|
|
|
|
timestamp: number;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 14:52:18 +00:00
|
|
|
const ansiCodes = {
|
|
|
|
|
reset: '\u001B[0m',
|
|
|
|
|
red: '\u001B[31m',
|
|
|
|
|
green: '\u001B[32m',
|
|
|
|
|
cyan: '\u001B[36m',
|
|
|
|
|
gray: '\u001B[90m',
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-13 18:33:06 +00:00
|
|
|
const unicodeSymbols = {
|
|
|
|
|
running: '●',
|
|
|
|
|
completed: '✓',
|
|
|
|
|
failed: '✕',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const asciiSymbols = {
|
|
|
|
|
running: '*',
|
|
|
|
|
completed: 'OK',
|
|
|
|
|
failed: 'X',
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-13 20:18:52 +00:00
|
|
|
const unicodeSpinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
|
|
|
const asciiSpinnerFrames = ['-', '\\', '|', '/'];
|
|
|
|
|
|
2026-05-13 14:52:18 +00:00
|
|
|
/**
|
|
|
|
|
* A live terminal renderer for multiple fixed-row tasks.
|
|
|
|
|
* It automatically falls back to append-only logs in non-interactive environments.
|
|
|
|
|
*/
|
|
|
|
|
export class SmartcliTerminal {
|
|
|
|
|
private stream: ISmartcliWritable;
|
|
|
|
|
private interactive: boolean;
|
|
|
|
|
private colors: boolean;
|
2026-05-13 18:33:06 +00:00
|
|
|
private useUnicodeSymbols: boolean;
|
|
|
|
|
private cleanupEnabled: boolean;
|
|
|
|
|
private nonInteractiveThrottleMs: number;
|
2026-05-13 14:52:18 +00:00
|
|
|
private tasks: SmartcliTerminalTask[] = [];
|
|
|
|
|
private renderedLineCount = 0;
|
2026-05-13 18:33:06 +00:00
|
|
|
private lastRenderedOutput = '';
|
|
|
|
|
private cursorHidden = false;
|
|
|
|
|
private cleanupRegistered = false;
|
2026-05-13 20:18:52 +00:00
|
|
|
private liveRenderInterval: ReturnType<typeof setInterval> | null = null;
|
|
|
|
|
private liveRenderIntervalMs = 0;
|
2026-05-13 18:33:06 +00:00
|
|
|
private nonInteractiveLogState = new Map<SmartcliTerminalTask, INonInteractiveLogState>();
|
|
|
|
|
private cleanupHandlers: Array<() => void> = [];
|
2026-05-13 14:52:18 +00:00
|
|
|
|
|
|
|
|
constructor(optionsArg: ISmartcliTerminalOptions = {}) {
|
|
|
|
|
this.stream = optionsArg.stream || getDefaultStream();
|
|
|
|
|
this.interactive = getInteractiveState(this.stream, optionsArg.interactive);
|
|
|
|
|
this.colors = optionsArg.colors ?? (this.interactive && !hasEnvFlag('NO_COLOR'));
|
2026-05-13 18:33:06 +00:00
|
|
|
this.useUnicodeSymbols = getUnicodeSymbolState(this.stream, optionsArg.symbols);
|
|
|
|
|
this.cleanupEnabled = optionsArg.cleanup ?? true;
|
|
|
|
|
this.nonInteractiveThrottleMs = Math.max(0, optionsArg.nonInteractiveThrottleMs ?? 250);
|
2026-05-13 14:52:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public createTask(optionsArg: ISmartcliTerminalTaskOptions): SmartcliTerminalTask {
|
|
|
|
|
const task = new SmartcliTerminalTask(this, optionsArg);
|
|
|
|
|
this.tasks.push(task);
|
|
|
|
|
|
|
|
|
|
if (this.interactive) {
|
2026-05-13 18:33:06 +00:00
|
|
|
this.ensureInteractiveSession();
|
2026-05-13 14:52:18 +00:00
|
|
|
this.render();
|
2026-05-13 20:18:52 +00:00
|
|
|
this.updateLiveRenderLoop();
|
2026-05-13 14:52:18 +00:00
|
|
|
} else {
|
2026-05-13 18:33:06 +00:00
|
|
|
this.writePermanentLine(`start ${task.job}`);
|
2026-05-13 14:52:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return task;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public createProcess(optionsArg: ISmartcliTerminalTaskOptions): SmartcliTerminalTask {
|
|
|
|
|
return this.createTask(optionsArg);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 18:33:06 +00:00
|
|
|
public task(jobArg: string, optionsArg: Omit<ISmartcliTerminalTaskOptions, 'job'> = {}) {
|
|
|
|
|
return this.createTask({
|
|
|
|
|
...optionsArg,
|
|
|
|
|
job: jobArg,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 14:52:18 +00:00
|
|
|
public isInteractive(): boolean {
|
|
|
|
|
return this.interactive;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public getTasks(): SmartcliTerminalTask[] {
|
|
|
|
|
return [...this.tasks];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @internal */
|
|
|
|
|
public updateTask(taskArg: SmartcliTerminalTask, messageArg?: string): void {
|
|
|
|
|
if (!this.tasks.includes(taskArg)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.interactive) {
|
2026-05-13 20:18:52 +00:00
|
|
|
this.updateLiveRenderLoop();
|
2026-05-13 14:52:18 +00:00
|
|
|
this.render();
|
2026-05-13 18:33:06 +00:00
|
|
|
} else if (messageArg && this.shouldWriteNonInteractiveUpdate(taskArg, messageArg)) {
|
2026-05-13 20:18:52 +00:00
|
|
|
for (const messageLine of normalizeLines(messageArg)) {
|
|
|
|
|
this.writePermanentLine(`update ${taskArg.job}: ${messageLine}`);
|
|
|
|
|
}
|
2026-05-13 14:52:18 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @internal */
|
|
|
|
|
public completeTask(taskArg: SmartcliTerminalTask, messageArg?: string): void {
|
|
|
|
|
const message = messageArg || taskArg.getLastLogLine();
|
2026-05-13 18:33:06 +00:00
|
|
|
const summary = this.interactive
|
2026-05-13 20:18:52 +00:00
|
|
|
? `${this.getStatusSymbol('completed')} ${taskArg.job} ${taskArg.getElapsedSummaryText()}${message ? ` - ${message}` : ''}`
|
|
|
|
|
: `done ${taskArg.job} in ${taskArg.getElapsedSummaryText()}${message ? `: ${message}` : ''}`;
|
2026-05-13 18:33:06 +00:00
|
|
|
this.finalizeTask(taskArg, [summary], 'completed');
|
2026-05-13 14:52:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @internal */
|
|
|
|
|
public failTask(taskArg: SmartcliTerminalTask, errorLinesArg: string[]): void {
|
2026-05-13 18:33:06 +00:00
|
|
|
const summary = this.interactive
|
2026-05-13 20:18:52 +00:00
|
|
|
? `${this.getStatusSymbol('failed')} ${taskArg.job} ${taskArg.getElapsedSummaryText()}${errorLinesArg[0] ? ` - ${errorLinesArg[0]}` : ''}`
|
|
|
|
|
: `fail ${taskArg.job} in ${taskArg.getElapsedSummaryText()}${errorLinesArg[0] ? `: ${errorLinesArg[0]}` : ''}`;
|
|
|
|
|
const detailLines = errorLinesArg.slice(1).map((lineArg) => {
|
|
|
|
|
return this.interactive ? ` ${lineArg}` : `fail ${taskArg.job}: ${lineArg}`;
|
|
|
|
|
});
|
2026-05-13 18:33:06 +00:00
|
|
|
this.finalizeTask(taskArg, [summary, ...detailLines], 'failed');
|
2026-05-13 14:52:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public clear(): void {
|
|
|
|
|
if (this.interactive) {
|
|
|
|
|
this.clearRenderedBlock();
|
2026-05-13 18:33:06 +00:00
|
|
|
this.restoreCursor();
|
2026-05-13 14:52:18 +00:00
|
|
|
}
|
|
|
|
|
this.tasks = [];
|
2026-05-13 18:33:06 +00:00
|
|
|
this.nonInteractiveLogState.clear();
|
2026-05-13 20:18:52 +00:00
|
|
|
this.stopLiveRenderLoop();
|
2026-05-13 14:52:18 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-13 18:33:06 +00:00
|
|
|
/** @internal */
|
|
|
|
|
public getStatusSymbol(statusArg: TSmartcliTerminalTaskStatus): string {
|
|
|
|
|
const symbols = this.useUnicodeSymbols ? unicodeSymbols : asciiSymbols;
|
|
|
|
|
return symbols[statusArg];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @internal */
|
|
|
|
|
public colorizeLine(lineArg: string, statusArg?: TSmartcliTerminalTaskStatus): string {
|
|
|
|
|
if (!this.colors) {
|
|
|
|
|
return lineArg;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (statusArg === 'completed') {
|
|
|
|
|
return `${ansiCodes.green}${lineArg}${ansiCodes.reset}`;
|
|
|
|
|
}
|
|
|
|
|
if (statusArg === 'failed') {
|
|
|
|
|
return `${ansiCodes.red}${lineArg}${ansiCodes.reset}`;
|
|
|
|
|
}
|
|
|
|
|
if (statusArg === 'running') {
|
|
|
|
|
return `${ansiCodes.cyan}${lineArg}${ansiCodes.reset}`;
|
|
|
|
|
}
|
|
|
|
|
if (lineArg.startsWith(' ')) {
|
|
|
|
|
return `${ansiCodes.gray}${lineArg}${ansiCodes.reset}`;
|
|
|
|
|
}
|
|
|
|
|
return lineArg;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private ensureInteractiveSession(): void {
|
|
|
|
|
if (!this.cursorHidden) {
|
|
|
|
|
this.stream.write('\u001B[?25l');
|
|
|
|
|
this.cursorHidden = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.cleanupEnabled && !this.cleanupRegistered) {
|
|
|
|
|
this.registerProcessCleanup();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private finalizeTask(
|
|
|
|
|
taskArg: SmartcliTerminalTask,
|
|
|
|
|
linesArg: string[],
|
|
|
|
|
statusArg: TSmartcliTerminalTaskStatus
|
|
|
|
|
): void {
|
2026-05-13 14:52:18 +00:00
|
|
|
this.tasks = this.tasks.filter((task) => task !== taskArg);
|
2026-05-13 18:33:06 +00:00
|
|
|
this.nonInteractiveLogState.delete(taskArg);
|
2026-05-13 14:52:18 +00:00
|
|
|
|
|
|
|
|
if (this.interactive) {
|
|
|
|
|
this.clearRenderedBlock();
|
2026-05-13 18:33:06 +00:00
|
|
|
this.writePermanentLines(linesArg.map((lineArg) => this.colorizeLine(lineArg, statusArg)));
|
2026-05-13 20:18:52 +00:00
|
|
|
this.updateLiveRenderLoop();
|
2026-05-13 14:52:18 +00:00
|
|
|
this.render();
|
2026-05-13 18:33:06 +00:00
|
|
|
if (this.tasks.length === 0) {
|
|
|
|
|
this.restoreCursor();
|
|
|
|
|
}
|
2026-05-13 14:52:18 +00:00
|
|
|
} else {
|
|
|
|
|
this.writePermanentLines(linesArg);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private render(): void {
|
|
|
|
|
if (!this.interactive) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const width = this.getLineWidth();
|
2026-05-13 18:33:06 +00:00
|
|
|
const lines = this.tasks.flatMap((taskArg) => taskArg.renderPlainRows(width));
|
|
|
|
|
const coloredLines = lines.map((lineArg) => {
|
|
|
|
|
const status = lineArg.startsWith(' ') ? undefined : getStatusFromRenderedLine(lineArg, this);
|
|
|
|
|
return this.colorizeLine(lineArg, status);
|
2026-05-13 14:52:18 +00:00
|
|
|
});
|
2026-05-13 18:33:06 +00:00
|
|
|
const renderedOutput = coloredLines.join('\n');
|
|
|
|
|
|
|
|
|
|
if (renderedOutput === this.lastRenderedOutput) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-05-13 14:52:18 +00:00
|
|
|
|
|
|
|
|
if (this.renderedLineCount > 0) {
|
|
|
|
|
this.stream.write(`\u001B[${this.renderedLineCount}F`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 18:33:06 +00:00
|
|
|
const lineCount = Math.max(this.renderedLineCount, coloredLines.length);
|
2026-05-13 14:52:18 +00:00
|
|
|
for (let index = 0; index < lineCount; index++) {
|
|
|
|
|
this.stream.write('\u001B[2K\r');
|
2026-05-13 18:33:06 +00:00
|
|
|
if (index < coloredLines.length) {
|
|
|
|
|
this.stream.write(coloredLines[index]);
|
2026-05-13 14:52:18 +00:00
|
|
|
}
|
|
|
|
|
this.stream.write('\n');
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 18:33:06 +00:00
|
|
|
this.renderedLineCount = coloredLines.length;
|
|
|
|
|
this.lastRenderedOutput = renderedOutput;
|
2026-05-13 14:52:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private clearRenderedBlock(): void {
|
|
|
|
|
if (this.renderedLineCount === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.stream.write(`\u001B[${this.renderedLineCount}F`);
|
|
|
|
|
this.stream.write(`\u001B[${this.renderedLineCount}M`);
|
|
|
|
|
this.renderedLineCount = 0;
|
2026-05-13 18:33:06 +00:00
|
|
|
this.lastRenderedOutput = '';
|
2026-05-13 14:52:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private writePermanentLines(linesArg: string[]): void {
|
|
|
|
|
for (const line of linesArg) {
|
|
|
|
|
this.writePermanentLine(line);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private writePermanentLine(lineArg: string): void {
|
2026-05-13 18:33:06 +00:00
|
|
|
this.stream.write(`${lineArg}\n`);
|
2026-05-13 14:52:18 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-13 18:33:06 +00:00
|
|
|
private shouldWriteNonInteractiveUpdate(
|
|
|
|
|
taskArg: SmartcliTerminalTask,
|
|
|
|
|
messageArg: string
|
|
|
|
|
): boolean {
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
const previousState = this.nonInteractiveLogState.get(taskArg);
|
|
|
|
|
|
|
|
|
|
if (previousState?.message === messageArg) {
|
|
|
|
|
return false;
|
2026-05-13 14:52:18 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-13 18:33:06 +00:00
|
|
|
if (previousState && now - previousState.timestamp < this.nonInteractiveThrottleMs) {
|
|
|
|
|
return false;
|
2026-05-13 14:52:18 +00:00
|
|
|
}
|
2026-05-13 18:33:06 +00:00
|
|
|
|
|
|
|
|
this.nonInteractiveLogState.set(taskArg, {
|
|
|
|
|
message: messageArg,
|
|
|
|
|
timestamp: now,
|
|
|
|
|
});
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private restoreCursor(): void {
|
|
|
|
|
if (!this.cursorHidden) {
|
|
|
|
|
return;
|
2026-05-13 14:52:18 +00:00
|
|
|
}
|
2026-05-13 18:33:06 +00:00
|
|
|
this.stream.write('\u001B[?25h');
|
|
|
|
|
this.cursorHidden = false;
|
|
|
|
|
this.unregisterProcessCleanup();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private registerProcessCleanup(): void {
|
|
|
|
|
const processObject = getProcessObject();
|
|
|
|
|
if (!processObject?.once) {
|
|
|
|
|
return;
|
2026-05-13 14:52:18 +00:00
|
|
|
}
|
2026-05-13 18:33:06 +00:00
|
|
|
|
|
|
|
|
const restoreOnly = () => {
|
2026-05-13 20:18:52 +00:00
|
|
|
this.stopLiveRenderLoop();
|
2026-05-13 18:33:06 +00:00
|
|
|
this.clearRenderedBlock();
|
|
|
|
|
if (this.cursorHidden) {
|
|
|
|
|
this.stream.write('\u001B[?25h');
|
|
|
|
|
this.cursorHidden = false;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
const exitWithSignal = (codeArg: number) => {
|
|
|
|
|
restoreOnly();
|
|
|
|
|
processObject.exit?.(codeArg);
|
|
|
|
|
};
|
|
|
|
|
const throwAfterRestore = (errorArg: unknown) => {
|
|
|
|
|
restoreOnly();
|
|
|
|
|
throw errorArg;
|
|
|
|
|
};
|
|
|
|
|
const sigintHandler = () => exitWithSignal(130);
|
|
|
|
|
const sigtermHandler = () => exitWithSignal(143);
|
|
|
|
|
|
|
|
|
|
processObject.once('exit', restoreOnly);
|
|
|
|
|
processObject.once('SIGINT', sigintHandler);
|
|
|
|
|
processObject.once('SIGTERM', sigtermHandler);
|
|
|
|
|
processObject.once('uncaughtException', throwAfterRestore);
|
|
|
|
|
this.cleanupHandlers = [
|
|
|
|
|
() => processObject.off?.('exit', restoreOnly),
|
|
|
|
|
() => processObject.off?.('SIGINT', sigintHandler),
|
|
|
|
|
() => processObject.off?.('SIGTERM', sigtermHandler),
|
|
|
|
|
() => processObject.off?.('uncaughtException', throwAfterRestore),
|
|
|
|
|
];
|
|
|
|
|
this.cleanupRegistered = true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 20:18:52 +00:00
|
|
|
private updateLiveRenderLoop(): void {
|
|
|
|
|
if (!this.interactive) {
|
|
|
|
|
this.stopLiveRenderLoop();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const liveIntervals = this.tasks
|
|
|
|
|
.map((taskArg) => taskArg.getLiveRenderIntervalMs())
|
|
|
|
|
.filter((intervalArg): intervalArg is number => Boolean(intervalArg));
|
|
|
|
|
const nextInterval = liveIntervals.length > 0 ? Math.min(...liveIntervals) : 0;
|
|
|
|
|
|
|
|
|
|
if (nextInterval === this.liveRenderIntervalMs) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.stopLiveRenderLoop();
|
|
|
|
|
|
|
|
|
|
if (nextInterval > 0) {
|
|
|
|
|
this.liveRenderInterval = setInterval(() => this.render(), nextInterval);
|
|
|
|
|
(this.liveRenderInterval as any).unref?.();
|
|
|
|
|
this.liveRenderIntervalMs = nextInterval;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private stopLiveRenderLoop(): void {
|
|
|
|
|
if (this.liveRenderInterval) {
|
|
|
|
|
clearInterval(this.liveRenderInterval);
|
|
|
|
|
this.liveRenderInterval = null;
|
|
|
|
|
}
|
|
|
|
|
this.liveRenderIntervalMs = 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 18:33:06 +00:00
|
|
|
private unregisterProcessCleanup(): void {
|
|
|
|
|
for (const cleanupHandler of this.cleanupHandlers) {
|
|
|
|
|
cleanupHandler();
|
2026-05-13 14:52:18 +00:00
|
|
|
}
|
2026-05-13 18:33:06 +00:00
|
|
|
this.cleanupHandlers = [];
|
|
|
|
|
this.cleanupRegistered = false;
|
2026-05-13 14:52:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getLineWidth(): number {
|
|
|
|
|
return Math.max(20, (this.stream.columns || 80) - 1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class SmartcliTerminalTask {
|
|
|
|
|
public readonly job: string;
|
|
|
|
|
public readonly rows: number;
|
2026-05-13 18:33:06 +00:00
|
|
|
public readonly startTime = Date.now();
|
2026-05-13 14:52:18 +00:00
|
|
|
public status: TSmartcliTerminalTaskStatus = 'running';
|
|
|
|
|
private terminal: SmartcliTerminal;
|
|
|
|
|
private logLimit: number;
|
|
|
|
|
private logLines: string[] = [];
|
|
|
|
|
private errorLines: string[] = [];
|
2026-05-13 18:33:06 +00:00
|
|
|
private progressCurrent?: number;
|
|
|
|
|
private progressTotal?: number;
|
2026-05-13 20:18:52 +00:00
|
|
|
private showTimer: boolean;
|
|
|
|
|
private showSpinner: boolean;
|
|
|
|
|
private spinnerFrames: string[];
|
|
|
|
|
private spinnerIntervalMs: number;
|
2026-05-13 14:52:18 +00:00
|
|
|
|
|
|
|
|
constructor(terminalArg: SmartcliTerminal, optionsArg: ISmartcliTerminalTaskOptions) {
|
|
|
|
|
this.terminal = terminalArg;
|
|
|
|
|
this.job = optionsArg.job;
|
|
|
|
|
this.rows = Math.max(1, Math.floor(optionsArg.rows || 3));
|
|
|
|
|
this.logLimit = Math.max(this.rows, Math.floor(optionsArg.logLimit || 100));
|
2026-05-13 20:18:52 +00:00
|
|
|
this.showTimer = Boolean(optionsArg.showTimer ?? optionsArg.timer ?? false);
|
|
|
|
|
this.showSpinner = Boolean(optionsArg.showSpinner ?? optionsArg.spinner ?? false);
|
|
|
|
|
this.spinnerFrames = optionsArg.spinnerFrames?.length
|
|
|
|
|
? optionsArg.spinnerFrames
|
|
|
|
|
: this.terminal.getStatusSymbol('running') === '*'
|
|
|
|
|
? asciiSpinnerFrames
|
|
|
|
|
: unicodeSpinnerFrames;
|
|
|
|
|
this.spinnerIntervalMs = Math.max(20, Math.floor(optionsArg.spinnerIntervalMs || 80));
|
2026-05-13 14:52:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public log(messageArg: string): this {
|
|
|
|
|
if (this.status !== 'running') {
|
|
|
|
|
return this;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const newLines = normalizeLines(messageArg);
|
|
|
|
|
this.logLines.push(...newLines);
|
|
|
|
|
if (this.logLines.length > this.logLimit) {
|
|
|
|
|
this.logLines.splice(0, this.logLines.length - this.logLimit);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.terminal.updateTask(this, newLines.join('\n'));
|
|
|
|
|
return this;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public update(messageArg: string): this {
|
|
|
|
|
return this.log(messageArg);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 20:18:52 +00:00
|
|
|
public setTimerEnabled(enabledArg = true): this {
|
|
|
|
|
if (this.status !== 'running') {
|
|
|
|
|
return this;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.showTimer = enabledArg;
|
|
|
|
|
this.terminal.updateTask(this);
|
|
|
|
|
return this;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public setSpinnerEnabled(enabledArg = true): this {
|
|
|
|
|
if (this.status !== 'running') {
|
|
|
|
|
return this;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.showSpinner = enabledArg;
|
|
|
|
|
this.terminal.updateTask(this);
|
|
|
|
|
return this;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 18:33:06 +00:00
|
|
|
public setProgress(currentArg: number, totalArg: number, messageArg?: string): this {
|
|
|
|
|
if (this.status !== 'running') {
|
|
|
|
|
return this;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.progressCurrent = Math.max(0, currentArg);
|
|
|
|
|
this.progressTotal = Math.max(0, totalArg);
|
|
|
|
|
const progressText = this.getProgressText();
|
|
|
|
|
this.log(messageArg ? `${messageArg} ${progressText}` : progressText);
|
|
|
|
|
return this;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async run<T>(
|
|
|
|
|
operationArg: (taskArg: this) => T | Promise<T>,
|
|
|
|
|
optionsArg: ISmartcliTerminalTaskRunOptions = {}
|
|
|
|
|
): Promise<T> {
|
|
|
|
|
try {
|
|
|
|
|
const result = await operationArg(this);
|
|
|
|
|
this.complete(optionsArg.successMessage);
|
|
|
|
|
return result;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.attachError(error, { keepOpen: optionsArg.errorKeepOpen });
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 14:52:18 +00:00
|
|
|
public complete(messageArg?: string): this {
|
|
|
|
|
if (this.status !== 'running') {
|
|
|
|
|
return this;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.status = 'completed';
|
|
|
|
|
this.terminal.completeTask(this, messageArg);
|
|
|
|
|
return this;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public attachError(
|
|
|
|
|
errorArg: unknown,
|
|
|
|
|
optionsArg: ISmartcliTerminalAttachErrorOptions = {}
|
|
|
|
|
): this {
|
|
|
|
|
if (this.status !== 'running') {
|
|
|
|
|
return this;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.status = 'failed';
|
|
|
|
|
this.errorLines = formatError(errorArg);
|
|
|
|
|
|
|
|
|
|
if (optionsArg.keepOpen) {
|
|
|
|
|
this.terminal.updateTask(this, this.errorLines.join('\n'));
|
|
|
|
|
} else {
|
|
|
|
|
this.terminal.failTask(this, this.errorLines);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return this;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public fail(errorArg: unknown): this {
|
|
|
|
|
return this.attachError(errorArg);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 18:33:06 +00:00
|
|
|
public getElapsedText(): string {
|
2026-05-13 20:18:52 +00:00
|
|
|
return formatSmarttimeSeconds(Date.now() - this.startTime);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public getTimerText(): string {
|
|
|
|
|
return formatSmarttimeSeconds(Date.now() - this.startTime);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @internal */
|
|
|
|
|
public getElapsedSummaryText(): string {
|
|
|
|
|
return this.showTimer ? this.getTimerText() : this.getElapsedText();
|
2026-05-13 18:33:06 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-13 14:52:18 +00:00
|
|
|
public getLastLogLine(): string | undefined {
|
|
|
|
|
return this.logLines[this.logLines.length - 1];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public getLogLines(): string[] {
|
|
|
|
|
return [...this.logLines];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public getErrorLines(): string[] {
|
|
|
|
|
return [...this.errorLines];
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 20:18:52 +00:00
|
|
|
/** @internal */
|
|
|
|
|
public getLiveRenderIntervalMs(): number | undefined {
|
|
|
|
|
if (this.status !== 'running') {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.showSpinner) {
|
|
|
|
|
return this.spinnerIntervalMs;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.showTimer) {
|
|
|
|
|
return 1000;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 14:52:18 +00:00
|
|
|
/** @internal */
|
|
|
|
|
public renderPlainRows(widthArg: number): string[] {
|
|
|
|
|
const lines: string[] = [];
|
|
|
|
|
const detailLines = this.errorLines.length > 0 ? this.errorLines : this.logLines;
|
2026-05-13 18:33:06 +00:00
|
|
|
const header = this.getHeaderLine();
|
2026-05-13 14:52:18 +00:00
|
|
|
|
|
|
|
|
if (this.rows === 1) {
|
|
|
|
|
const lastDetail = detailLines[detailLines.length - 1];
|
2026-05-13 18:33:06 +00:00
|
|
|
lines.push(`${header}${lastDetail ? ` - ${lastDetail}` : ''}`);
|
|
|
|
|
return lines.map((lineArg) => truncateLine(lineArg, widthArg));
|
2026-05-13 14:52:18 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-13 18:33:06 +00:00
|
|
|
lines.push(header);
|
2026-05-13 14:52:18 +00:00
|
|
|
const visibleDetailLineCount = this.rows - 1;
|
|
|
|
|
const visibleDetailLines = detailLines.slice(-visibleDetailLineCount);
|
|
|
|
|
for (const line of visibleDetailLines) {
|
|
|
|
|
lines.push(` ${line}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
while (lines.length < this.rows) {
|
|
|
|
|
lines.push('');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return lines.map((lineArg) => truncateLine(lineArg, widthArg));
|
|
|
|
|
}
|
2026-05-13 18:33:06 +00:00
|
|
|
|
|
|
|
|
private getHeaderLine(): string {
|
|
|
|
|
const progressText = this.progressTotal ? ` ${this.getProgressText()}` : '';
|
2026-05-13 20:18:52 +00:00
|
|
|
const timerText = this.showTimer ? ` ${this.getTimerText()}` : '';
|
|
|
|
|
return `${this.getRunningIndicator()} ${this.job}${progressText}${timerText}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getRunningIndicator(): string {
|
|
|
|
|
if (this.status !== 'running' || !this.showSpinner) {
|
|
|
|
|
return this.terminal.getStatusSymbol(this.status);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const frameIndex = Math.floor((Date.now() - this.startTime) / this.spinnerIntervalMs) % this.spinnerFrames.length;
|
|
|
|
|
return this.spinnerFrames[frameIndex];
|
2026-05-13 18:33:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getProgressText(): string {
|
|
|
|
|
if (!this.progressTotal) {
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const percent = Math.floor((this.progressCurrent || 0) / this.progressTotal * 100);
|
|
|
|
|
return `${Math.min(100, percent)}% (${this.progressCurrent}/${this.progressTotal})`;
|
|
|
|
|
}
|
2026-05-13 14:52:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getDefaultStream(): ISmartcliWritable {
|
2026-05-13 18:33:06 +00:00
|
|
|
const processObject = getProcessObject();
|
|
|
|
|
if (processObject?.stdout?.write) {
|
|
|
|
|
return processObject.stdout;
|
2026-05-13 14:52:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
isTTY: false,
|
|
|
|
|
write: (chunkArg: string) => {
|
|
|
|
|
if (typeof console !== 'undefined') {
|
|
|
|
|
console.log(chunkArg.replace(/\n$/, ''));
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getInteractiveState(streamArg: ISmartcliWritable, overrideArg?: boolean): boolean {
|
|
|
|
|
if (typeof overrideArg === 'boolean') {
|
|
|
|
|
return overrideArg;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!streamArg.isTTY) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return !(
|
|
|
|
|
hasEnvFlag('CI') ||
|
|
|
|
|
hasEnvFlag('GITHUB_ACTIONS') ||
|
|
|
|
|
hasEnvFlag('JENKINS_URL') ||
|
|
|
|
|
hasEnvFlag('GITLAB_CI') ||
|
|
|
|
|
hasEnvFlag('TRAVIS') ||
|
|
|
|
|
hasEnvFlag('CIRCLECI') ||
|
|
|
|
|
getEnvValue('TERM') === 'dumb'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 18:33:06 +00:00
|
|
|
function getUnicodeSymbolState(
|
|
|
|
|
streamArg: ISmartcliWritable,
|
|
|
|
|
modeArg: TSmartcliTerminalSymbolMode = 'auto'
|
|
|
|
|
): boolean {
|
|
|
|
|
if (modeArg === 'unicode') {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if (modeArg === 'ascii') {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const processObject = getProcessObject();
|
|
|
|
|
if (processObject?.platform === 'win32' && !getEnvValue('WT_SESSION')) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return streamArg.isTTY !== false && getEnvValue('TERM') !== 'dumb';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getStatusFromRenderedLine(
|
|
|
|
|
lineArg: string,
|
|
|
|
|
terminalArg: SmartcliTerminal
|
|
|
|
|
): TSmartcliTerminalTaskStatus | undefined {
|
|
|
|
|
if (lineArg.startsWith(terminalArg.getStatusSymbol('completed'))) {
|
|
|
|
|
return 'completed';
|
|
|
|
|
}
|
|
|
|
|
if (lineArg.startsWith(terminalArg.getStatusSymbol('failed'))) {
|
|
|
|
|
return 'failed';
|
|
|
|
|
}
|
|
|
|
|
if (lineArg.startsWith(terminalArg.getStatusSymbol('running'))) {
|
|
|
|
|
return 'running';
|
|
|
|
|
}
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 14:52:18 +00:00
|
|
|
function hasEnvFlag(nameArg: string): boolean {
|
|
|
|
|
const value = getEnvValue(nameArg);
|
|
|
|
|
return Boolean(value && value !== '0' && value.toLowerCase() !== 'false');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getEnvValue(nameArg: string): string | undefined {
|
2026-05-13 18:33:06 +00:00
|
|
|
return getProcessObject()?.env?.[nameArg];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getProcessObject(): any {
|
2026-05-13 14:52:18 +00:00
|
|
|
const globalObject: any = globalThis as any;
|
2026-05-13 18:33:06 +00:00
|
|
|
return globalObject.process;
|
2026-05-13 14:52:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeLines(messageArg: string): string[] {
|
|
|
|
|
return String(messageArg)
|
|
|
|
|
.split(/\r?\n/)
|
|
|
|
|
.map((lineArg) => lineArg.trimEnd())
|
|
|
|
|
.filter((lineArg) => lineArg.length > 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatError(errorArg: unknown): string[] {
|
|
|
|
|
if (errorArg instanceof Error) {
|
|
|
|
|
return normalizeLines(errorArg.stack || errorArg.message || errorArg.name);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (typeof errorArg === 'string') {
|
|
|
|
|
return normalizeLines(errorArg);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const jsonString = JSON.stringify(errorArg);
|
|
|
|
|
return normalizeLines(jsonString === undefined ? String(errorArg) : jsonString);
|
|
|
|
|
} catch {
|
|
|
|
|
return [String(errorArg)];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 20:18:52 +00:00
|
|
|
function formatSmarttimeSeconds(millisecondsArg: number): string {
|
|
|
|
|
const seconds = Math.floor(millisecondsArg / 1000);
|
|
|
|
|
if (seconds === 0) {
|
|
|
|
|
return '0s';
|
2026-05-13 18:33:06 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-13 20:18:52 +00:00
|
|
|
return plugins.smarttime.getMilliSecondsAsHumanReadableString(
|
|
|
|
|
plugins.smarttime.units.seconds(seconds)
|
|
|
|
|
);
|
2026-05-13 18:33:06 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-13 14:52:18 +00:00
|
|
|
function truncateLine(lineArg: string, widthArg: number): string {
|
|
|
|
|
if (lineArg.length <= widthArg) {
|
|
|
|
|
return lineArg;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (widthArg <= 3) {
|
|
|
|
|
return lineArg.slice(0, widthArg);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return `${lineArg.slice(0, widthArg - 3)}...`;
|
|
|
|
|
}
|