visual-exec 1.0.1 → 1.0.2

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.
@@ -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;
@@ -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())
@@ -393,7 +459,12 @@ export class VisualExec {
393
459
  this._logger[level](`${chalk.green("No output")} from ${this._outputLabel}`);
394
460
  return;
395
461
  }
396
- const colorize = (t) => t.replace(/ERR!/g, chalk.red("ERR!"));
462
+ //
463
+ // The child's bytes end up on this terminal and in the saved log data, so scrub the
464
+ // control sequences out first - an error report that erases the lines above it is worse
465
+ // than useless. sanitizeForOutput runs before colorize, which only adds SGR.
466
+ //
467
+ const colorize = (t) => sanitizeForOutput(t).replace(/ERR!/g, chalk.red("ERR!"));
397
468
  const logs = [chalk.green(">>>"), `Start of output from ${this._outputLabel} ===`];
398
469
  if (output.stdout) {
399
470
  logs.push(`\n${colorize(output.stdout)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "visual-exec",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
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",
@@ -49,7 +49,7 @@
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",
52
+ "visual-logger": "^2.0.1",
53
53
  "xsh": "^1.0.1"
54
54
  },
55
55
  "sideEffects": false