visual-exec 1.1.10 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -5
- package/dist/visual-exec.d.ts +6 -1
- package/dist/visual-exec.js +69 -24
- package/docs/reference.md +343 -0
- package/package.json +8 -7
package/README.md
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
|
-
[![NPM version][npm-image]][npm-url] [![Build Status][build-image]][build-url]
|
|
2
|
-
|
|
3
1
|
# visual-exec
|
|
4
2
|
|
|
3
|
+
[![License][license-image]][license-url]
|
|
4
|
+
[![build][build-image]][build-url]
|
|
5
|
+
|
|
6
|
+
[![Downloads][downloads-image]][downloads-url]
|
|
7
|
+
|
|
8
|
+
[![npm badge][npm-badge-png]][package-url]
|
|
9
|
+
|
|
10
|
+
- [API reference](https://fynjs.pages.dev/api/modules/visual-exec) - TypeDoc
|
|
11
|
+
- [Markdown reference](docs/reference.md) - the full API and runtime behavior in one file. Also ships in the package.
|
|
12
|
+
- [GitHub](https://github.com/jchip/fynjs/tree/main/packages/visual-exec)
|
|
13
|
+
|
|
5
14
|
Run a shell command and show it as a live spinner line instead of a wall of output.
|
|
6
15
|
|
|
7
16
|
Output is captured while the command runs and the terminal shows a single updating line; on failure the captured output is logged so you can see what went wrong. The default logger detects CI and turns the in-place items off there, so build logs stay plain. Built on [visual-logger] and [xsh].
|
|
@@ -71,7 +80,8 @@ const { stdout, stderr } = await ve.execute();
|
|
|
71
80
|
| `onOutput(data, stream)` | called on each stdout/stderr chunk |
|
|
72
81
|
| `onComplete(output, exitCode)` | called when finished; its return value becomes `execute()`'s result on success |
|
|
73
82
|
| `outputFile` / `outputFileOptions` | stream output to a file (`append`, `includeStderr`, `timestamps`) |
|
|
74
|
-
| `progress` / `onProgress` | extract progress from
|
|
83
|
+
| `progress` / `onProgress` | extract progress from stdout or stderr by regex or custom function |
|
|
84
|
+
| `progress.format(p)` | text to show after the `stdout` label, ie: `p => \`${p.current}/${p.total}\`` |
|
|
75
85
|
| `matchers` | `[{ pattern, onMatch }]` run against output lines |
|
|
76
86
|
|
|
77
87
|
### Methods
|
|
@@ -93,7 +103,12 @@ Licensed under the [Apache License, Version 2.0](./LICENSE).
|
|
|
93
103
|
|
|
94
104
|
[visual-logger]: https://github.com/jchip/fynjs/tree/main/packages/visual-logger
|
|
95
105
|
[xsh]: https://github.com/jchip/fynjs/tree/main/packages/xsh
|
|
96
|
-
|
|
97
|
-
[
|
|
106
|
+
|
|
107
|
+
[license-image]: https://img.shields.io/npm/l/visual-exec.svg
|
|
108
|
+
[license-url]: LICENSE
|
|
98
109
|
[build-image]: https://github.com/jchip/fynjs/actions/workflows/ci.yml/badge.svg
|
|
99
110
|
[build-url]: https://github.com/jchip/fynjs/actions/workflows/ci.yml
|
|
111
|
+
[downloads-image]: https://img.shields.io/npm/dm/visual-exec.svg
|
|
112
|
+
[downloads-url]: https://npm-stat.com/charts.html?package=visual-exec
|
|
113
|
+
[npm-badge-png]: https://nodei.co/npm/visual-exec.png?downloads=true&stars=true
|
|
114
|
+
[package-url]: https://npmjs.com/package/visual-exec
|
package/dist/visual-exec.d.ts
CHANGED
|
@@ -21,7 +21,7 @@ export interface ProgressExtractor {
|
|
|
21
21
|
total?: number;
|
|
22
22
|
percent?: number;
|
|
23
23
|
} | null;
|
|
24
|
-
/**
|
|
24
|
+
/** Format progress as text to show after the `stdout` label, ie: `p => \`${p.current}/${p.total}\`` */
|
|
25
25
|
format?: (p: {
|
|
26
26
|
current?: number;
|
|
27
27
|
total?: number;
|
|
@@ -171,6 +171,9 @@ export declare class VisualExec {
|
|
|
171
171
|
private _progress?;
|
|
172
172
|
private _onProgress?;
|
|
173
173
|
private _matchers?;
|
|
174
|
+
/** latest `progress.format` result, shown on the stdout label */
|
|
175
|
+
private _progressText?;
|
|
176
|
+
private _stdoutDigest?;
|
|
174
177
|
private _stdoutKey?;
|
|
175
178
|
private _stderrKey?;
|
|
176
179
|
private _updateStdout?;
|
|
@@ -186,6 +189,7 @@ export declare class VisualExec {
|
|
|
186
189
|
private _createOutputFileStream;
|
|
187
190
|
private _writeToOutputFile;
|
|
188
191
|
private _extractProgress;
|
|
192
|
+
private _renderDigest;
|
|
189
193
|
private _updateDigest;
|
|
190
194
|
private _createDataHandler;
|
|
191
195
|
/** Abort/kill the running command */
|
|
@@ -200,6 +204,7 @@ export declare class VisualExec {
|
|
|
200
204
|
* it defaults to `ExecOutput`, which is what a run without an `onComplete` produces.
|
|
201
205
|
*/
|
|
202
206
|
show<T = ExecOutput>(child: ChildProcess): Promise<T>;
|
|
207
|
+
private _show;
|
|
203
208
|
logResult(err: VisualExecError | null, output?: ExecOutput): void;
|
|
204
209
|
checkForErrors(text: string): RegExpMatchArray | null;
|
|
205
210
|
/**
|
package/dist/visual-exec.js
CHANGED
|
@@ -13,16 +13,14 @@ const TEN_MB = 10 * ONE_MB;
|
|
|
13
13
|
const DEFAULT_TIMEOUT_GRACE = 5000;
|
|
14
14
|
const DEFAULT_LAST_LINES = 10;
|
|
15
15
|
const MAX_OUTPUT_IN_ERROR = 50000;
|
|
16
|
-
function createTimeoutError(
|
|
17
|
-
const err = new Error(`Command timed out after ${elapsed}ms: ${
|
|
16
|
+
function createTimeoutError(command, elapsed) {
|
|
17
|
+
const err = new Error(`Command timed out after ${elapsed}ms: ${command}`);
|
|
18
18
|
err.name = "TimeoutError";
|
|
19
|
-
err.context = { ...context, duration: elapsed };
|
|
20
19
|
return err;
|
|
21
20
|
}
|
|
22
|
-
function createAbortError(
|
|
23
|
-
const err = new Error(`Command aborted: ${
|
|
21
|
+
function createAbortError(command) {
|
|
22
|
+
const err = new Error(`Command aborted: ${command}`);
|
|
24
23
|
err.name = "AbortError";
|
|
25
|
-
err.context = context;
|
|
26
24
|
return err;
|
|
27
25
|
}
|
|
28
26
|
function lastLines(text, n) {
|
|
@@ -197,7 +195,7 @@ export class VisualExec {
|
|
|
197
195
|
}
|
|
198
196
|
_extractProgress(line) {
|
|
199
197
|
const cfg = this._progress;
|
|
200
|
-
if (!cfg || !this._onProgress)
|
|
198
|
+
if (!cfg || !(this._onProgress || cfg.format))
|
|
201
199
|
return;
|
|
202
200
|
let progress = null;
|
|
203
201
|
if (cfg.extract) {
|
|
@@ -213,9 +211,30 @@ export class VisualExec {
|
|
|
213
211
|
};
|
|
214
212
|
}
|
|
215
213
|
}
|
|
216
|
-
if (progress)
|
|
214
|
+
if (!progress)
|
|
215
|
+
return;
|
|
216
|
+
if (cfg.format) {
|
|
217
|
+
const text = cfg.format(progress);
|
|
218
|
+
if (text !== this._progressText) {
|
|
219
|
+
this._progressText = text;
|
|
220
|
+
this._renderDigest(this._stdoutDigest);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (this._onProgress)
|
|
217
224
|
this._onProgress(progress);
|
|
218
225
|
}
|
|
226
|
+
_renderDigest(item) {
|
|
227
|
+
// progress goes on the stdout label, the item with the spinner
|
|
228
|
+
const display = item === this._stdoutDigest && this._progressText
|
|
229
|
+
? `=== ${this._title}\nstdout ${this._progressText}`
|
|
230
|
+
: undefined;
|
|
231
|
+
this._logger.updateItem(item.name, {
|
|
232
|
+
msg: item.msg,
|
|
233
|
+
display,
|
|
234
|
+
_save: false,
|
|
235
|
+
_render: false
|
|
236
|
+
});
|
|
237
|
+
}
|
|
219
238
|
_updateDigest(item, buf) {
|
|
220
239
|
// sanitize at the boundary so every downstream use - lines, item.buf, the item message -
|
|
221
240
|
// is already inert. Raw output still reaches onOutput/outputFile via _createDataHandler.
|
|
@@ -252,11 +271,8 @@ export class VisualExec {
|
|
|
252
271
|
if (buf.endsWith("\n")) {
|
|
253
272
|
item.buf += "\n";
|
|
254
273
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
_save: false,
|
|
258
|
-
_render: false
|
|
259
|
-
});
|
|
274
|
+
item.msg = msgs.join(chalk.blue.inverse("\\n"));
|
|
275
|
+
this._renderDigest(item);
|
|
260
276
|
}
|
|
261
277
|
_createDataHandler(stream) {
|
|
262
278
|
return (buf) => {
|
|
@@ -298,6 +314,9 @@ export class VisualExec {
|
|
|
298
314
|
* it defaults to `ExecOutput`, which is what a run without an `onComplete` produces.
|
|
299
315
|
*/
|
|
300
316
|
show(child) {
|
|
317
|
+
return this._show(child, this._command);
|
|
318
|
+
}
|
|
319
|
+
_show(child, command) {
|
|
301
320
|
this._stdoutKey = Symbol("visual-exec-stdout");
|
|
302
321
|
this._stderrKey = Symbol("visual-exec-stderr");
|
|
303
322
|
this._rawChild = child.child;
|
|
@@ -313,16 +332,25 @@ export class VisualExec {
|
|
|
313
332
|
display: `stderr`
|
|
314
333
|
});
|
|
315
334
|
const stdoutDigest = { name: this._stdoutKey, buf: "" };
|
|
335
|
+
this._stdoutDigest = stdoutDigest;
|
|
316
336
|
const stderrDigest = { name: this._stderrKey, buf: "" };
|
|
317
337
|
this._updateStdout = (buf) => this._updateDigest(stdoutDigest, buf);
|
|
318
338
|
this._updateStderr = (buf) => this._updateDigest(stderrDigest, buf);
|
|
339
|
+
// output seen so far, so a timeout or abort error can carry it like an exit failure does
|
|
340
|
+
const seen = { stdout: "", stderr: "" };
|
|
341
|
+
const capture = (stream, data) => {
|
|
342
|
+
const text = seen[stream] + data;
|
|
343
|
+
seen[stream] = text.length > MAX_OUTPUT_IN_ERROR ? text.slice(-MAX_OUTPUT_IN_ERROR) : text;
|
|
344
|
+
};
|
|
319
345
|
this._onStdoutData = (buf) => {
|
|
320
346
|
const data = typeof buf === "string" ? buf : buf.toString();
|
|
347
|
+
capture("stdout", data);
|
|
321
348
|
this._updateStdout(data);
|
|
322
349
|
this._createDataHandler("stdout")(buf);
|
|
323
350
|
};
|
|
324
351
|
this._onStderrData = (buf) => {
|
|
325
352
|
const data = typeof buf === "string" ? buf : buf.toString();
|
|
353
|
+
capture("stderr", data);
|
|
326
354
|
this._updateStderr(data);
|
|
327
355
|
this._createDataHandler("stderr")(buf);
|
|
328
356
|
};
|
|
@@ -343,7 +371,12 @@ export class VisualExec {
|
|
|
343
371
|
var _b, _c, _d;
|
|
344
372
|
const output = (_b = err.output) !== null && _b !== void 0 ? _b : { stdout: "", stderr: "" };
|
|
345
373
|
const exitCode = (_d = (_c = err.exitCode) !== null && _c !== void 0 ? _c : err.code) !== null && _d !== void 0 ? _d : 1;
|
|
346
|
-
|
|
374
|
+
try {
|
|
375
|
+
(_a = this._onComplete) === null || _a === void 0 ? void 0 : _a.call(this, output, exitCode);
|
|
376
|
+
}
|
|
377
|
+
catch (_e) {
|
|
378
|
+
// the command's own failure is the error to report
|
|
379
|
+
}
|
|
347
380
|
this.logResult(err);
|
|
348
381
|
throw err;
|
|
349
382
|
})
|
|
@@ -353,6 +386,20 @@ export class VisualExec {
|
|
|
353
386
|
const result = (_a = this._onComplete) === null || _a === void 0 ? void 0 : _a.call(this, output, 0);
|
|
354
387
|
return result !== undefined ? result : output;
|
|
355
388
|
});
|
|
389
|
+
const cancelError = (err, duration) => {
|
|
390
|
+
const output = { ...seen };
|
|
391
|
+
err.output = output;
|
|
392
|
+
return enhanceError(err, {
|
|
393
|
+
exitCode: -1,
|
|
394
|
+
signal: "SIGTERM",
|
|
395
|
+
cwd: this._cwd,
|
|
396
|
+
command,
|
|
397
|
+
duration,
|
|
398
|
+
lastLines: lastLines(`${output.stdout}\n${output.stderr}`, DEFAULT_LAST_LINES),
|
|
399
|
+
stdout: output.stdout,
|
|
400
|
+
stderr: output.stderr
|
|
401
|
+
});
|
|
402
|
+
};
|
|
356
403
|
if (this._timeout) {
|
|
357
404
|
const timeoutPromise = new Promise((_, reject) => {
|
|
358
405
|
timeoutId = setTimeout(() => {
|
|
@@ -369,14 +416,18 @@ export class VisualExec {
|
|
|
369
416
|
}, this._timeoutGrace);
|
|
370
417
|
(_d = (_c = grace).unref) === null || _d === void 0 ? void 0 : _d.call(_c);
|
|
371
418
|
}
|
|
372
|
-
reject(createTimeoutError(
|
|
419
|
+
reject(cancelError(createTimeoutError(command, this._timeout), this._timeout));
|
|
373
420
|
}, this._timeout);
|
|
374
421
|
});
|
|
375
422
|
execPromise = Promise.race([execPromise, timeoutPromise]);
|
|
376
423
|
}
|
|
424
|
+
let handleAbort;
|
|
377
425
|
const cleanup = () => {
|
|
378
426
|
if (timeoutId)
|
|
379
427
|
clearTimeout(timeoutId);
|
|
428
|
+
// a long-lived signal reused across runs would otherwise collect one listener per run
|
|
429
|
+
if (handleAbort)
|
|
430
|
+
this._signal.removeEventListener("abort", handleAbort);
|
|
380
431
|
const stream = this._outputStream;
|
|
381
432
|
this._outputStream = undefined;
|
|
382
433
|
// Only streams we opened are ours to close - a caller-supplied one stays open.
|
|
@@ -403,15 +454,9 @@ export class VisualExec {
|
|
|
403
454
|
};
|
|
404
455
|
if (this._signal) {
|
|
405
456
|
const abortPromise = new Promise((_, reject) => {
|
|
406
|
-
|
|
457
|
+
handleAbort = () => {
|
|
407
458
|
this.abort();
|
|
408
|
-
reject(createAbortError(
|
|
409
|
-
command: this._command,
|
|
410
|
-
cwd: this._cwd,
|
|
411
|
-
exitCode: -1,
|
|
412
|
-
signal: "SIGTERM",
|
|
413
|
-
duration: Date.now() - startTime
|
|
414
|
-
}));
|
|
459
|
+
reject(cancelError(createAbortError(command), Date.now() - startTime));
|
|
415
460
|
};
|
|
416
461
|
if (this._signal.aborted) {
|
|
417
462
|
handleAbort();
|
|
@@ -533,7 +578,7 @@ export class VisualExec {
|
|
|
533
578
|
};
|
|
534
579
|
return Promise.reject(enhanceError(err, context));
|
|
535
580
|
});
|
|
536
|
-
return this.
|
|
581
|
+
return this._show(child, cmd);
|
|
537
582
|
}
|
|
538
583
|
}
|
|
539
584
|
export default VisualExec;
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
# visual-exec reference
|
|
2
|
+
|
|
3
|
+
`visual-exec` runs a shell command through `xsh.exec`, shows its output as a live spinner item on a `visual-logger`, and logs the captured output when the command ends. ESM only (`"type": "module"`).
|
|
4
|
+
|
|
5
|
+
## Imports
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
import VisualExec, { getDefaultLogger, parsers } from "visual-exec";
|
|
9
|
+
import { VisualExec, jsonLinesParser, keyValueParser } from "visual-exec";
|
|
10
|
+
import type { VisualExecOptions, ExecOutput, VisualExecError } from "visual-exec";
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The default export is `VisualExec`, the same class as the named export. There are no subpaths (only `visual-exec/package.json`).
|
|
14
|
+
|
|
15
|
+
Runtime exports: `VisualExec` (also `default`), `parsers`, `jsonLinesParser`, `keyValueParser`, `getDefaultLogger`.
|
|
16
|
+
|
|
17
|
+
Type-only exports: `VisualExecOptions`, `ExecOutput`, `ExecErrorContext`, `VisualExecError`, `OnOutputCallback`, `OnCompleteCallback`, `OutputFileOptions`, `ProgressExtractor`, `OutputMatcher`.
|
|
18
|
+
|
|
19
|
+
Not exported from the root: `sanitizeText`, `sanitizeForDisplay`, `sanitizeForOutput`, `SanitizeOptions`, `OutputStream` (`"stdout" | "stderr"`, used in `OnOutputCallback`), and the `ChildProcess` shape taken by `show`. The default export of `get-default-logger` is also not re-exported.
|
|
20
|
+
|
|
21
|
+
## `VisualExec`
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
class VisualExec {
|
|
25
|
+
constructor(options: VisualExecOptions);
|
|
26
|
+
execute<T = ExecOutput>(command?: string): Promise<T>;
|
|
27
|
+
show<T = ExecOutput>(child: ChildProcess): Promise<T>;
|
|
28
|
+
abort(signal?: string): void; // default "SIGTERM"
|
|
29
|
+
kill(signal?: string): void; // alias of abort
|
|
30
|
+
logResult(err: VisualExecError | null, output?: ExecOutput): void;
|
|
31
|
+
checkForErrors(text: string): RegExpMatchArray | null;
|
|
32
|
+
logFinalOutput(err: VisualExecError | null, output: ExecOutput): void;
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
One instance is meant for one run at a time. State such as the output file stream, item keys and the abort flag lives on the instance. `execute` may be called again after it settles. Calling it while a run is in progress overwrites that state.
|
|
37
|
+
|
|
38
|
+
### Constructor options
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
interface VisualExecOptions {
|
|
42
|
+
command: string;
|
|
43
|
+
cwd?: string;
|
|
44
|
+
visualLogger?: VisualLogger;
|
|
45
|
+
spinner?: ItemOptions["spinner"];
|
|
46
|
+
displayTitle?: string;
|
|
47
|
+
logLabel?: string;
|
|
48
|
+
outputLabel?: string;
|
|
49
|
+
outputLevel?: string;
|
|
50
|
+
maxBuffer?: number;
|
|
51
|
+
forceStderr?: boolean;
|
|
52
|
+
checkStdoutError?: boolean | RegExp;
|
|
53
|
+
timeout?: number;
|
|
54
|
+
timeoutGrace?: number;
|
|
55
|
+
onTimeout?: () => void;
|
|
56
|
+
onOutput?: OnOutputCallback;
|
|
57
|
+
onComplete?: OnCompleteCallback;
|
|
58
|
+
outputFile?: string | NodeJS.WritableStream;
|
|
59
|
+
outputFileOptions?: OutputFileOptions;
|
|
60
|
+
signal?: AbortSignal;
|
|
61
|
+
progress?: ProgressExtractor;
|
|
62
|
+
onProgress?: (progress: { current?: number; total?: number; percent?: number }) => void;
|
|
63
|
+
matchers?: OutputMatcher[];
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
| Option | Type | Default | Behavior |
|
|
68
|
+
| --- | --- | --- | --- |
|
|
69
|
+
| `command` | string | none (required) | Command run by `execute()` when it is called with no argument. Nothing validates it at construction. A non-string only changes the default title to `"Running user command"`. `execute()` then fails in `xsh.exec` (see Errors). |
|
|
70
|
+
| `cwd` | string | `process.cwd()` | Working directory. Also sets `PWD` in the child env. A falsy value falls back to `process.cwd()`. |
|
|
71
|
+
| `visualLogger` | `VisualLogger` | `getDefaultLogger()` | Logger that renders the spinner items and receives all log lines. |
|
|
72
|
+
| `spinner` | `ItemOptions["spinner"]` | `VisualLogger.spinners[1]` | Spinner style passed to the stdout item. |
|
|
73
|
+
| `displayTitle` | string | `"Running " + command` | Title shown on the stdout item. Also the default for `logLabel` and `outputLabel`. |
|
|
74
|
+
| `logLabel` | string | the title | Label in the `Done` and `Failed` lines. |
|
|
75
|
+
| `outputLabel` | string | the title | Label in the `No output` line and the `Start of output` and `End of output` markers. |
|
|
76
|
+
| `outputLevel` | string | `"verbose"` | Logger method name used for final output when nothing looks like an error. Must name a method on the logger. An unknown name throws `TypeError` from `logFinalOutput`. |
|
|
77
|
+
| `maxBuffer` | number | 10 MiB (10485760) | Passed to `child_process.exec` as `maxBuffer`, per stream. |
|
|
78
|
+
| `forceStderr` | boolean | `true` | When true, any non-empty captured stderr logs the final output at `error` level, even on exit code 0. |
|
|
79
|
+
| `checkStdoutError` | `boolean \| RegExp` | `true` | `true` uses `/error\|warn\|fatal\|unhandled\|reject\|exception\|failure\|fail\|failed/i`. A `RegExp` is used as given. `false` disables the check. A stdout match only raises the log level to `error`. It does not fail the run. |
|
|
80
|
+
| `timeout` | number (ms) | none | Starts when `show()` runs. A falsy value (`undefined`, `0`) disables it. See Timeout. |
|
|
81
|
+
| `timeoutGrace` | number (ms) | 5000 | Delay between SIGTERM and SIGKILL after a timeout. |
|
|
82
|
+
| `onTimeout` | `() => void` | none | Called when the timeout fires, before SIGTERM. Not called if `abort()` or the abort signal already ran. |
|
|
83
|
+
| `onOutput` | `OnOutputCallback` | none | Called for each raw stdout or stderr chunk. See Output capture. |
|
|
84
|
+
| `onComplete` | `OnCompleteCallback` | none | Called when the command ends. See Completion. |
|
|
85
|
+
| `outputFile` | `string \| WritableStream` | none | Where raw output is streamed. See Output file. |
|
|
86
|
+
| `outputFileOptions` | `OutputFileOptions` | `{ append: false, includeStderr: true, timestamps: false }` | Merged over the defaults. Only used with `outputFile`. |
|
|
87
|
+
| `signal` | `AbortSignal` | none | Aborts the run. See Abort. |
|
|
88
|
+
| `progress` | `ProgressExtractor` | none | Progress extraction config. Needs `onProgress` too. |
|
|
89
|
+
| `onProgress` | function | none | Called with extracted progress. Without it `progress` does nothing. |
|
|
90
|
+
| `matchers` | `OutputMatcher[]` | none | Per-line regex matchers. |
|
|
91
|
+
|
|
92
|
+
### Related types
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
type OnOutputCallback = (data: string, stream: "stdout" | "stderr") => void;
|
|
96
|
+
type OnCompleteCallback = (output: ExecOutput, exitCode: number) => unknown;
|
|
97
|
+
|
|
98
|
+
interface OutputFileOptions {
|
|
99
|
+
append?: boolean; // default false: open with flag "w". true: "a"
|
|
100
|
+
includeStderr?: boolean; // default true
|
|
101
|
+
timestamps?: boolean; // default false
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface ProgressExtractor {
|
|
105
|
+
pattern?: RegExp;
|
|
106
|
+
extract?: (line: string) => { current?: number; total?: number; percent?: number } | null;
|
|
107
|
+
format?: (p: { current?: number; total?: number; percent?: number }) => string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface OutputMatcher {
|
|
111
|
+
pattern: RegExp;
|
|
112
|
+
onMatch: (match: RegExpMatchArray) => void;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface ExecOutput { stdout: string; stderr: string }
|
|
116
|
+
|
|
117
|
+
interface ExecErrorContext {
|
|
118
|
+
exitCode: number;
|
|
119
|
+
signal: string | null;
|
|
120
|
+
cwd: string;
|
|
121
|
+
command: string;
|
|
122
|
+
duration: number;
|
|
123
|
+
lastLines: string[];
|
|
124
|
+
stdout: string;
|
|
125
|
+
stderr: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface VisualExecError extends Error {
|
|
129
|
+
output?: ExecOutput;
|
|
130
|
+
code?: number;
|
|
131
|
+
exitCode?: number;
|
|
132
|
+
signal?: string | null;
|
|
133
|
+
cwd?: string;
|
|
134
|
+
command?: string;
|
|
135
|
+
duration?: number;
|
|
136
|
+
lastLines?: string[];
|
|
137
|
+
stdout?: string;
|
|
138
|
+
stderr?: string;
|
|
139
|
+
context?: ExecErrorContext;
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`ProgressExtractor.format` is declared but never read by the code. Setting it has no effect.
|
|
144
|
+
|
|
145
|
+
### `execute(command?)`
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
execute<T = ExecOutput>(command?: string): Promise<T>
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Runs the command and renders it. `command` overrides the constructor command for this call (`command || this._command`, so an empty string falls back).
|
|
152
|
+
|
|
153
|
+
How it spawns: it calls `xsh.exec({ silent: true, cwd, env, maxBuffer }, cmd)`. `xsh.exec` calls Node `child_process.exec`, so the command runs in the default system shell (`/bin/sh` on POSIX) as one string, not an argv array. `env` is `process.env` plus `PWD` set to the cwd. `silent: true` means xsh does not pipe the child to `process.stdout` or `process.stderr`. Output is only buffered, shown through the logger, and passed to callbacks. Output is decoded as utf8.
|
|
154
|
+
|
|
155
|
+
Resets `startTime` and the abort flag at the start of each call, then hands the child to `show()`.
|
|
156
|
+
|
|
157
|
+
Resolves with `{ stdout, stderr }` (full buffered strings), or with the value returned by `onComplete` if that value is not `undefined`. `T` is a type hint only. Nothing checks it.
|
|
158
|
+
|
|
159
|
+
Rejects when the shell command exits non-zero, is killed, exceeds `maxBuffer`, times out, or is aborted. See Errors.
|
|
160
|
+
|
|
161
|
+
### `show(child)`
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
show<T = ExecOutput>(child: {
|
|
165
|
+
stdout: NodeJS.ReadableStream;
|
|
166
|
+
stderr: NodeJS.ReadableStream;
|
|
167
|
+
promise: Promise<ExecOutput>;
|
|
168
|
+
child?: { kill(signal?: string): boolean; pid?: number };
|
|
169
|
+
}): Promise<T>
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Attaches rendering, callbacks, timeout and abort handling to an already started child. `execute` is `xsh.exec` plus error enrichment plus `show`. `child.promise` must reject with an object that may carry `output`, `code` or `exitCode`. The enriched error context (`lastLines`, `cwd`, `command`, `duration`, `stdout`, `stderr` on the error) is added only by `execute`, not by `show`. `child.child` is what `abort()`, timeout and the abort signal kill. Without it, nothing is killed.
|
|
173
|
+
|
|
174
|
+
### `abort(signal?)` and `kill(signal?)`
|
|
175
|
+
|
|
176
|
+
Sets the abort flag, then calls `child.kill(signal)` (default `"SIGTERM"`) if the child has a truthy `pid`. `kill` calls `abort` with the same argument.
|
|
177
|
+
|
|
178
|
+
`abort()` alone does not reject by itself. The kill makes the child exit, so the run rejects through the normal failure path (see Errors), unless a timeout or signal rejection won first. After `abort()`, a pending timeout timer does nothing when it fires (the check returns early). Does nothing before `show()` has a child. The abort flag is reset by the next `execute()`.
|
|
179
|
+
|
|
180
|
+
### `logResult(err, output?)`
|
|
181
|
+
|
|
182
|
+
Public, called internally once per run when the child promise settles. It needs `show()` to have run (it dereferences the stored child). It:
|
|
183
|
+
|
|
184
|
+
1. Removes the stdout and stderr items from the logger.
|
|
185
|
+
2. Removes the `data` listeners it added to the child streams.
|
|
186
|
+
3. On error: `logger.error("Failed <logLabel> - <err.message>")` with `Failed` in red, and takes `output` from `err.output`. On success: `logger.info("Done <logLabel> <secs>secs exit code 0")` with seconds to 2 decimals.
|
|
187
|
+
4. Calls `logFinalOutput(err, output)`.
|
|
188
|
+
|
|
189
|
+
Seconds are measured from `startTime`, which is set in the constructor and again in `execute()`.
|
|
190
|
+
|
|
191
|
+
### `checkForErrors(text)`
|
|
192
|
+
|
|
193
|
+
Returns `null` when `checkStdoutError` is `false` or `text` is empty. Otherwise returns `text.match(regex)` using the resolved regex (default or user supplied). A global regex returns all matches without groups. Used by `logFinalOutput` on stdout only.
|
|
194
|
+
|
|
195
|
+
### `logFinalOutput(err, output)`
|
|
196
|
+
|
|
197
|
+
Logs the captured output after the run. Documented in the source as overridable. Assign an instance property to replace it (`ve.logFinalOutput = () => {}` suppresses output logging).
|
|
198
|
+
|
|
199
|
+
- Level is `"error"` if `err` is set, or `forceStderr` is true and `output.stderr` is non-empty, or `checkForErrors(output.stdout)` matches. Otherwise it is `outputLevel`.
|
|
200
|
+
- If `output` is missing or both streams are empty: logs `No output from <outputLabel>` (with `No output` in green) at that level and returns.
|
|
201
|
+
- Otherwise builds one multi-argument call: `>>>`, `Start of output from <outputLabel> ===`, then `\n<stdout>`, then `\n=== stderr ===\n<stderr>` if stderr is non-empty (header in red), then `\n<<<` and `End of output from <outputLabel> ---`.
|
|
202
|
+
- It calls `logger.prefix(false)` first, then the level method. `prefix(false)` sets the logger prefix to `""` and is never restored, so it persists on the logger (including a shared one) after the first call.
|
|
203
|
+
- Output text is sanitized by `sanitizeForOutput` (below) and every `ERR!` is colored red.
|
|
204
|
+
|
|
205
|
+
## Output capture and display
|
|
206
|
+
|
|
207
|
+
Each `show()` adds two items to the logger:
|
|
208
|
+
|
|
209
|
+
| Item | Color | `display` | Spinner |
|
|
210
|
+
| --- | --- | --- | --- |
|
|
211
|
+
| stdout | green | `=== <title>\nstdout` | the `spinner` option |
|
|
212
|
+
| stderr | red | `stderr` | default of the logger |
|
|
213
|
+
|
|
214
|
+
Items are keyed by fresh `Symbol`s, so concurrent instances on one logger do not collide. Both are removed in `logResult`.
|
|
215
|
+
|
|
216
|
+
Each `data` chunk from stdout or stderr does two things.
|
|
217
|
+
|
|
218
|
+
1. Updates its item's message with `logger.updateItem(key, { msg, _save: false, _render: false })`. The chunk is sanitized (`sanitizeForDisplay`) first. Lines are trimmed and empty lines dropped. Taking lines from the end, it keeps lines while the sum of their ANSI-stripped lengths stays under 100 characters. Kept lines are joined with an inverse blue `\n` marker. If no whole line fits, the last line is used, cut to its first 100 characters when it is over 120 characters (the cut text is ANSI-stripped). The kept text carries over to the next chunk as the item buffer, so a partial line is joined with the next chunk. `_render: false` means the update does not force a render. The logger's own render timer draws it. `_save: false` keeps the update out of the saved log data.
|
|
219
|
+
2. Runs the raw (unsanitized) data handler: `onOutput(data, stream)`, then the output file write, then progress and matchers on each non-empty line from `data.split("\n")`.
|
|
220
|
+
|
|
221
|
+
Raw means exactly what the child wrote. `onOutput` and the output file get the bytes untouched, per chunk, with no line buffering. A line split across two chunks is seen as two partial lines by progress and matchers.
|
|
222
|
+
|
|
223
|
+
Full output is not accumulated by `visual-exec`. The final `stdout` and `stderr` come from `child_process.exec`'s own buffers.
|
|
224
|
+
|
|
225
|
+
### Sanitizing
|
|
226
|
+
|
|
227
|
+
Child control sequences are removed before display and before the final log output, so a child that moves the cursor cannot erase lines. SGR colour sequences (`ESC[...m`) are kept. These are removed: other CSI sequences, OSC, DCS, PM, APC and SOS strings, charset and single-char escapes, 8-bit C1 controls, and C0 controls other than `\n`, `\t` and the `ESC` of kept SGR. Display profile: tabs become spaces, a lone `\r` is dropped. Output profile: tabs kept, `\r\n` and `\r` become `\n`, and an SGR reset is appended if any colour remained. Callbacks and the output file are not sanitized.
|
|
228
|
+
|
|
229
|
+
### Progress
|
|
230
|
+
|
|
231
|
+
Runs per non-empty line, only when both `progress` and `onProgress` are set.
|
|
232
|
+
|
|
233
|
+
- If `progress.extract` exists it is used and its return is passed on when truthy. `pattern` is ignored then.
|
|
234
|
+
- Else if `progress.pattern` matches and the match has `groups`, calls `onProgress({ current, total, percent })`. Each field is `parseInt(group, 10)` when that named group is non-empty, else `undefined`. A pattern with `groups` that matches but defines none of the three names still calls `onProgress` with all fields `undefined`. A pattern with no named groups never calls it.
|
|
235
|
+
- `format` is never used.
|
|
236
|
+
|
|
237
|
+
### Matchers
|
|
238
|
+
|
|
239
|
+
For each non-empty line and each matcher in order: `line.match(pattern)`, and if truthy, `onMatch(match)`. Same chunk caveats as above. Errors thrown by `onMatch`, `onProgress`, or `onOutput` are not caught by `visual-exec` (they throw inside the stream `data` listener).
|
|
240
|
+
|
|
241
|
+
### Output file
|
|
242
|
+
|
|
243
|
+
With `outputFile` as a string: the parent directory is created recursively if missing, the file is opened with flag `w` (or `a` if `append`), and the stream is closed when the run ends. `execute()` waits for the stream's `close` (or `error`) event before settling, so the file is complete when the promise resolves. A stream already destroyed or closed is not waited on.
|
|
244
|
+
|
|
245
|
+
With `outputFile` as a `WritableStream`: it is written to directly and never ended or closed by `visual-exec`. `execute()` does not wait for it to flush.
|
|
246
|
+
|
|
247
|
+
Written data is the raw chunk. With `timestamps: true` each chunk is prefixed with `[<ISO timestamp>] `, one prefix per chunk, not per line. With `includeStderr: false` stderr chunks are skipped.
|
|
248
|
+
|
|
249
|
+
## Timeout
|
|
250
|
+
|
|
251
|
+
When `timeout` is truthy, a timer starts in `show()`. On fire (unless `abort()` or the signal already ran):
|
|
252
|
+
|
|
253
|
+
1. `onTimeout()` is called.
|
|
254
|
+
2. If the child has a `pid`, it gets SIGTERM, then after `timeoutGrace` ms an unref'd timer sends SIGKILL if a `pid` is still recorded. The grace timer does not keep the process alive. `pid` is not cleared on exit, so SIGKILL is attempted even if the child already exited.
|
|
255
|
+
3. The run rejects immediately with a timeout error (below). It does not wait for the child to die.
|
|
256
|
+
|
|
257
|
+
The timer is cleared when the run settles.
|
|
258
|
+
|
|
259
|
+
## Abort
|
|
260
|
+
|
|
261
|
+
With `signal`, the run rejects with an abort error as soon as the signal aborts, and `abort()` is called (SIGTERM). If the signal is already aborted when `show()` runs, the same happens immediately. The `abort` listener is added to the signal and is not removed afterward.
|
|
262
|
+
|
|
263
|
+
## Completion
|
|
264
|
+
|
|
265
|
+
`onComplete(output, exitCode)` runs once when the child settles.
|
|
266
|
+
|
|
267
|
+
- Success: `logResult` runs first, then `onComplete(output, 0)`. If it returns a value other than `undefined`, that value is the resolved result of `execute()`. Otherwise the output object is. A throw from `onComplete` rejects the run. Failure logging is not triggered for that throw.
|
|
268
|
+
- Failure: `onComplete(output, exitCode)` runs before `logResult`, with `output` from `err.output` (or empty strings) and `exitCode` from `err.exitCode ?? err.code ?? 1`. Its return value is ignored. The original error is rethrown. A throw from `onComplete` replaces the error and skips `logResult`.
|
|
269
|
+
- Timeout and abort rejections happen through a race. `onComplete` still runs later when the killed child's own promise rejects, but its outcome no longer affects the already rejected promise.
|
|
270
|
+
|
|
271
|
+
## Errors
|
|
272
|
+
|
|
273
|
+
Process failures reject. One synchronous throw is possible: if the command is not a string (or array of strings), `xsh.exec` throws from `execute()` before anything is shown.
|
|
274
|
+
|
|
275
|
+
### Command failure
|
|
276
|
+
|
|
277
|
+
The rejection is the `Error` made by xsh, message `shell cmd '<cmd>' exit code <code>`, with these properties added or kept:
|
|
278
|
+
|
|
279
|
+
| Property | Value |
|
|
280
|
+
| --- | --- |
|
|
281
|
+
| `output` | `{ stdout, stderr }`, the full buffered output. From xsh. |
|
|
282
|
+
| `code` | From Node's exec error `code`. Normally the exit code. It may be a string such as `"ERR_CHILD_PROCESS_STDIO_MAXBUFFER"` or `null` when killed by a signal, despite the `number` type. From xsh. |
|
|
283
|
+
| `exitCode` | `err.code ?? 1`. `1` when `code` is `null` or `undefined`. |
|
|
284
|
+
| `signal` | `err.signal`, else `"SIGTERM"` if the child object has `killed === true`, else `null`. xsh does not copy Node's `signal`, so a child killed from outside is `null`. Any kill via `abort()` or timeout reports `"SIGTERM"`, even for SIGKILL. |
|
|
285
|
+
| `cwd` | The instance cwd. |
|
|
286
|
+
| `command` | The command actually run (the `execute(command)` override if given). |
|
|
287
|
+
| `duration` | Milliseconds since `execute()` started. |
|
|
288
|
+
| `lastLines` | Last 10 non-empty lines of stdout lines followed by stderr lines. |
|
|
289
|
+
| `stdout`, `stderr` | Last 50000 characters of each (sliced from the end). `output` keeps the full text. |
|
|
290
|
+
| `context` | An `ExecErrorContext` with the same values. Its `stdout` and `stderr` are not truncated. |
|
|
291
|
+
|
|
292
|
+
The error is logged with `Failed ...` and the final output before it is rethrown.
|
|
293
|
+
|
|
294
|
+
### `TimeoutError`
|
|
295
|
+
|
|
296
|
+
`name` is `"TimeoutError"`, a plain `Error` (no class is exported). Message: `Command timed out after <timeout>ms: <constructor command>`. `context` is `{ command, cwd, exitCode: -1, signal: "SIGTERM", duration: <timeout> }`. The error has none of the enrichment properties (`exitCode`, `stdout`, `stderr`, `lastLines`, `cwd`, `duration`). Only `context` is set, and it has no `stdout`, `stderr` or `lastLines`. `duration` is the timeout value, not measured time. Note `command` here is the constructor command even if `execute(command)` was used.
|
|
297
|
+
|
|
298
|
+
### `AbortError`
|
|
299
|
+
|
|
300
|
+
`name` is `"AbortError"`. Message: `Command aborted: <constructor command>`. `context` is `{ command, cwd, exitCode: -1, signal: "SIGTERM", duration }` where `duration` is time since `show()` started. Same missing properties as `TimeoutError`: only `context` is set.
|
|
301
|
+
|
|
302
|
+
### Which wins
|
|
303
|
+
|
|
304
|
+
`Promise.race` runs timeout and abort against the command promise. The first to settle sets the rejection. A normal exit or failure beating both gives the normal result or command failure error.
|
|
305
|
+
|
|
306
|
+
## `getDefaultLogger()`
|
|
307
|
+
|
|
308
|
+
```ts
|
|
309
|
+
function getDefaultLogger(): VisualLogger
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
Returns a module-level singleton `VisualLogger`, created on first call with default options. If `ci-info` reports a CI environment, on creation it logs `visual-exec: CI env detected` at info level and calls `setItemType("none")`, so spinner items are not drawn. This logger is used by `VisualExec` when `visualLogger` is not given. The instance is shared by every `VisualExec` in the process that uses the default. It never changes after creation.
|
|
313
|
+
|
|
314
|
+
## Parsers
|
|
315
|
+
|
|
316
|
+
Standalone helpers to use inside `onOutput`, `progress.extract` or `onMatch`. Nothing in the package calls them.
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
function jsonLinesParser(line: string): unknown | null
|
|
320
|
+
function keyValueParser(line: string): Record<string, string> | null
|
|
321
|
+
const parsers: { jsonLines: typeof jsonLinesParser; keyValue: typeof keyValueParser }
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
- `jsonLinesParser`: trims the line. Returns `null` for an empty line, a line starting with `//`, or invalid JSON. Otherwise returns `JSON.parse` of it. A line that is the JSON text `null` also returns `null`.
|
|
325
|
+
- `keyValueParser`: matches `^([^=:]+)[=:]\s*(.*)$`. Splits at the first `=` or `:`. Returns `{ [trimmedKey]: trimmedValue }`, or `null` if no match. A line with an empty key returns `null`.
|
|
326
|
+
- `parsers`: `{ jsonLines: jsonLinesParser, keyValue: keyValueParser }`.
|
|
327
|
+
|
|
328
|
+
## Example
|
|
329
|
+
|
|
330
|
+
```js
|
|
331
|
+
const ve = new VisualExec({
|
|
332
|
+
command: "npm test",
|
|
333
|
+
timeout: 60000,
|
|
334
|
+
progress: { pattern: /(?<current>\d+)\/(?<total>\d+)/ },
|
|
335
|
+
onProgress: p => console.log(p.current, p.total),
|
|
336
|
+
outputFile: ".temp/test.log"
|
|
337
|
+
});
|
|
338
|
+
try {
|
|
339
|
+
const { stdout } = await ve.execute();
|
|
340
|
+
} catch (err) {
|
|
341
|
+
console.error(err.name, err.exitCode, err.lastLines);
|
|
342
|
+
}
|
|
343
|
+
```
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "visual-exec",
|
|
3
|
-
"version": "1.1
|
|
4
|
-
"homepage": "https://
|
|
3
|
+
"version": "1.2.1",
|
|
4
|
+
"homepage": "https://fynjs.pages.dev/api/modules/visual-exec",
|
|
5
5
|
"description": "Visual shell exec with spinners and progress display",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
"progress"
|
|
41
41
|
],
|
|
42
42
|
"files": [
|
|
43
|
-
"dist"
|
|
43
|
+
"dist",
|
|
44
|
+
"docs/reference.md"
|
|
44
45
|
],
|
|
45
46
|
"author": "Joel Chen",
|
|
46
47
|
"license": "Apache-2.0",
|
|
@@ -49,14 +50,14 @@
|
|
|
49
50
|
"ci-info": "^4.4.0",
|
|
50
51
|
"has-ansi": "^6.0.2",
|
|
51
52
|
"strip-ansi": "^7.2.0",
|
|
52
|
-
"visual-logger": "^2.1.
|
|
53
|
-
"xsh": "^1.1.
|
|
53
|
+
"visual-logger": "^2.1.10",
|
|
54
|
+
"xsh": "^1.1.10"
|
|
54
55
|
},
|
|
55
56
|
"devDependencies": {
|
|
56
57
|
"@types/node": "^26.4.1",
|
|
57
58
|
"@vitest/coverage-v8": "^5.0.0",
|
|
58
|
-
"publish-util": "^3.1.
|
|
59
|
-
"run-verify": "^2.
|
|
59
|
+
"publish-util": "^3.1.8",
|
|
60
|
+
"run-verify": "^2.2.0",
|
|
60
61
|
"typescript": "^7.0.2",
|
|
61
62
|
"vite": "^8.2.2",
|
|
62
63
|
"vitest": "^5.0.0"
|