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 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 output by regex or custom function |
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
- [npm-image]: https://badge.fury.io/js/visual-exec.svg
97
- [npm-url]: https://npmjs.org/package/visual-exec
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
@@ -21,7 +21,7 @@ export interface ProgressExtractor {
21
21
  total?: number;
22
22
  percent?: number;
23
23
  } | null;
24
- /** Display format for progress */
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
  /**
@@ -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(context, elapsed) {
17
- const err = new Error(`Command timed out after ${elapsed}ms: ${context.command}`);
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(context) {
23
- const err = new Error(`Command aborted: ${context.command}`);
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
- this._logger.updateItem(item.name, {
256
- msg: msgs.join(chalk.blue.inverse("\\n")),
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
- (_a = this._onComplete) === null || _a === void 0 ? void 0 : _a.call(this, output, exitCode);
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({ command: this._command, cwd: this._cwd, exitCode: -1, signal: "SIGTERM" }, this._timeout));
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
- const handleAbort = () => {
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.show(child);
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.10",
4
- "homepage": "https://github.com/jchip/fynjs/tree/main/packages/visual-exec",
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.9",
53
- "xsh": "^1.1.8"
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.7",
59
- "run-verify": "^2.1.8",
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"