visual-exec 1.0.1 → 1.1.0

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/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { VisualExec, VisualExecOptions, ExecOutput, ExecErrorContext, VisualExecError, parsers, jsonLinesParser, keyValueParser } from "./visual-exec.js";
1
+ export { VisualExec, type VisualExecOptions, type ExecOutput, type ExecErrorContext, type VisualExecError, parsers, jsonLinesParser, keyValueParser } from "./visual-exec.js";
2
2
  export type { OnOutputCallback, OnCompleteCallback, OutputFileOptions, ProgressExtractor, OutputMatcher } from "./visual-exec.js";
3
3
  export { getDefaultLogger } from "./get-default-logger.js";
4
4
  export { VisualExec as default } from "./visual-exec.js";
@@ -1,4 +1,4 @@
1
- import VisualLogger from "visual-logger";
1
+ import VisualLogger, { type ItemOptions } from "visual-logger";
2
2
  export type OutputStream = "stdout" | "stderr";
3
3
  /** Called on each chunk of stdout/stderr during execution */
4
4
  export type OnOutputCallback = (data: string, stream: OutputStream) => void;
@@ -40,7 +40,7 @@ export interface VisualExecOptions {
40
40
  /** Visual logger instance to use */
41
41
  visualLogger?: VisualLogger;
42
42
  /** Spinner style to use */
43
- spinner?: any;
43
+ spinner?: ItemOptions["spinner"];
44
44
  /** Title displayed during execution */
45
45
  displayTitle?: string;
46
46
  /** Label used in log messages */
@@ -126,6 +126,27 @@ export declare const parsers: {
126
126
  jsonLines: typeof jsonLinesParser;
127
127
  keyValue: typeof keyValueParser;
128
128
  };
129
+ export interface SanitizeOptions {
130
+ /** "space" collapses tabs (single-line contexts), "keep" preserves them (dumps) */
131
+ tabs?: "space" | "keep";
132
+ /** what to do with a lone \r - "drop", or "newline" to keep progress redraws readable */
133
+ cr?: "drop" | "newline";
134
+ /** append an SGR reset when the text emitted colour, so it cannot bleed into later output */
135
+ resetAtEnd?: boolean;
136
+ }
137
+ /**
138
+ * Strip terminal control sequences from child output, keeping colour. Never use this for
139
+ * output written to a file or handed to a callback - those should get the bytes untouched.
140
+ */
141
+ export declare function sanitizeText(text: string, options?: SanitizeOptions): string;
142
+ /** Profile for text squeezed into a single progress line. */
143
+ export declare function sanitizeForDisplay(text: string): string;
144
+ /**
145
+ * Profile for a multi-line transcript written into scrollback: tabs are real formatting,
146
+ * carriage returns become line breaks so progress redraws stay readable, and a trailing
147
+ * reset stops an unterminated child colour bleeding into later output.
148
+ */
149
+ export declare function sanitizeForOutput(text: string): string;
129
150
  export declare class VisualExec {
130
151
  private _title;
131
152
  private _logLabel;
@@ -171,7 +192,14 @@ export declare class VisualExec {
171
192
  abort(signal?: string): void;
172
193
  /** Alias for abort() */
173
194
  kill(signal?: string): void;
174
- show(child: ChildProcess): Promise<ExecOutput | unknown>;
195
+ /**
196
+ * Attach to an already-started child and render its output.
197
+ *
198
+ * Resolves with the command's `ExecOutput`, unless an `onComplete` callback returned a
199
+ * value - that value is passed through instead. `T` names what the caller expects back;
200
+ * it defaults to `ExecOutput`, which is what a run without an `onComplete` produces.
201
+ */
202
+ show<T = ExecOutput>(child: ChildProcess): Promise<T>;
175
203
  logResult(err: VisualExecError | null, output?: ExecOutput): void;
176
204
  checkForErrors(text: string): RegExpMatchArray | null;
177
205
  /**
@@ -179,6 +207,12 @@ export declare class VisualExec {
179
207
  * Set to a no-op function to suppress output logging.
180
208
  */
181
209
  logFinalOutput(err: VisualExecError | null, output: ExecOutput): void;
182
- execute(command?: string): Promise<ExecOutput | unknown>;
210
+ /**
211
+ * Run the command and render its output.
212
+ *
213
+ * Resolves with `ExecOutput`, or with whatever an `onComplete` callback returned in its
214
+ * place. `T` defaults to `ExecOutput` so the common case needs no cast at the call site.
215
+ */
216
+ execute<T = ExecOutput>(command?: string): Promise<T>;
183
217
  }
184
218
  export default VisualExec;
@@ -3,7 +3,7 @@ import chalk from "chalk";
3
3
  import * as fs from "fs";
4
4
  import * as path from "path";
5
5
  import { getDefaultLogger } from "./get-default-logger.js";
6
- import VisualLogger from "visual-logger";
6
+ import VisualLogger, {} from "visual-logger";
7
7
  import hasAnsi from "has-ansi";
8
8
  import stripAnsi from "strip-ansi";
9
9
  // Set xsh to use native Promise
@@ -70,6 +70,70 @@ export const parsers = {
70
70
  jsonLines: jsonLinesParser,
71
71
  keyValue: keyValueParser
72
72
  };
73
+ //
74
+ // Child output is captured from a pipe and rendered back into this process's terminal. It
75
+ // can carry the child's own control sequences - a nested fyn renders its own visual logger
76
+ // and emits eraseLine/cursorUp runs. Those are zero-width to string-width, so line
77
+ // accounting stays correct while the terminal still *executes* them, walking the cursor up
78
+ // and erasing committed output.
79
+ //
80
+ // Keep SGR (colour); drop anything that can move the cursor, erase, scroll, switch charset,
81
+ // set the window title, or write the clipboard.
82
+ //
83
+ // The escape grammar, in the order the alternation must test it:
84
+ // CSI ESC [ params intermediates final
85
+ // OSC/DCS/PM/APC/SOS ESC ] P ^ _ X ... terminated by BEL or ST (or truncated at end)
86
+ // nF ESC intermediates(0x20-0x2F)+ final e.g. ESC(0, ESC#8, ESC%G
87
+ // Fp/Fe/Fs ESC single(0x30-0x7E) e.g. ESC7, ESCc, ESC M
88
+ //
89
+ const ESCAPE_SEQUENCE = new RegExp("\u001B(?:" +
90
+ "\\[[0-?]*[ -/]*[@-~]" +
91
+ "|[\\]P^_X][\\s\\S]*?(?:\\u0007|\u001B\\\\|$)" +
92
+ "|[ -/]+[0-~]" +
93
+ "|[0-~]" +
94
+ ")", "g");
95
+ const SGR_SEQUENCE = new RegExp("^\u001B\\[[0-?]*[ -/]*m$");
96
+ const HAS_SGR = new RegExp("\u001B\\[[0-?]*[ -/]*m");
97
+ const SGR_RESET = "\u001B[0m";
98
+ // 8-bit C1 controls: escapes without the ESC byte. 0x9B is an 8-bit CSI and 0x9D/0x90 are
99
+ // 8-bit OSC/DCS, so consume their payload too - dropping only the introducer would leave the
100
+ // parameters behind as inert but visible junk. Anything left is stripped bare.
101
+ const C1_SEQUENCE = /[\u009B][0-?]*[ -/]*[@-~]|[\u009D\u0090\u009E\u009F][\s\S]*?(?:\u0007|\u009C|$)/g;
102
+ const C1_CONTROL = /[\u0080-\u009F]/g;
103
+ // C0 controls that move the cursor or clear the screen. \n and \t are left to the profile,
104
+ // and \u001B is left alone - the only ones remaining are inside kept SGR sequences.
105
+ // eslint-disable-next-line no-control-regex
106
+ const UNSAFE_CONTROL = /[\u0000-\u0008\u000B-\u001A\u001C-\u001F\u007F]/g;
107
+ /**
108
+ * Strip terminal control sequences from child output, keeping colour. Never use this for
109
+ * output written to a file or handed to a callback - those should get the bytes untouched.
110
+ */
111
+ export function sanitizeText(text, options = {}) {
112
+ const { tabs = "space", cr = "drop", resetAtEnd = false } = options;
113
+ let out = text.replace(ESCAPE_SEQUENCE, seq => (SGR_SEQUENCE.test(seq) ? seq : ""));
114
+ // must run before UNSAFE_CONTROL, which would otherwise swallow the \r
115
+ if (cr === "newline") {
116
+ out = out.replace(/\r\n?/g, "\n");
117
+ }
118
+ out = out
119
+ .replace(/\t/g, tabs === "keep" ? "\t" : " ")
120
+ .replace(C1_SEQUENCE, "")
121
+ .replace(C1_CONTROL, "")
122
+ .replace(UNSAFE_CONTROL, "");
123
+ return resetAtEnd && HAS_SGR.test(out) ? out + SGR_RESET : out;
124
+ }
125
+ /** Profile for text squeezed into a single progress line. */
126
+ export function sanitizeForDisplay(text) {
127
+ return sanitizeText(text, { tabs: "space", cr: "drop" });
128
+ }
129
+ /**
130
+ * Profile for a multi-line transcript written into scrollback: tabs are real formatting,
131
+ * carriage returns become line breaks so progress redraws stay readable, and a trailing
132
+ * reset stops an unterminated child colour bleeding into later output.
133
+ */
134
+ export function sanitizeForOutput(text) {
135
+ return sanitizeText(text, { tabs: "keep", cr: "newline", resetAtEnd: true });
136
+ }
73
137
  export class VisualExec {
74
138
  constructor(options) {
75
139
  this._aborted = false;
@@ -157,7 +221,9 @@ export class VisualExec {
157
221
  this._onProgress(progress);
158
222
  }
159
223
  _updateDigest(item, buf) {
160
- const newBuf = item.buf + buf;
224
+ // sanitize at the boundary so every downstream use - lines, item.buf, the item message -
225
+ // is already inert. Raw output still reaches onOutput/outputFile via _createDataHandler.
226
+ const newBuf = item.buf + sanitizeForDisplay(buf);
161
227
  const lines = newBuf
162
228
  .split("\n")
163
229
  .map(x => x && x.trim())
@@ -228,6 +294,13 @@ export class VisualExec {
228
294
  kill(signal) {
229
295
  this.abort(signal);
230
296
  }
297
+ /**
298
+ * Attach to an already-started child and render its output.
299
+ *
300
+ * Resolves with the command's `ExecOutput`, unless an `onComplete` callback returned a
301
+ * value - that value is passed through instead. `T` names what the caller expects back;
302
+ * it defaults to `ExecOutput`, which is what a run without an `onComplete` produces.
303
+ */
231
304
  show(child) {
232
305
  this._stdoutKey = Symbol("visual-exec-stdout");
233
306
  this._stderrKey = Symbol("visual-exec-stderr");
@@ -270,10 +343,11 @@ export class VisualExec {
270
343
  const startTime = Date.now();
271
344
  let execPromise = child.promise
272
345
  .catch((err) => {
273
- var _a, _b, _c, _d;
274
- const output = (_a = err.output) !== null && _a !== void 0 ? _a : { stdout: "", stderr: "" };
275
- const exitCode = (_c = (_b = err.exitCode) !== null && _b !== void 0 ? _b : err.code) !== null && _c !== void 0 ? _c : 1;
276
- (_d = this._onComplete) === null || _d === void 0 ? void 0 : _d.call(this, output, exitCode);
346
+ var _a;
347
+ var _b, _c, _d;
348
+ const output = (_b = err.output) !== null && _b !== void 0 ? _b : { stdout: "", stderr: "" };
349
+ const exitCode = (_d = (_c = err.exitCode) !== null && _c !== void 0 ? _c : err.code) !== null && _d !== void 0 ? _d : 1;
350
+ (_a = this._onComplete) === null || _a === void 0 ? void 0 : _a.call(this, output, exitCode);
277
351
  this.logResult(err);
278
352
  throw err;
279
353
  })
@@ -352,6 +426,8 @@ export class VisualExec {
352
426
  });
353
427
  execPromise = Promise.race([execPromise, abortPromise]);
354
428
  }
429
+ // The runtime value is `ExecOutput` unless `onComplete` substituted its own; only the
430
+ // caller knows which, so the promise is handed back as the `T` they asked for.
355
431
  return execPromise.finally(cleanup);
356
432
  }
357
433
  logResult(err, output) {
@@ -393,7 +469,12 @@ export class VisualExec {
393
469
  this._logger[level](`${chalk.green("No output")} from ${this._outputLabel}`);
394
470
  return;
395
471
  }
396
- const colorize = (t) => t.replace(/ERR!/g, chalk.red("ERR!"));
472
+ //
473
+ // The child's bytes end up on this terminal and in the saved log data, so scrub the
474
+ // control sequences out first - an error report that erases the lines above it is worse
475
+ // than useless. sanitizeForOutput runs before colorize, which only adds SGR.
476
+ //
477
+ const colorize = (t) => sanitizeForOutput(t).replace(/ERR!/g, chalk.red("ERR!"));
397
478
  const logs = [chalk.green(">>>"), `Start of output from ${this._outputLabel} ===`];
398
479
  if (output.stdout) {
399
480
  logs.push(`\n${colorize(output.stdout)}`);
@@ -404,6 +485,12 @@ export class VisualExec {
404
485
  logs.push(chalk.blue("\n<<<"), `End of output from ${this._outputLabel} ---`);
405
486
  this._logger.prefix(false)[level](...logs);
406
487
  }
488
+ /**
489
+ * Run the command and render its output.
490
+ *
491
+ * Resolves with `ExecOutput`, or with whatever an `onComplete` callback returned in its
492
+ * place. `T` defaults to `ExecOutput` so the common case needs no cast at the call site.
493
+ */
407
494
  execute(command) {
408
495
  this._startTime = Date.now();
409
496
  this._aborted = false;
@@ -421,22 +508,20 @@ export class VisualExec {
421
508
  child: result.child
422
509
  };
423
510
  const duration = () => Date.now() - this._startTime;
424
- const baseContext = (output) => {
425
- var _a, _b, _c, _d;
426
- return ({
427
- command: cmd,
428
- cwd: this._cwd,
429
- duration: duration(),
430
- stdout: (_a = output === null || output === void 0 ? void 0 : output.stdout) !== null && _a !== void 0 ? _a : "",
431
- stderr: (_b = output === null || output === void 0 ? void 0 : output.stderr) !== null && _b !== void 0 ? _b : "",
432
- lastLines: lastLines([...((_c = output === null || output === void 0 ? void 0 : output.stdout) !== null && _c !== void 0 ? _c : "").split("\n"), ...((_d = output === null || output === void 0 ? void 0 : output.stderr) !== null && _d !== void 0 ? _d : "").split("\n")].join("\n"), DEFAULT_LAST_LINES)
433
- });
434
- };
511
+ const baseContext = (output) => { var _a, _b, _c, _d; return ({
512
+ command: cmd,
513
+ cwd: this._cwd,
514
+ duration: duration(),
515
+ stdout: (_a = output === null || output === void 0 ? void 0 : output.stdout) !== null && _a !== void 0 ? _a : "",
516
+ stderr: (_b = output === null || output === void 0 ? void 0 : output.stderr) !== null && _b !== void 0 ? _b : "",
517
+ lastLines: lastLines([...((_c = output === null || output === void 0 ? void 0 : output.stdout) !== null && _c !== void 0 ? _c : "").split("\n"), ...((_d = output === null || output === void 0 ? void 0 : output.stderr) !== null && _d !== void 0 ? _d : "").split("\n")].join("\n"), DEFAULT_LAST_LINES)
518
+ }); };
435
519
  child.promise = child.promise.catch((err) => {
436
- var _a, _b, _c, _d, _e;
437
- const output = (_a = err.output) !== null && _a !== void 0 ? _a : { stdout: "", stderr: "" };
438
- const exitCode = (_b = err.code) !== null && _b !== void 0 ? _b : 1;
439
- const signal = (_e = (_c = err.signal) !== null && _c !== void 0 ? _c : (((_d = result.child) === null || _d === void 0 ? void 0 : _d.killed) ? "SIGTERM" : null)) !== null && _e !== void 0 ? _e : null;
520
+ var _a;
521
+ var _b, _c, _d, _e;
522
+ const output = (_b = err.output) !== null && _b !== void 0 ? _b : { stdout: "", stderr: "" };
523
+ const exitCode = (_c = err.code) !== null && _c !== void 0 ? _c : 1;
524
+ const signal = (_e = (_d = err.signal) !== null && _d !== void 0 ? _d : (((_a = result.child) === null || _a === void 0 ? void 0 : _a.killed) ? "SIGTERM" : null)) !== null && _e !== void 0 ? _e : null;
440
525
  const context = {
441
526
  ...baseContext(output),
442
527
  exitCode,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "visual-exec",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "homepage": "https://github.com/jchip/fynjs/tree/main/packages/visual-exec",
5
5
  "description": "Visual shell exec with spinners and progress display",
6
6
  "type": "module",
@@ -14,12 +14,12 @@
14
14
  "./package.json": "./package.json"
15
15
  },
16
16
  "engines": {
17
- "node": ">=22.12.0"
17
+ "node": ">=22.18.0"
18
18
  },
19
19
  "scripts": {
20
20
  "build": "rm -rf dist && tsc -p tsconfig.json",
21
21
  "test": "vitest run",
22
- "ci:check": "tsc --noEmit -p tsconfig.json && npm test",
22
+ "ci:check": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.test.json && npm test",
23
23
  "test:watch": "vitest",
24
24
  "prepublishOnly": "xrun --serial build test",
25
25
  "postpack": "publish-util-postpack"
@@ -49,8 +49,8 @@
49
49
  "ci-info": "^4.4.0",
50
50
  "has-ansi": "^6.0.2",
51
51
  "strip-ansi": "^7.2.0",
52
- "visual-logger": "^2.0.0",
53
- "xsh": "^1.0.1"
52
+ "visual-logger": "^2.1.0",
53
+ "xsh": "^1.1.0"
54
54
  },
55
55
  "sideEffects": false
56
56
  }