Compare commits

..

4 Commits

Author SHA1 Message Date
jkunz b388f56e33 v4.3.0
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-05-13 20:18:55 +00:00
jkunz 1ae31e36bc feat(terminal): add optional live timers and spinners to terminal tasks 2026-05-13 20:18:52 +00:00
jkunz e2eb4eb040 v4.2.0
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-05-13 18:33:08 +00:00
jkunz 502cca375f feat(terminal): enhance terminal task rendering with progress, lifecycle helpers, and configurable output modes 2026-05-13 18:33:06 +00:00
11 changed files with 731 additions and 129 deletions
+20
View File
@@ -3,6 +3,26 @@
## Pending ## Pending
## 2026-05-13 - 4.3.0
### Features
- add optional live timers and spinners to terminal tasks (terminal)
- Adds task options and runtime toggles for live timers and animated spinners in interactive terminal rendering.
- Prefixes every line of multiline updates and failure details with the task name in non-interactive output for clearer logs.
- Uses @push.rocks/smarttime to format timer output as human-readable second-based durations.
## 2026-05-13 - 4.2.0
### Features
- enhance terminal task rendering with progress, lifecycle helpers, and configurable output modes (terminal)
- add task progress reporting and task.run() helpers for automatic completion and failure handling
- support configurable unicode or ascii symbols, cleanup behavior, and throttled non-interactive lifecycle logs
- export new terminal task run and symbol mode types and document the updated terminal API
## 2026-05-13 - 4.1.0 ## 2026-05-13 - 4.1.0
### Features ### Features
Generated
+2
View File
@@ -9,6 +9,7 @@
"npm:@push.rocks/smartobject@^1.0.12": "1.0.12", "npm:@push.rocks/smartobject@^1.0.12": "1.0.12",
"npm:@push.rocks/smartpromise@^4.2.4": "4.2.4", "npm:@push.rocks/smartpromise@^4.2.4": "4.2.4",
"npm:@push.rocks/smartrx@^3.0.10": "3.0.10", "npm:@push.rocks/smartrx@^3.0.10": "3.0.10",
"npm:@push.rocks/smarttime@^4.2.3": "4.2.3",
"npm:@types/node@^25.7.0": "25.7.0", "npm:@types/node@^25.7.0": "25.7.0",
"npm:@types/yargs-parser@^21.0.3": "21.0.3", "npm:@types/yargs-parser@^21.0.3": "21.0.3",
"npm:yargs-parser@22.0.0": "22.0.0" "npm:yargs-parser@22.0.0": "22.0.0"
@@ -6235,6 +6236,7 @@
"npm:@push.rocks/smartobject@^1.0.12", "npm:@push.rocks/smartobject@^1.0.12",
"npm:@push.rocks/smartpromise@^4.2.4", "npm:@push.rocks/smartpromise@^4.2.4",
"npm:@push.rocks/smartrx@^3.0.10", "npm:@push.rocks/smartrx@^3.0.10",
"npm:@push.rocks/smarttime@^4.2.3",
"npm:@types/node@^25.7.0", "npm:@types/node@^25.7.0",
"npm:@types/yargs-parser@^21.0.3", "npm:@types/yargs-parser@^21.0.3",
"npm:yargs-parser@22.0.0" "npm:yargs-parser@22.0.0"
+2 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@push.rocks/smartcli", "name": "@push.rocks/smartcli",
"private": false, "private": false,
"version": "4.1.0", "version": "4.3.0",
"description": "A library that simplifies building reactive command-line applications using observables, with robust support for commands, arguments, options, aliases, and asynchronous operation management.", "description": "A library that simplifies building reactive command-line applications using observables, with robust support for commands, arguments, options, aliases, and asynchronous operation management.",
"main": "dist_ts/index.js", "main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts", "typings": "dist_ts/index.d.ts",
@@ -41,6 +41,7 @@
"@push.rocks/smartobject": "^1.0.12", "@push.rocks/smartobject": "^1.0.12",
"@push.rocks/smartpromise": "^4.2.4", "@push.rocks/smartpromise": "^4.2.4",
"@push.rocks/smartrx": "^3.0.10", "@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smarttime": "^4.2.3",
"yargs-parser": "22.0.0" "yargs-parser": "22.0.0"
}, },
"devDependencies": { "devDependencies": {
+4 -1
View File
@@ -23,6 +23,9 @@ importers:
'@push.rocks/smartrx': '@push.rocks/smartrx':
specifier: ^3.0.10 specifier: ^3.0.10
version: 3.0.10 version: 3.0.10
'@push.rocks/smarttime':
specifier: ^4.2.3
version: 4.2.3
yargs-parser: yargs-parser:
specifier: 22.0.0 specifier: 22.0.0
version: 22.0.0 version: 22.0.0
@@ -5220,7 +5223,7 @@ snapshots:
'@push.rocks/smarttime@4.2.3': '@push.rocks/smarttime@4.2.3':
dependencies: dependencies:
'@push.rocks/lik': 6.4.1 '@push.rocks/lik': 6.4.1
'@push.rocks/smartdelay': 3.0.5 '@push.rocks/smartdelay': 3.1.0
'@push.rocks/smartpromise': 4.2.4 '@push.rocks/smartpromise': 4.2.4
croner: 10.0.1 croner: 10.0.1
date-fns: 4.1.0 date-fns: 4.1.0
+20 -5
View File
@@ -75,20 +75,21 @@ demo help
## Terminal Task Rendering ## Terminal Task Rendering
Use `SmartcliTerminal` for long-running jobs that should render cleanly in both interactive and non-interactive environments. In a TTY, active tasks render below each other with a fixed row count. In CI, pipes, Docker logs, or `TERM=dumb`, the same calls become append-only log lines. Use `SmartcliTerminal` for long-running jobs that should render cleanly in both interactive and non-interactive environments. In a TTY, active tasks render below each other with a fixed row count, colored status symbols, and optional live timers/spinners. In CI, pipes, Docker logs, or `TERM=dumb`, the same calls become throttled append-only lifecycle logs where every message line is prefixed with its task name.
```ts ```ts
import { SmartcliTerminal } from '@push.rocks/smartcli'; import { SmartcliTerminal } from '@push.rocks/smartcli';
const terminal = new SmartcliTerminal(); const terminal = new SmartcliTerminal();
const buildTask = terminal.createTask({ const buildTask = terminal.task('Build package', {
job: 'Build package',
rows: 3, rows: 3,
showTimer: true,
showSpinner: true,
}); });
buildTask.update('Installing dependencies'); buildTask.update('Installing dependencies');
buildTask.log('Running tsbuild'); buildTask.setProgress(1, 2, 'Running tsbuild');
buildTask.complete('Build finished'); buildTask.complete('Build finished');
const publishTask = terminal.createProcess({ const publishTask = terminal.createProcess({
@@ -104,7 +105,21 @@ try {
} }
``` ```
Completed tasks collapse into a permanent `[ok]` line. Failed tasks collapse into a permanent `[err]` line with error details. If an error should remain visible inside the live task area, use `attachError(error, { keepOpen: true })`. Completed tasks collapse into one permanent success line. Failed tasks collapse into one permanent failure line with error details. If an error should remain visible inside the live task area, use `attachError(error, { keepOpen: true })`.
`showTimer` renders a second-precision live counter using `@push.rocks/smarttime`, for example `4s` or `1m 30s`. `showSpinner` animates the running indicator in interactive terminals and is ignored for append-only output. The shorter aliases `timer` and `spinner` are also accepted.
For scoped work, `task.run()` completes or fails automatically:
```ts
await terminal.task('Generate assets').run(async (task) => {
task.setProgress(1, 3, 'Reading source files');
await readSourceFiles();
task.setProgress(2, 3, 'Rendering assets');
await renderAssets();
task.setProgress(3, 3, 'Writing output');
}, { successMessage: 'Assets generated' });
```
## Execution Model ## Execution Model
-79
View File
@@ -3,25 +3,6 @@ import * as smartrx from '@push.rocks/smartrx';
import * as smartcli from '../ts/index.js'; import * as smartcli from '../ts/index.js';
class TestWritable implements smartcli.ISmartcliWritable {
public chunks: string[] = [];
public isTTY: boolean;
public columns = 80;
constructor(isTTYArg: boolean) {
this.isTTY = isTTYArg;
}
public write(chunkArg: string): boolean {
this.chunks.push(chunkArg);
return true;
}
public toString(): string {
return this.chunks.join('');
}
}
tap.test('should create a new Smartcli', async () => { tap.test('should create a new Smartcli', async () => {
const smartCliTestObject = new smartcli.Smartcli(); const smartCliTestObject = new smartcli.Smartcli();
expect(smartCliTestObject).toBeInstanceOf(smartcli.Smartcli); expect(smartCliTestObject).toBeInstanceOf(smartcli.Smartcli);
@@ -55,64 +36,4 @@ tap.test('should accept a command', async () => {
expect(hasExecuted).toBeTrue(); expect(hasExecuted).toBeTrue();
}); });
tap.test('should render terminal tasks in non-interactive mode', async () => {
const stream = new TestWritable(false);
const terminal = new smartcli.SmartcliTerminal({ stream, interactive: false, colors: false });
const task = terminal.createTask({ job: 'build package', rows: 2 });
task.update('running tsbuild');
task.complete('done');
const output = stream.toString();
expect(output).toInclude('[start] build package');
expect(output).toInclude('[build package] running tsbuild');
expect(output).toInclude('[ok] build package - done');
expect(terminal.getTasks()).toHaveLength(0);
});
tap.test('should render fixed rows in interactive mode', async () => {
const stream = new TestWritable(true);
const terminal = new smartcli.SmartcliTerminal({ stream, interactive: true, colors: false });
const task = terminal.createTask({ job: 'install dependencies', rows: 2 });
task.update('fetching packages');
const renderedRows = task.renderPlainRows(80);
expect(renderedRows).toHaveLength(2);
expect(renderedRows[0]).toInclude('[run] install dependencies');
expect(stream.toString()).toInclude('\u001B[2K');
task.complete('installed');
expect(stream.toString()).toInclude('[ok] install dependencies - installed');
expect(terminal.getTasks()).toHaveLength(0);
});
tap.test('should attach persistent terminal task errors', async () => {
const stream = new TestWritable(true);
const terminal = new smartcli.SmartcliTerminal({ stream, interactive: true, colors: false });
const task = terminal.createTask({ job: 'deploy release', rows: 3 });
task.attachError('deployment failed', { keepOpen: true });
expect(task.status).toEqual('failed');
expect(task.getErrorLines()).toContain('deployment failed');
expect(task.renderPlainRows(80)[0]).toInclude('[err] deploy release');
expect(terminal.getTasks()).toHaveLength(1);
terminal.clear();
});
tap.test('should collapse failed terminal tasks into permanent output', async () => {
const stream = new TestWritable(false);
const terminal = new smartcli.SmartcliTerminal({ stream, interactive: false, colors: false });
const task = terminal.createTask({ job: 'publish package' });
task.attachError('registry rejected package');
const output = stream.toString();
expect(output).toInclude('[err] publish package - registry rejected package');
expect(terminal.getTasks()).toHaveLength(0);
});
export default tap.start(); export default tap.start();
+260
View File
@@ -0,0 +1,260 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as smartcli from '../ts/index.js';
const delay = async (millisecondsArg: number) => {
await new Promise((resolve) => setTimeout(resolve, millisecondsArg));
};
class TestWritable implements smartcli.ISmartcliWritable {
public chunks: string[] = [];
public isTTY: boolean;
public columns = 80;
constructor(isTTYArg: boolean) {
this.isTTY = isTTYArg;
}
public write(chunkArg: string): boolean {
this.chunks.push(chunkArg);
return true;
}
public toString(): string {
return this.chunks.join('');
}
}
tap.test('should render terminal tasks in non-interactive mode', async () => {
const stream = new TestWritable(false);
const terminal = new smartcli.SmartcliTerminal({
stream,
interactive: false,
colors: false,
symbols: 'ascii',
nonInteractiveThrottleMs: 0,
});
const task = terminal.createTask({ job: 'build package', rows: 2 });
task.update('running tsbuild');
task.complete('done');
const output = stream.toString();
expect(output).toInclude('start build package');
expect(output).toInclude('update build package: running tsbuild');
expect(output).toInclude('done build package in');
expect(output).toInclude(': done');
expect(terminal.getTasks()).toHaveLength(0);
});
tap.test('should render fixed rows in interactive mode', async () => {
const stream = new TestWritable(true);
const terminal = new smartcli.SmartcliTerminal({
stream,
interactive: true,
colors: false,
symbols: 'ascii',
cleanup: false,
});
const task = terminal.createTask({ job: 'install dependencies', rows: 2 });
task.update('fetching packages');
const renderedRows = task.renderPlainRows(80);
expect(renderedRows).toHaveLength(2);
expect(renderedRows[0]).toInclude('* install dependencies');
expect(stream.toString()).toInclude('\u001B[?25l');
expect(stream.toString()).toInclude('\u001B[2K');
task.complete('installed');
expect(stream.toString()).toInclude('OK install dependencies');
expect(stream.toString()).toInclude('installed');
expect(stream.toString()).toInclude('\u001B[?25h');
expect(terminal.getTasks()).toHaveLength(0);
});
tap.test('should attach persistent terminal task errors', async () => {
const stream = new TestWritable(true);
const terminal = new smartcli.SmartcliTerminal({
stream,
interactive: true,
colors: false,
symbols: 'ascii',
cleanup: false,
});
const task = terminal.createTask({ job: 'deploy release', rows: 3 });
task.attachError('deployment failed', { keepOpen: true });
expect(task.status).toEqual('failed');
expect(task.getErrorLines()).toContain('deployment failed');
expect(task.renderPlainRows(80)[0]).toInclude('X deploy release');
expect(terminal.getTasks()).toHaveLength(1);
terminal.clear();
});
tap.test('should collapse failed terminal tasks into permanent output', async () => {
const stream = new TestWritable(false);
const terminal = new smartcli.SmartcliTerminal({
stream,
interactive: false,
colors: false,
symbols: 'ascii',
});
const task = terminal.createTask({ job: 'publish package' });
task.attachError('registry rejected package');
const output = stream.toString();
expect(output).toInclude('fail publish package in');
expect(output).toInclude(': registry rejected package');
expect(terminal.getTasks()).toHaveLength(0);
});
tap.test('should prefix every non-interactive multiline message with the task name', async () => {
const stream = new TestWritable(false);
const terminal = new smartcli.SmartcliTerminal({
stream,
interactive: false,
colors: false,
nonInteractiveThrottleMs: 0,
});
const updateTask = terminal.task('multiline update');
updateTask.update('line one\nline two');
updateTask.complete('done');
const failedTask = terminal.task('multiline failure');
failedTask.fail('first failure line\nsecond failure line');
const output = stream.toString();
expect(output).toInclude('update multiline update: line one');
expect(output).toInclude('update multiline update: line two');
expect(output).toInclude('fail multiline failure in');
expect(output).toInclude('fail multiline failure: second failure line');
});
tap.test('should set task progress', async () => {
const stream = new TestWritable(true);
const terminal = new smartcli.SmartcliTerminal({
stream,
interactive: true,
colors: false,
symbols: 'ascii',
cleanup: false,
});
const task = terminal.task('process files', { rows: 2 });
task.setProgress(2, 5, 'processed files');
expect(task.getLastLogLine()).toInclude('40% (2/5)');
expect(task.renderPlainRows(80)[0]).toInclude('40% (2/5)');
terminal.clear();
});
tap.test('should render an optional second-precision timer', async () => {
const stream = new TestWritable(true);
const terminal = new smartcli.SmartcliTerminal({
stream,
interactive: true,
colors: false,
symbols: 'ascii',
cleanup: false,
});
const task = terminal.task('timed task', { rows: 2, showTimer: true });
expect(task.getTimerText()).toEqual('0s');
expect(task.renderPlainRows(80)[0]).toInclude('0s');
task.complete('timed complete');
expect(stream.toString()).toInclude('timed complete');
});
tap.test('should render an optional spinner', async () => {
const stream = new TestWritable(true);
const terminal = new smartcli.SmartcliTerminal({
stream,
interactive: true,
colors: false,
symbols: 'ascii',
cleanup: false,
});
const task = terminal.task('spinner task', {
rows: 2,
showSpinner: true,
spinnerFrames: ['a', 'b'],
spinnerIntervalMs: 20,
});
await delay(90);
const output = stream.toString();
expect(output).toInclude('spinner task');
expect(output.includes('a spinner task') || output.includes('b spinner task')).toBeTrue();
expect(task.getLiveRenderIntervalMs()).toEqual(20);
task.complete('spinner complete');
});
tap.test('should auto-complete task.run', async () => {
const stream = new TestWritable(false);
const terminal = new smartcli.SmartcliTerminal({
stream,
interactive: false,
colors: false,
nonInteractiveThrottleMs: 0,
});
const task = terminal.task('run operation');
const result = await task.run(async (taskArg) => {
taskArg.update('inside operation');
return 'result';
}, { successMessage: 'operation finished' });
expect(result).toEqual('result');
expect(task.status).toEqual('completed');
expect(stream.toString()).toInclude('done run operation in');
expect(stream.toString()).toInclude('operation finished');
});
tap.test('should auto-fail task.run', async () => {
const stream = new TestWritable(false);
const terminal = new smartcli.SmartcliTerminal({ stream, interactive: false, colors: false });
const task = terminal.task('failing operation');
let caughtError: Error | undefined;
try {
await task.run(async () => {
throw new Error('operation failed');
});
} catch (error) {
caughtError = error as Error;
}
expect(caughtError?.message).toEqual('operation failed');
expect(task.status).toEqual('failed');
expect(stream.toString()).toInclude('fail failing operation in');
expect(stream.toString()).toInclude('operation failed');
});
tap.test('should throttle duplicate non-interactive updates', async () => {
const stream = new TestWritable(false);
const terminal = new smartcli.SmartcliTerminal({
stream,
interactive: false,
colors: false,
nonInteractiveThrottleMs: 10000,
});
const task = terminal.task('quiet task');
task.update('same update');
task.update('same update');
task.update('different but throttled');
const output = stream.toString();
expect(output).toInclude('update quiet task: same update');
expect(output).not.toInclude('different but throttled');
});
export default tap.start();
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartcli', name: '@push.rocks/smartcli',
version: '4.1.0', version: '4.3.0',
description: 'A library that simplifies building reactive command-line applications using observables, with robust support for commands, arguments, options, aliases, and asynchronous operation management.' description: 'A library that simplifies building reactive command-line applications using observables, with robust support for commands, arguments, options, aliases, and asynchronous operation management.'
} }
+2
View File
@@ -3,7 +3,9 @@ export { SmartcliTerminal, SmartcliTerminalTask } from './smartcli.classes.termi
export type { export type {
ISmartcliTerminalAttachErrorOptions, ISmartcliTerminalAttachErrorOptions,
ISmartcliTerminalOptions, ISmartcliTerminalOptions,
ISmartcliTerminalTaskRunOptions,
ISmartcliTerminalTaskOptions, ISmartcliTerminalTaskOptions,
ISmartcliWritable, ISmartcliWritable,
TSmartcliTerminalSymbolMode,
TSmartcliTerminalTaskStatus, TSmartcliTerminalTaskStatus,
} from './smartcli.classes.terminal.js'; } from './smartcli.classes.terminal.js';
+418 -41
View File
@@ -1,4 +1,7 @@
import * as plugins from './smartcli.plugins.js';
export type TSmartcliTerminalTaskStatus = 'running' | 'completed' | 'failed'; export type TSmartcliTerminalTaskStatus = 'running' | 'completed' | 'failed';
export type TSmartcliTerminalSymbolMode = 'auto' | 'unicode' | 'ascii';
export interface ISmartcliWritable { export interface ISmartcliWritable {
isTTY?: boolean; isTTY?: boolean;
@@ -10,18 +13,37 @@ export interface ISmartcliTerminalOptions {
stream?: ISmartcliWritable; stream?: ISmartcliWritable;
interactive?: boolean; interactive?: boolean;
colors?: boolean; colors?: boolean;
symbols?: TSmartcliTerminalSymbolMode;
cleanup?: boolean;
nonInteractiveThrottleMs?: number;
} }
export interface ISmartcliTerminalTaskOptions { export interface ISmartcliTerminalTaskOptions {
job: string; job: string;
rows?: number; rows?: number;
logLimit?: number; logLimit?: number;
showTimer?: boolean;
showSpinner?: boolean;
timer?: boolean;
spinner?: boolean;
spinnerFrames?: string[];
spinnerIntervalMs?: number;
} }
export interface ISmartcliTerminalAttachErrorOptions { export interface ISmartcliTerminalAttachErrorOptions {
keepOpen?: boolean; keepOpen?: boolean;
} }
export interface ISmartcliTerminalTaskRunOptions {
successMessage?: string;
errorKeepOpen?: boolean;
}
interface INonInteractiveLogState {
message: string;
timestamp: number;
}
const ansiCodes = { const ansiCodes = {
reset: '\u001B[0m', reset: '\u001B[0m',
red: '\u001B[31m', red: '\u001B[31m',
@@ -30,6 +52,21 @@ const ansiCodes = {
gray: '\u001B[90m', gray: '\u001B[90m',
}; };
const unicodeSymbols = {
running: '●',
completed: '✓',
failed: '✕',
};
const asciiSymbols = {
running: '*',
completed: 'OK',
failed: 'X',
};
const unicodeSpinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const asciiSpinnerFrames = ['-', '\\', '|', '/'];
/** /**
* A live terminal renderer for multiple fixed-row tasks. * A live terminal renderer for multiple fixed-row tasks.
* It automatically falls back to append-only logs in non-interactive environments. * It automatically falls back to append-only logs in non-interactive environments.
@@ -38,13 +75,26 @@ export class SmartcliTerminal {
private stream: ISmartcliWritable; private stream: ISmartcliWritable;
private interactive: boolean; private interactive: boolean;
private colors: boolean; private colors: boolean;
private useUnicodeSymbols: boolean;
private cleanupEnabled: boolean;
private nonInteractiveThrottleMs: number;
private tasks: SmartcliTerminalTask[] = []; private tasks: SmartcliTerminalTask[] = [];
private renderedLineCount = 0; private renderedLineCount = 0;
private lastRenderedOutput = '';
private cursorHidden = false;
private cleanupRegistered = false;
private liveRenderInterval: ReturnType<typeof setInterval> | null = null;
private liveRenderIntervalMs = 0;
private nonInteractiveLogState = new Map<SmartcliTerminalTask, INonInteractiveLogState>();
private cleanupHandlers: Array<() => void> = [];
constructor(optionsArg: ISmartcliTerminalOptions = {}) { constructor(optionsArg: ISmartcliTerminalOptions = {}) {
this.stream = optionsArg.stream || getDefaultStream(); this.stream = optionsArg.stream || getDefaultStream();
this.interactive = getInteractiveState(this.stream, optionsArg.interactive); this.interactive = getInteractiveState(this.stream, optionsArg.interactive);
this.colors = optionsArg.colors ?? (this.interactive && !hasEnvFlag('NO_COLOR')); this.colors = optionsArg.colors ?? (this.interactive && !hasEnvFlag('NO_COLOR'));
this.useUnicodeSymbols = getUnicodeSymbolState(this.stream, optionsArg.symbols);
this.cleanupEnabled = optionsArg.cleanup ?? true;
this.nonInteractiveThrottleMs = Math.max(0, optionsArg.nonInteractiveThrottleMs ?? 250);
} }
public createTask(optionsArg: ISmartcliTerminalTaskOptions): SmartcliTerminalTask { public createTask(optionsArg: ISmartcliTerminalTaskOptions): SmartcliTerminalTask {
@@ -52,9 +102,11 @@ export class SmartcliTerminal {
this.tasks.push(task); this.tasks.push(task);
if (this.interactive) { if (this.interactive) {
this.ensureInteractiveSession();
this.render(); this.render();
this.updateLiveRenderLoop();
} else { } else {
this.writePermanentLine(`[start] ${task.job}`); this.writePermanentLine(`start ${task.job}`);
} }
return task; return task;
@@ -64,6 +116,13 @@ export class SmartcliTerminal {
return this.createTask(optionsArg); return this.createTask(optionsArg);
} }
public task(jobArg: string, optionsArg: Omit<ISmartcliTerminalTaskOptions, 'job'> = {}) {
return this.createTask({
...optionsArg,
job: jobArg,
});
}
public isInteractive(): boolean { public isInteractive(): boolean {
return this.interactive; return this.interactive;
} }
@@ -79,40 +138,99 @@ export class SmartcliTerminal {
} }
if (this.interactive) { if (this.interactive) {
this.updateLiveRenderLoop();
this.render(); this.render();
} else if (messageArg) { } else if (messageArg && this.shouldWriteNonInteractiveUpdate(taskArg, messageArg)) {
this.writePermanentLine(`[${taskArg.job}] ${messageArg}`); for (const messageLine of normalizeLines(messageArg)) {
this.writePermanentLine(`update ${taskArg.job}: ${messageLine}`);
}
} }
} }
/** @internal */ /** @internal */
public completeTask(taskArg: SmartcliTerminalTask, messageArg?: string): void { public completeTask(taskArg: SmartcliTerminalTask, messageArg?: string): void {
const message = messageArg || taskArg.getLastLogLine(); const message = messageArg || taskArg.getLastLogLine();
const summary = `[ok] ${taskArg.job}${message ? ` - ${message}` : ''}`; const summary = this.interactive
this.finalizeTask(taskArg, [summary]); ? `${this.getStatusSymbol('completed')} ${taskArg.job} ${taskArg.getElapsedSummaryText()}${message ? ` - ${message}` : ''}`
: `done ${taskArg.job} in ${taskArg.getElapsedSummaryText()}${message ? `: ${message}` : ''}`;
this.finalizeTask(taskArg, [summary], 'completed');
} }
/** @internal */ /** @internal */
public failTask(taskArg: SmartcliTerminalTask, errorLinesArg: string[]): void { public failTask(taskArg: SmartcliTerminalTask, errorLinesArg: string[]): void {
const summary = `[err] ${taskArg.job}${errorLinesArg[0] ? ` - ${errorLinesArg[0]}` : ''}`; const summary = this.interactive
const detailLines = errorLinesArg.slice(1).map((lineArg) => ` ${lineArg}`); ? `${this.getStatusSymbol('failed')} ${taskArg.job} ${taskArg.getElapsedSummaryText()}${errorLinesArg[0] ? ` - ${errorLinesArg[0]}` : ''}`
this.finalizeTask(taskArg, [summary, ...detailLines]); : `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}`;
});
this.finalizeTask(taskArg, [summary, ...detailLines], 'failed');
} }
public clear(): void { public clear(): void {
if (this.interactive) { if (this.interactive) {
this.clearRenderedBlock(); this.clearRenderedBlock();
this.restoreCursor();
} }
this.tasks = []; this.tasks = [];
this.nonInteractiveLogState.clear();
this.stopLiveRenderLoop();
} }
private finalizeTask(taskArg: SmartcliTerminalTask, linesArg: string[]): void { /** @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 {
this.tasks = this.tasks.filter((task) => task !== taskArg); this.tasks = this.tasks.filter((task) => task !== taskArg);
this.nonInteractiveLogState.delete(taskArg);
if (this.interactive) { if (this.interactive) {
this.clearRenderedBlock(); this.clearRenderedBlock();
this.writePermanentLines(linesArg); this.writePermanentLines(linesArg.map((lineArg) => this.colorizeLine(lineArg, statusArg)));
this.updateLiveRenderLoop();
this.render(); this.render();
if (this.tasks.length === 0) {
this.restoreCursor();
}
} else { } else {
this.writePermanentLines(linesArg); this.writePermanentLines(linesArg);
} }
@@ -124,27 +242,32 @@ export class SmartcliTerminal {
} }
const width = this.getLineWidth(); const width = this.getLineWidth();
const lines = this.tasks.flatMap((taskArg) => { const lines = this.tasks.flatMap((taskArg) => taskArg.renderPlainRows(width));
return taskArg.renderPlainRows(width).map((lineArg, indexArg) => { const coloredLines = lines.map((lineArg) => {
const truncatedLine = truncateLine(lineArg, width); const status = lineArg.startsWith(' ') ? undefined : getStatusFromRenderedLine(lineArg, this);
return indexArg === 0 ? this.colorizeStatusLabel(truncatedLine) : truncatedLine; return this.colorizeLine(lineArg, status);
});
}); });
const renderedOutput = coloredLines.join('\n');
if (renderedOutput === this.lastRenderedOutput) {
return;
}
if (this.renderedLineCount > 0) { if (this.renderedLineCount > 0) {
this.stream.write(`\u001B[${this.renderedLineCount}F`); this.stream.write(`\u001B[${this.renderedLineCount}F`);
} }
const lineCount = Math.max(this.renderedLineCount, lines.length); const lineCount = Math.max(this.renderedLineCount, coloredLines.length);
for (let index = 0; index < lineCount; index++) { for (let index = 0; index < lineCount; index++) {
this.stream.write('\u001B[2K\r'); this.stream.write('\u001B[2K\r');
if (index < lines.length) { if (index < coloredLines.length) {
this.stream.write(lines[index]); this.stream.write(coloredLines[index]);
} }
this.stream.write('\n'); this.stream.write('\n');
} }
this.renderedLineCount = lines.length; this.renderedLineCount = coloredLines.length;
this.lastRenderedOutput = renderedOutput;
} }
private clearRenderedBlock(): void { private clearRenderedBlock(): void {
@@ -155,6 +278,7 @@ export class SmartcliTerminal {
this.stream.write(`\u001B[${this.renderedLineCount}F`); this.stream.write(`\u001B[${this.renderedLineCount}F`);
this.stream.write(`\u001B[${this.renderedLineCount}M`); this.stream.write(`\u001B[${this.renderedLineCount}M`);
this.renderedLineCount = 0; this.renderedLineCount = 0;
this.lastRenderedOutput = '';
} }
private writePermanentLines(linesArg: string[]): void { private writePermanentLines(linesArg: string[]): void {
@@ -164,28 +288,116 @@ export class SmartcliTerminal {
} }
private writePermanentLine(lineArg: string): void { private writePermanentLine(lineArg: string): void {
const line = this.colors ? this.colorizeStatusLabel(lineArg) : lineArg; this.stream.write(`${lineArg}\n`);
this.stream.write(`${line}\n`);
} }
private colorizeStatusLabel(lineArg: string): string { private shouldWriteNonInteractiveUpdate(
if (!this.colors) { taskArg: SmartcliTerminalTask,
return lineArg; messageArg: string
): boolean {
const now = Date.now();
const previousState = this.nonInteractiveLogState.get(taskArg);
if (previousState?.message === messageArg) {
return false;
} }
if (lineArg.startsWith('[ok]')) { if (previousState && now - previousState.timestamp < this.nonInteractiveThrottleMs) {
return `${ansiCodes.green}[ok]${ansiCodes.reset}${lineArg.slice(4)}`; return false;
} }
if (lineArg.startsWith('[err]')) {
return `${ansiCodes.red}[err]${ansiCodes.reset}${lineArg.slice(5)}`; this.nonInteractiveLogState.set(taskArg, {
message: messageArg,
timestamp: now,
});
return true;
} }
if (lineArg.startsWith('[run]')) {
return `${ansiCodes.cyan}[run]${ansiCodes.reset}${lineArg.slice(5)}`; private restoreCursor(): void {
if (!this.cursorHidden) {
return;
} }
if (lineArg.startsWith('[start]')) { this.stream.write('\u001B[?25h');
return `${ansiCodes.gray}[start]${ansiCodes.reset}${lineArg.slice(7)}`; this.cursorHidden = false;
this.unregisterProcessCleanup();
} }
return lineArg;
private registerProcessCleanup(): void {
const processObject = getProcessObject();
if (!processObject?.once) {
return;
}
const restoreOnly = () => {
this.stopLiveRenderLoop();
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;
}
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;
}
private unregisterProcessCleanup(): void {
for (const cleanupHandler of this.cleanupHandlers) {
cleanupHandler();
}
this.cleanupHandlers = [];
this.cleanupRegistered = false;
} }
private getLineWidth(): number { private getLineWidth(): number {
@@ -196,17 +408,32 @@ export class SmartcliTerminal {
export class SmartcliTerminalTask { export class SmartcliTerminalTask {
public readonly job: string; public readonly job: string;
public readonly rows: number; public readonly rows: number;
public readonly startTime = Date.now();
public status: TSmartcliTerminalTaskStatus = 'running'; public status: TSmartcliTerminalTaskStatus = 'running';
private terminal: SmartcliTerminal; private terminal: SmartcliTerminal;
private logLimit: number; private logLimit: number;
private logLines: string[] = []; private logLines: string[] = [];
private errorLines: string[] = []; private errorLines: string[] = [];
private progressCurrent?: number;
private progressTotal?: number;
private showTimer: boolean;
private showSpinner: boolean;
private spinnerFrames: string[];
private spinnerIntervalMs: number;
constructor(terminalArg: SmartcliTerminal, optionsArg: ISmartcliTerminalTaskOptions) { constructor(terminalArg: SmartcliTerminal, optionsArg: ISmartcliTerminalTaskOptions) {
this.terminal = terminalArg; this.terminal = terminalArg;
this.job = optionsArg.job; this.job = optionsArg.job;
this.rows = Math.max(1, Math.floor(optionsArg.rows || 3)); this.rows = Math.max(1, Math.floor(optionsArg.rows || 3));
this.logLimit = Math.max(this.rows, Math.floor(optionsArg.logLimit || 100)); this.logLimit = Math.max(this.rows, Math.floor(optionsArg.logLimit || 100));
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));
} }
public log(messageArg: string): this { public log(messageArg: string): this {
@@ -228,6 +455,52 @@ export class SmartcliTerminalTask {
return this.log(messageArg); return this.log(messageArg);
} }
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;
}
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;
}
}
public complete(messageArg?: string): this { public complete(messageArg?: string): this {
if (this.status !== 'running') { if (this.status !== 'running') {
return this; return this;
@@ -262,6 +535,19 @@ export class SmartcliTerminalTask {
return this.attachError(errorArg); return this.attachError(errorArg);
} }
public getElapsedText(): string {
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();
}
public getLastLogLine(): string | undefined { public getLastLogLine(): string | undefined {
return this.logLines[this.logLines.length - 1]; return this.logLines[this.logLines.length - 1];
} }
@@ -274,19 +560,36 @@ export class SmartcliTerminalTask {
return [...this.errorLines]; return [...this.errorLines];
} }
/** @internal */
public getLiveRenderIntervalMs(): number | undefined {
if (this.status !== 'running') {
return undefined;
}
if (this.showSpinner) {
return this.spinnerIntervalMs;
}
if (this.showTimer) {
return 1000;
}
return undefined;
}
/** @internal */ /** @internal */
public renderPlainRows(widthArg: number): string[] { public renderPlainRows(widthArg: number): string[] {
const statusLabel = this.status === 'failed' ? '[err]' : '[run]';
const lines: string[] = []; const lines: string[] = [];
const detailLines = this.errorLines.length > 0 ? this.errorLines : this.logLines; const detailLines = this.errorLines.length > 0 ? this.errorLines : this.logLines;
const header = this.getHeaderLine();
if (this.rows === 1) { if (this.rows === 1) {
const lastDetail = detailLines[detailLines.length - 1]; const lastDetail = detailLines[detailLines.length - 1];
lines.push(`${statusLabel} ${this.job}${lastDetail ? ` - ${lastDetail}` : ''}`); lines.push(`${header}${lastDetail ? ` - ${lastDetail}` : ''}`);
return lines; return lines.map((lineArg) => truncateLine(lineArg, widthArg));
} }
lines.push(`${statusLabel} ${this.job}`); lines.push(header);
const visibleDetailLineCount = this.rows - 1; const visibleDetailLineCount = this.rows - 1;
const visibleDetailLines = detailLines.slice(-visibleDetailLineCount); const visibleDetailLines = detailLines.slice(-visibleDetailLineCount);
for (const line of visibleDetailLines) { for (const line of visibleDetailLines) {
@@ -299,12 +602,36 @@ export class SmartcliTerminalTask {
return lines.map((lineArg) => truncateLine(lineArg, widthArg)); return lines.map((lineArg) => truncateLine(lineArg, widthArg));
} }
private getHeaderLine(): string {
const progressText = this.progressTotal ? ` ${this.getProgressText()}` : '';
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];
}
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})`;
}
} }
function getDefaultStream(): ISmartcliWritable { function getDefaultStream(): ISmartcliWritable {
const globalObject: any = globalThis as any; const processObject = getProcessObject();
if (globalObject.process?.stdout?.write) { if (processObject?.stdout?.write) {
return globalObject.process.stdout; return processObject.stdout;
} }
return { return {
@@ -337,14 +664,53 @@ function getInteractiveState(streamArg: ISmartcliWritable, overrideArg?: boolean
); );
} }
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;
}
function hasEnvFlag(nameArg: string): boolean { function hasEnvFlag(nameArg: string): boolean {
const value = getEnvValue(nameArg); const value = getEnvValue(nameArg);
return Boolean(value && value !== '0' && value.toLowerCase() !== 'false'); return Boolean(value && value !== '0' && value.toLowerCase() !== 'false');
} }
function getEnvValue(nameArg: string): string | undefined { function getEnvValue(nameArg: string): string | undefined {
return getProcessObject()?.env?.[nameArg];
}
function getProcessObject(): any {
const globalObject: any = globalThis as any; const globalObject: any = globalThis as any;
return globalObject.process?.env?.[nameArg]; return globalObject.process;
} }
function normalizeLines(messageArg: string): string[] { function normalizeLines(messageArg: string): string[] {
@@ -371,6 +737,17 @@ function formatError(errorArg: unknown): string[] {
} }
} }
function formatSmarttimeSeconds(millisecondsArg: number): string {
const seconds = Math.floor(millisecondsArg / 1000);
if (seconds === 0) {
return '0s';
}
return plugins.smarttime.getMilliSecondsAsHumanReadableString(
plugins.smarttime.units.seconds(seconds)
);
}
function truncateLine(lineArg: string, widthArg: number): string { function truncateLine(lineArg: string, widthArg: number): string {
if (lineArg.length <= widthArg) { if (lineArg.length <= widthArg) {
return lineArg; return lineArg;
+2 -1
View File
@@ -5,8 +5,9 @@ import * as path from 'node:path';
import * as smartparam from '@push.rocks/smartobject'; import * as smartparam from '@push.rocks/smartobject';
import * as smartpromise from '@push.rocks/smartpromise'; import * as smartpromise from '@push.rocks/smartpromise';
import * as smartrx from '@push.rocks/smartrx'; import * as smartrx from '@push.rocks/smartrx';
import * as smarttime from '@push.rocks/smarttime';
export { smartlog, lik, path, smartparam, smartpromise, smartrx }; export { smartlog, lik, path, smartparam, smartpromise, smartrx, smarttime };
// thirdparty scope // thirdparty scope
import yargsParser from 'yargs-parser'; import yargsParser from 'yargs-parser';