Compare commits

...

9 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
jkunz c07b2969b8 v4.1.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 14:52:58 +00:00
jkunz a5ec2717c5 feat(terminal): add live terminal task rendering with interactive and non-interactive output modes 2026-05-13 14:52:18 +00:00
jkunz 8852bd5c86 v4.0.21
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-04-30 14:29:50 +00:00
jkunz 6279f2cbad fix(smartcli): tighten command parsing and error handling while updating build and package configuration 2026-04-30 14:29:50 +00:00
jkunz e3f5616320 fix(core): Remove flawed safety check in getUserArgs and debug log
- Fixed bug where CLI with no args would return entire argv including node path
- Removed debug 'Wanted command: ...' log from startParse()
2026-01-12 01:22:25 +00:00
16 changed files with 7277 additions and 8617 deletions
+21 -23
View File
@@ -1,9 +1,5 @@
{
"npmci": {
"npmGlobalTools": [],
"npmAccesslevel": "public"
},
"gitzone": {
"@git.zone/cli": {
"projectType": "npm",
"module": {
"githost": "code.foss.global",
@@ -12,23 +8,25 @@
"description": "A library that simplifies building reactive command-line applications using observables, with robust support for commands, arguments, options, aliases, and asynchronous operation management.",
"npmPackagename": "@push.rocks/smartcli",
"license": "MIT",
"keywords": [
"CLI",
"command line",
"observable",
"reactive",
"asynchronous",
"commands",
"arguments",
"options",
"alias",
"typescript",
"node.js",
"development tool"
]
}
"projectDomain": "push.rocks"
},
"release": {
"targets": {
"npm": {
"registries": [
"https://verdaccio.lossless.digital",
"https://registry.npmjs.org"
],
"accessLevel": "public"
}
}
},
"schemaVersion": 2
},
"tsdoc": {
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
"@git.zone/tsdoc": {
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [LICENSE](LICENSE) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
},
"@ship.zone/szci": {
"npmGlobalTools": []
}
}
}
-26
View File
@@ -1,26 +0,0 @@
{
"json.schemas": [
{
"fileMatch": ["/npmextra.json"],
"schema": {
"type": "object",
"properties": {
"npmci": {
"type": "object",
"description": "settings for npmci"
},
"gitzone": {
"type": "object",
"description": "settings for gitzone",
"properties": {
"projectType": {
"type": "string",
"enum": ["website", "element", "service", "npm", "wcc"]
}
}
}
}
}
}
]
}
+42 -1
View File
@@ -1,5 +1,46 @@
# Changelog
## 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
### Features
- add live terminal task rendering with interactive and non-interactive output modes (terminal)
- introduces SmartcliTerminal and SmartcliTerminalTask exports for fixed-row task rendering
- supports task updates, completion, failure handling, and persistent error output
- detects interactive terminals and falls back to append-only logs in CI or non-TTY environments
- adds tests covering interactive rendering, non-interactive logging, and error attachment behavior
## 2026-04-30 - 4.0.21 - fix(smartcli)
tighten command parsing and error handling while updating build and package configuration
- throw an explicit error when triggering an unregistered command instead of failing on an undefined subject
- make the cli version property optional to align with current typing expectations
- update tests to use explicit argv input and export the tap startup call for runtime compatibility
- enable stricter TypeScript configuration and refresh build, dependency, and package metadata files
## 2025-10-28 - 4.0.19 - fix(license)
Update license files and add local tool settings
@@ -400,4 +441,4 @@ Initial commit
---
## Summary of Omitted Versions
The following versions contained no additional userfacing changes beyond version bumps and are summarized here: 1.0.12, 0.0.6, and 0.0.3.
The following versions contained no additional userfacing changes beyond version bumps and are summarized here: 1.0.12, 0.0.6, and 0.0.3.
Generated
+2403 -3390
View File
File diff suppressed because it is too large Load Diff
+15 -12
View File
@@ -1,14 +1,14 @@
{
"name": "@push.rocks/smartcli",
"private": false,
"version": "4.0.19",
"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.",
"main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts",
"type": "module",
"scripts": {
"test": "(tstest test/ --verbose)",
"build": "(tsbuild --web --allowimplicitany)",
"build": "tsbuild --web",
"buildDocs": "tsdoc"
},
"repository": {
@@ -32,22 +32,24 @@
"author": "Lossless GmbH <office@lossless.com> (https://lossless.com)",
"license": "MIT",
"bugs": {
"url": "https://gitlab.com/pushrocks/smartcli/issues"
"url": "https://code.foss.global/push.rocks/smartcli/issues"
},
"homepage": "https://code.foss.global/push.rocks/smartcli",
"dependencies": {
"@push.rocks/lik": "^6.2.2",
"@push.rocks/smartlog": "^3.1.10",
"@push.rocks/lik": "^6.4.1",
"@push.rocks/smartlog": "^3.2.2",
"@push.rocks/smartobject": "^1.0.12",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartpromise": "^4.2.4",
"@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smarttime": "^4.2.3",
"yargs-parser": "22.0.0"
},
"devDependencies": {
"@git.zone/tsbuild": "^2.6.8",
"@git.zone/tsrun": "^1.6.2",
"@git.zone/tstest": "^2.7.0",
"@types/node": "^24.9.1"
"@git.zone/tsbuild": "^4.4.1",
"@git.zone/tsrun": "^2.0.4",
"@git.zone/tstest": "^3.6.6",
"@types/node": "^25.7.0",
"@types/yargs-parser": "^21.0.3"
},
"files": [
"ts/**/*",
@@ -58,11 +60,12 @@
"dist_ts_web/**/*",
"assets/**/*",
"cli.js",
"npmextra.json",
".smartconfig.json",
"LICENSE",
"readme.md"
],
"browserslist": [
"last 1 chrome versions"
],
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34"
"packageManager": "pnpm@10.28.2"
}
+3401 -4339
View File
File diff suppressed because it is too large Load Diff
+350 -806
View File
File diff suppressed because it is too large Load Diff
+2 -6
View File
@@ -16,11 +16,7 @@ tap.test('should add an command', async (toolsArg) => {
awesomeCommandSubject.subscribe(() => {
done.resolve();
});
console.log(process.argv);
process.argv.splice(2, 0, 'awesome');
console.log(process.argv);
// Pass process.argv explicitly for testing (bypasses Deno.args in Deno environments)
smartCliTestObject.startParse(process.argv);
smartCliTestObject.startParse(['node', 'test.js', 'awesome']);
await done.promise;
});
@@ -40,4 +36,4 @@ tap.test('should accept a command', async () => {
expect(hasExecuted).toBeTrue();
});
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 = {
name: '@push.rocks/smartcli',
version: '4.0.19',
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.'
}
+10
View File
@@ -1 +1,11 @@
export { Smartcli } from './smartcli.classes.smartcli.js';
export { SmartcliTerminal, SmartcliTerminalTask } from './smartcli.classes.terminal.js';
export type {
ISmartcliTerminalAttachErrorOptions,
ISmartcliTerminalOptions,
ISmartcliTerminalTaskRunOptions,
ISmartcliTerminalTaskOptions,
ISmartcliWritable,
TSmartcliTerminalSymbolMode,
TSmartcliTerminalTaskStatus,
} from './smartcli.classes.terminal.js';
+4 -2
View File
@@ -18,7 +18,7 @@ export class Smartcli {
*/
public parseCompleted = plugins.smartpromise.defer<any>();
public version: string;
public version?: string;
/**
* map of all Trigger/Observable objects to keep track
@@ -65,6 +65,9 @@ export class Smartcli {
*/
public triggerCommand(commandNameArg: string, argvObject: any) {
const triggerSubject = this.getCommandSubject(commandNameArg);
if (!triggerSubject) {
throw new Error(`No smartcli command registered for ${commandNameArg}`);
}
triggerSubject.next(argvObject);
return triggerSubject;
}
@@ -136,7 +139,6 @@ export class Smartcli {
console.log(this.version || 'unknown version');
return;
}
console.log(`Wanted command: ${wantedCommand}`);
for (const command of this.commandObservableMap.getArray()) {
if (!wantedCommand) {
const standardCommand = this.commandObservableMap.findSync((commandArg) => {
+761
View File
@@ -0,0 +1,761 @@
import * as plugins from './smartcli.plugins.js';
export type TSmartcliTerminalTaskStatus = 'running' | 'completed' | 'failed';
export type TSmartcliTerminalSymbolMode = 'auto' | 'unicode' | 'ascii';
export interface ISmartcliWritable {
isTTY?: boolean;
columns?: number;
write(chunk: string): void | boolean;
}
export interface ISmartcliTerminalOptions {
stream?: ISmartcliWritable;
interactive?: boolean;
colors?: boolean;
symbols?: TSmartcliTerminalSymbolMode;
cleanup?: boolean;
nonInteractiveThrottleMs?: number;
}
export interface ISmartcliTerminalTaskOptions {
job: string;
rows?: number;
logLimit?: number;
showTimer?: boolean;
showSpinner?: boolean;
timer?: boolean;
spinner?: boolean;
spinnerFrames?: string[];
spinnerIntervalMs?: number;
}
export interface ISmartcliTerminalAttachErrorOptions {
keepOpen?: boolean;
}
export interface ISmartcliTerminalTaskRunOptions {
successMessage?: string;
errorKeepOpen?: boolean;
}
interface INonInteractiveLogState {
message: string;
timestamp: number;
}
const ansiCodes = {
reset: '\u001B[0m',
red: '\u001B[31m',
green: '\u001B[32m',
cyan: '\u001B[36m',
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.
* It automatically falls back to append-only logs in non-interactive environments.
*/
export class SmartcliTerminal {
private stream: ISmartcliWritable;
private interactive: boolean;
private colors: boolean;
private useUnicodeSymbols: boolean;
private cleanupEnabled: boolean;
private nonInteractiveThrottleMs: number;
private tasks: SmartcliTerminalTask[] = [];
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 = {}) {
this.stream = optionsArg.stream || getDefaultStream();
this.interactive = getInteractiveState(this.stream, optionsArg.interactive);
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 {
const task = new SmartcliTerminalTask(this, optionsArg);
this.tasks.push(task);
if (this.interactive) {
this.ensureInteractiveSession();
this.render();
this.updateLiveRenderLoop();
} else {
this.writePermanentLine(`start ${task.job}`);
}
return task;
}
public createProcess(optionsArg: ISmartcliTerminalTaskOptions): SmartcliTerminalTask {
return this.createTask(optionsArg);
}
public task(jobArg: string, optionsArg: Omit<ISmartcliTerminalTaskOptions, 'job'> = {}) {
return this.createTask({
...optionsArg,
job: jobArg,
});
}
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) {
this.updateLiveRenderLoop();
this.render();
} else if (messageArg && this.shouldWriteNonInteractiveUpdate(taskArg, messageArg)) {
for (const messageLine of normalizeLines(messageArg)) {
this.writePermanentLine(`update ${taskArg.job}: ${messageLine}`);
}
}
}
/** @internal */
public completeTask(taskArg: SmartcliTerminalTask, messageArg?: string): void {
const message = messageArg || taskArg.getLastLogLine();
const summary = this.interactive
? `${this.getStatusSymbol('completed')} ${taskArg.job} ${taskArg.getElapsedSummaryText()}${message ? ` - ${message}` : ''}`
: `done ${taskArg.job} in ${taskArg.getElapsedSummaryText()}${message ? `: ${message}` : ''}`;
this.finalizeTask(taskArg, [summary], 'completed');
}
/** @internal */
public failTask(taskArg: SmartcliTerminalTask, errorLinesArg: string[]): void {
const summary = this.interactive
? `${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}`;
});
this.finalizeTask(taskArg, [summary, ...detailLines], 'failed');
}
public clear(): void {
if (this.interactive) {
this.clearRenderedBlock();
this.restoreCursor();
}
this.tasks = [];
this.nonInteractiveLogState.clear();
this.stopLiveRenderLoop();
}
/** @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.nonInteractiveLogState.delete(taskArg);
if (this.interactive) {
this.clearRenderedBlock();
this.writePermanentLines(linesArg.map((lineArg) => this.colorizeLine(lineArg, statusArg)));
this.updateLiveRenderLoop();
this.render();
if (this.tasks.length === 0) {
this.restoreCursor();
}
} else {
this.writePermanentLines(linesArg);
}
}
private render(): void {
if (!this.interactive) {
return;
}
const width = this.getLineWidth();
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);
});
const renderedOutput = coloredLines.join('\n');
if (renderedOutput === this.lastRenderedOutput) {
return;
}
if (this.renderedLineCount > 0) {
this.stream.write(`\u001B[${this.renderedLineCount}F`);
}
const lineCount = Math.max(this.renderedLineCount, coloredLines.length);
for (let index = 0; index < lineCount; index++) {
this.stream.write('\u001B[2K\r');
if (index < coloredLines.length) {
this.stream.write(coloredLines[index]);
}
this.stream.write('\n');
}
this.renderedLineCount = coloredLines.length;
this.lastRenderedOutput = renderedOutput;
}
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;
this.lastRenderedOutput = '';
}
private writePermanentLines(linesArg: string[]): void {
for (const line of linesArg) {
this.writePermanentLine(line);
}
}
private writePermanentLine(lineArg: string): void {
this.stream.write(`${lineArg}\n`);
}
private shouldWriteNonInteractiveUpdate(
taskArg: SmartcliTerminalTask,
messageArg: string
): boolean {
const now = Date.now();
const previousState = this.nonInteractiveLogState.get(taskArg);
if (previousState?.message === messageArg) {
return false;
}
if (previousState && now - previousState.timestamp < this.nonInteractiveThrottleMs) {
return false;
}
this.nonInteractiveLogState.set(taskArg, {
message: messageArg,
timestamp: now,
});
return true;
}
private restoreCursor(): void {
if (!this.cursorHidden) {
return;
}
this.stream.write('\u001B[?25h');
this.cursorHidden = false;
this.unregisterProcessCleanup();
}
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 {
return Math.max(20, (this.stream.columns || 80) - 1);
}
}
export class SmartcliTerminalTask {
public readonly job: string;
public readonly rows: number;
public readonly startTime = Date.now();
public status: TSmartcliTerminalTaskStatus = 'running';
private terminal: SmartcliTerminal;
private logLimit: number;
private logLines: 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) {
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));
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 {
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);
}
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 {
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);
}
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 {
return this.logLines[this.logLines.length - 1];
}
public getLogLines(): string[] {
return [...this.logLines];
}
public getErrorLines(): string[] {
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 */
public renderPlainRows(widthArg: number): string[] {
const lines: string[] = [];
const detailLines = this.errorLines.length > 0 ? this.errorLines : this.logLines;
const header = this.getHeaderLine();
if (this.rows === 1) {
const lastDetail = detailLines[detailLines.length - 1];
lines.push(`${header}${lastDetail ? ` - ${lastDetail}` : ''}`);
return lines.map((lineArg) => truncateLine(lineArg, widthArg));
}
lines.push(header);
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));
}
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 {
const processObject = getProcessObject();
if (processObject?.stdout?.write) {
return processObject.stdout;
}
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'
);
}
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 {
const value = getEnvValue(nameArg);
return Boolean(value && value !== '0' && value.toLowerCase() !== 'false');
}
function getEnvValue(nameArg: string): string | undefined {
return getProcessObject()?.env?.[nameArg];
}
function getProcessObject(): any {
const globalObject: any = globalThis as any;
return globalObject.process;
}
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)];
}
}
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 {
if (lineArg.length <= widthArg) {
return lineArg;
}
if (widthArg <= 3) {
return lineArg.slice(0, widthArg);
}
return `${lineArg.slice(0, widthArg - 3)}...`;
}
+1 -6
View File
@@ -63,14 +63,9 @@ export function getUserArgs(argv?: string[]): string[] {
offset = Math.min(2, a.length);
}
// Safety: if offset would skip all elements and array is not empty, don't skip anything
// This handles edge cases like test environments with unusual argv setups
if (offset >= a.length && a.length > 0) {
offset = 0;
}
// Note: we intentionally avoid path/URL heuristics on argv[1] so we don't
// accidentally drop the first user arg when it's a path-like value in compiled mode.
// When offset >= a.length, this correctly returns an empty array (no user args).
return a.slice(offset);
}
+2 -1
View File
@@ -5,8 +5,9 @@ import * as path from 'node:path';
import * as smartparam from '@push.rocks/smartobject';
import * as smartpromise from '@push.rocks/smartpromise';
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
import yargsParser from 'yargs-parser';
+4 -4
View File
@@ -5,10 +5,10 @@
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noImplicitAny": true,
"esModuleInterop": true,
"verbatimModuleSyntax": true
"verbatimModuleSyntax": true,
"types": ["node"]
},
"exclude": [
"dist_*/**/*.d.ts"
]
"exclude": ["dist_*/**/*.d.ts"]
}