forgedock 1.0.1 → 1.0.3

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.
Files changed (43) hide show
  1. package/README.md +19 -2
  2. package/bin/forgedock.mjs +3084 -80
  3. package/bin/github-app-manifest.json +24 -0
  4. package/bin/tui.mjs +702 -0
  5. package/commands/analytics.md +129 -52
  6. package/commands/audit-agents.md +29 -5
  7. package/commands/audit.md +55 -17
  8. package/commands/autopilot.md +11 -6
  9. package/commands/cleanup.md +44 -24
  10. package/commands/deploy-info.md +7 -3
  11. package/commands/forge-stats.md +13 -4
  12. package/commands/forgedock-init.md +534 -0
  13. package/commands/geo-audit.md +114 -26
  14. package/commands/incident-response.md +4 -1
  15. package/commands/issue.md +26 -25
  16. package/commands/milestone.md +80 -59
  17. package/commands/orchestrate.md +38 -21
  18. package/commands/pipeline-health.md +26 -15
  19. package/commands/qa-sweep.md +68 -5
  20. package/commands/quality-gate.md +18 -0
  21. package/commands/review-pr-agents.md +58 -90
  22. package/commands/review-pr.md +58 -16
  23. package/commands/rollback.md +8 -5
  24. package/commands/security-audit.md +68 -12
  25. package/commands/sync-ecosystem.md +196 -156
  26. package/commands/work-on/build/architect.md +2 -2
  27. package/commands/work-on/build/context.md +2 -2
  28. package/commands/work-on/build/implement.md +3 -3
  29. package/commands/work-on/build/validate.md +3 -3
  30. package/commands/work-on/build.md +6 -6
  31. package/commands/work-on/close.md +59 -30
  32. package/commands/work-on/decompose.md +6 -6
  33. package/commands/work-on/investigate.md +16 -20
  34. package/commands/work-on/review.md +4 -4
  35. package/commands/work-on-monolithic.md +15 -16
  36. package/commands/work-on.md +45 -33
  37. package/docs/CODEX.md +1 -1
  38. package/docs/CONFIG.md +398 -0
  39. package/docs/WORKFLOW.md +115 -72
  40. package/package.json +1 -1
  41. package/scripts/verify-host-headers.sh +14 -9
  42. package/scripts/verify-route-registration.sh +10 -10
  43. package/commands/failure-recon.md +0 -375
package/bin/tui.mjs ADDED
@@ -0,0 +1,702 @@
1
+ /**
2
+ * bin/tui.mjs — Zero-dependency TUI primitives for ForgeDock
3
+ *
4
+ * Provides: colors, box drawing, spinner, progress bar, prompts,
5
+ * step renderer, and table renderer using only Node.js built-in APIs.
6
+ *
7
+ * NO_COLOR support: set NO_COLOR=1 or run in a non-TTY environment
8
+ * to disable all ANSI escape codes. Unicode symbols (e.g. ●, ✔, ✖, ○, —) are
9
+ * not ANSI codes and remain in the output even when NO_COLOR is set — this is
10
+ * correct per the NO_COLOR specification (https://no-color.org/).
11
+ */
12
+
13
+ import readline from "readline";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // ANSI detection — computed once at module load
17
+ // ---------------------------------------------------------------------------
18
+
19
+ const USE_ANSI =
20
+ process.stdout.isTTY === true &&
21
+ !process.env.NO_COLOR &&
22
+ process.env.TERM !== "dumb";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Color helpers
26
+ // ---------------------------------------------------------------------------
27
+
28
+ function ansi(code) {
29
+ return USE_ANSI ? `\x1b[${code}m` : "";
30
+ }
31
+
32
+ /** Wrap text in an ANSI escape sequence (or return plain text if ANSI disabled). */
33
+ function wrap(text, open, close) {
34
+ if (!USE_ANSI) return String(text);
35
+ return `\x1b[${open}m${text}\x1b[${close}m`;
36
+ }
37
+
38
+ export const reset = ansi(0);
39
+ export const RESET = reset; // alias for forgedock.mjs compat
40
+
41
+ export function bold(text) {
42
+ return wrap(text, 1, 22);
43
+ }
44
+ export const BOLD = USE_ANSI ? "\x1b[1m" : ""; // constant alias
45
+
46
+ export function dim(text) {
47
+ return wrap(text, 2, 22);
48
+ }
49
+
50
+ export function underline(text) {
51
+ return wrap(text, 4, 24);
52
+ }
53
+
54
+ export function red(text) {
55
+ return wrap(text, 31, 39);
56
+ }
57
+ export const RED = USE_ANSI ? "\x1b[31m" : ""; // constant alias
58
+
59
+ export function green(text) {
60
+ return wrap(text, 32, 39);
61
+ }
62
+ export const GREEN = USE_ANSI ? "\x1b[32m" : ""; // constant alias
63
+
64
+ export function yellow(text) {
65
+ return wrap(text, 33, 39);
66
+ }
67
+ export const YELLOW = USE_ANSI ? "\x1b[33m" : ""; // constant alias
68
+
69
+ export function cyan(text) {
70
+ return wrap(text, 36, 39);
71
+ }
72
+ export const CYAN = USE_ANSI ? "\x1b[36m" : ""; // constant alias
73
+
74
+ export function magenta(text) {
75
+ return wrap(text, 35, 39);
76
+ }
77
+
78
+ export function white(text) {
79
+ return wrap(text, 37, 39);
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Box drawing
84
+ // ---------------------------------------------------------------------------
85
+
86
+ const BOX_CHARS = {
87
+ topLeft: "╭",
88
+ topRight: "╮",
89
+ bottomLeft: "╰",
90
+ bottomRight: "╯",
91
+ horizontal: "─",
92
+ vertical: "│",
93
+ };
94
+
95
+ /**
96
+ * Render a Unicode box around lines of text.
97
+ *
98
+ * @param {string|string[]} content - Text content (or array of lines)
99
+ * @param {object} [opts]
100
+ * @param {string} [opts.title] - Optional title shown in the top border
101
+ * @param {number} [opts.padding] - Horizontal padding inside box (default 1)
102
+ * @param {number} [opts.width] - Minimum inner width (auto-detected from content)
103
+ * @returns {string} Rendered box string (includes trailing newline)
104
+ */
105
+ export function box(content, { title = "", padding = 1, width } = {}) {
106
+ const lines = Array.isArray(content) ? content : String(content).split("\n");
107
+ const pad = " ".repeat(padding);
108
+
109
+ // Strip ANSI codes to compute visual width
110
+ const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
111
+
112
+ const contentWidth = lines.reduce(
113
+ (max, l) => Math.max(max, stripAnsi(l).length),
114
+ 0,
115
+ );
116
+ const titleLen = stripAnsi(title).length;
117
+ const innerWidth = Math.max(
118
+ width || 0,
119
+ contentWidth,
120
+ titleLen + (title ? 4 : 0),
121
+ 1,
122
+ );
123
+ const totalInner = innerWidth + padding * 2;
124
+
125
+ function hLine(leftChar, rightChar, fill) {
126
+ if (title) {
127
+ const t = ` ${title} `;
128
+ const tLen = stripAnsi(t).length;
129
+ const remaining = totalInner - tLen;
130
+ const left = Math.floor(remaining / 2);
131
+ const right = remaining - left;
132
+ return leftChar + fill.repeat(left) + t + fill.repeat(right) + rightChar;
133
+ }
134
+ return leftChar + fill.repeat(totalInner) + rightChar;
135
+ }
136
+
137
+ const top = hLine(
138
+ BOX_CHARS.topLeft,
139
+ BOX_CHARS.topRight,
140
+ BOX_CHARS.horizontal,
141
+ );
142
+ const bottom =
143
+ BOX_CHARS.bottomLeft +
144
+ BOX_CHARS.horizontal.repeat(totalInner) +
145
+ BOX_CHARS.bottomRight;
146
+
147
+ const body = lines.map((line) => {
148
+ const visual = stripAnsi(line).length;
149
+ const spaces = Math.max(0, innerWidth - visual);
150
+ return (
151
+ BOX_CHARS.vertical +
152
+ pad +
153
+ line +
154
+ " ".repeat(spaces) +
155
+ pad +
156
+ BOX_CHARS.vertical
157
+ );
158
+ });
159
+
160
+ return [top, ...body, bottom].join("\n") + "\n";
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Spinner
165
+ // ---------------------------------------------------------------------------
166
+
167
+ const BRAILLE_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
168
+
169
+ /**
170
+ * Create an async spinner.
171
+ *
172
+ * @param {string} message - Initial spinner message
173
+ * @param {object} [opts]
174
+ * @param {string[]} [opts.frames] - Custom animation frames
175
+ * @param {number} [opts.interval] - Frame interval ms (default 80)
176
+ * @returns {{ stop(status?, finalMsg?): void, update(msg): void }}
177
+ *
178
+ * Usage:
179
+ * const s = spinner('Loading…');
180
+ * // ... async work ...
181
+ * s.stop('success', 'Done!');
182
+ */
183
+ export function spinner(
184
+ message,
185
+ { frames = BRAILLE_FRAMES, interval = 80 } = {},
186
+ ) {
187
+ let current = message;
188
+ let frame = 0;
189
+ let stopped = false;
190
+
191
+ if (!USE_ANSI || !process.stderr.isTTY) {
192
+ // Non-TTY: print message once and return a no-op stopper
193
+ process.stderr.write(current + "\n");
194
+ return {
195
+ update(msg) {
196
+ current = msg;
197
+ process.stderr.write(msg + "\n");
198
+ },
199
+ stop(status, finalMsg) {
200
+ const text = finalMsg || current;
201
+ if (status === "success")
202
+ process.stderr.write(green("✔") + " " + text + "\n");
203
+ else if (status === "fail")
204
+ process.stderr.write(red("✖") + " " + text + "\n");
205
+ else process.stderr.write(text + "\n");
206
+ },
207
+ };
208
+ }
209
+
210
+ // Hide cursor
211
+ process.stderr.write("\x1b[?25l");
212
+
213
+ const timer = setInterval(() => {
214
+ if (stopped) return;
215
+ const f = frames[frame % frames.length];
216
+ frame++;
217
+ process.stderr.write(`\r${cyan(f)} ${current} `);
218
+ }, interval);
219
+
220
+ // Allow process to exit even if timer is still running
221
+ timer.unref();
222
+
223
+ function cleanup(status, finalMsg) {
224
+ if (stopped) return;
225
+ stopped = true;
226
+ clearInterval(timer);
227
+ // Clear current line and show cursor
228
+ process.stderr.write("\r\x1b[K");
229
+ process.stderr.write("\x1b[?25h");
230
+
231
+ const text = finalMsg || current;
232
+ if (status === "success") {
233
+ process.stderr.write(green("✔") + " " + text + "\n");
234
+ } else if (status === "fail") {
235
+ process.stderr.write(red("✖") + " " + text + "\n");
236
+ } else if (status === "warn") {
237
+ process.stderr.write(yellow("⚠") + " " + text + "\n");
238
+ } else if (text) {
239
+ process.stderr.write(text + "\n");
240
+ }
241
+ }
242
+
243
+ // SIGINT: clean up before exit so cursor is restored
244
+ const sigintHandler = () => {
245
+ cleanup(null, "");
246
+ process.removeListener("SIGINT", sigintHandler);
247
+ process.kill(process.pid, "SIGINT");
248
+ };
249
+ process.once("SIGINT", sigintHandler);
250
+
251
+ return {
252
+ /**
253
+ * Update the spinner message.
254
+ * @param {string} msg
255
+ */
256
+ update(msg) {
257
+ current = msg;
258
+ },
259
+
260
+ /**
261
+ * Stop the spinner.
262
+ * @param {'success'|'fail'|'warn'|null} [status]
263
+ * @param {string} [finalMsg] - Message to show on final line (defaults to current message)
264
+ */
265
+ stop(status = null, finalMsg) {
266
+ process.removeListener("SIGINT", sigintHandler);
267
+ cleanup(status, finalMsg);
268
+ },
269
+ };
270
+ }
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // Progress bar
274
+ // ---------------------------------------------------------------------------
275
+
276
+ /**
277
+ * Render a single-line progress bar string.
278
+ *
279
+ * @param {number} current - Items completed
280
+ * @param {number} total - Total items
281
+ * @param {object} [opts]
282
+ * @param {number} [opts.width] - Bar character width (default 20)
283
+ * @param {string} [opts.label] - Optional label prefix
284
+ * @returns {string} e.g. " [████████░░░░░░░░░░░░] 8/20 (40%)"
285
+ */
286
+ export function progressBar(current, total, { width = 20, label = "" } = {}) {
287
+ const pct = total === 0 ? 1 : Math.min(current / total, 1);
288
+ const filled = Math.round(pct * width);
289
+ const empty = width - filled;
290
+ const bar = "█".repeat(filled) + "░".repeat(empty);
291
+ const pctStr = `${Math.round(pct * 100)}%`;
292
+ const counts = `${current}/${total}`;
293
+ const prefix = label ? `${label} ` : "";
294
+ return `${prefix}[${USE_ANSI ? cyan(bar) : bar}] ${counts} (${pctStr})`;
295
+ }
296
+
297
+ /**
298
+ * Create a live progress bar that updates in-place on stderr.
299
+ *
300
+ * @param {number} total
301
+ * @param {object} [opts]
302
+ * @returns {{ tick(n?: number, msg?: string): void, done(msg?: string): void }}
303
+ */
304
+ export function createProgressBar(total, opts = {}) {
305
+ let current = 0;
306
+ let started = false;
307
+
308
+ function render(msg) {
309
+ const bar = progressBar(current, total, opts);
310
+ const suffix = msg ? ` ${dim(msg)}` : "";
311
+ if (USE_ANSI && process.stderr.isTTY) {
312
+ process.stderr.write(`\r${bar}${suffix} `);
313
+ } else {
314
+ process.stderr.write(bar + suffix + "\n");
315
+ }
316
+ }
317
+
318
+ return {
319
+ tick(n = 1, msg) {
320
+ current = Math.min(current + n, total);
321
+ render(msg);
322
+ started = true;
323
+ },
324
+ done(msg) {
325
+ current = total;
326
+ if (started && USE_ANSI && process.stderr.isTTY) {
327
+ process.stderr.write("\r\x1b[K"); // clear line
328
+ }
329
+ const bar = progressBar(total, total, opts);
330
+ const suffix = msg ? ` ${msg}` : "";
331
+ process.stderr.write(bar + suffix + "\n");
332
+ },
333
+ };
334
+ }
335
+
336
+ // ---------------------------------------------------------------------------
337
+ // Prompts
338
+ // ---------------------------------------------------------------------------
339
+
340
+ /**
341
+ * Create a readline interface for stdin prompt.
342
+ * Callers must guard with `process.stdin.isTTY` before invoking.
343
+ */
344
+ function makeRl() {
345
+ return readline.createInterface({
346
+ input: process.stdin,
347
+ output: process.stdout,
348
+ });
349
+ }
350
+
351
+ /**
352
+ * Ask a yes/no question.
353
+ *
354
+ * @param {string} message
355
+ * @param {boolean} [defaultValue=false]
356
+ * @returns {Promise<boolean>}
357
+ */
358
+ export function confirm(message, defaultValue = false) {
359
+ if (!process.stdin.isTTY) return Promise.resolve(defaultValue);
360
+
361
+ const hint = defaultValue ? "(Y/n)" : "(y/N)";
362
+ const rl = makeRl();
363
+
364
+ return new Promise((resolve) => {
365
+ rl.question(`${cyan("?")} ${message} ${dim(hint)} `, (answer) => {
366
+ rl.close();
367
+ const a = answer.trim().toLowerCase();
368
+ if (a === "") resolve(defaultValue);
369
+ else resolve(a === "y" || a === "yes");
370
+ });
371
+ });
372
+ }
373
+
374
+ /**
375
+ * Ask for text input.
376
+ *
377
+ * @param {string} message
378
+ * @param {string} [defaultValue=""]
379
+ * @returns {Promise<string>}
380
+ */
381
+ export function input(message, defaultValue = "") {
382
+ if (!process.stdin.isTTY) return Promise.resolve(defaultValue);
383
+
384
+ const hint = defaultValue ? ` ${dim(`(${defaultValue})`)}` : "";
385
+ const rl = makeRl();
386
+
387
+ return new Promise((resolve) => {
388
+ rl.question(`${cyan("?")} ${message}${hint} `, (answer) => {
389
+ rl.close();
390
+ const trimmed = answer.trim();
391
+ resolve(trimmed === "" ? defaultValue : trimmed);
392
+ });
393
+ });
394
+ }
395
+
396
+ /**
397
+ * Single-selection list with arrow-key navigation.
398
+ *
399
+ * @param {string} message
400
+ * @param {Array<string|{label:string, value:any}>} choices
401
+ * @param {object} [opts]
402
+ * @param {number} [opts.initialIndex=0]
403
+ * @returns {Promise<any>} The selected value (or choice string if choices are strings)
404
+ */
405
+ export function select(message, choices, { initialIndex = 0 } = {}) {
406
+ if (choices.length === 0) return Promise.resolve(undefined);
407
+ if (!process.stdin.isTTY) {
408
+ // Non-TTY: return first choice value
409
+ const first = choices[0];
410
+ return Promise.resolve(typeof first === "object" ? first.value : first);
411
+ }
412
+
413
+ const items = choices.map((c) =>
414
+ typeof c === "object" ? c : { label: String(c), value: c },
415
+ );
416
+
417
+ return new Promise((resolve) => {
418
+ let idx = Math.min(initialIndex, items.length - 1);
419
+
420
+ function render() {
421
+ // Move cursor up to redraw list if not first render
422
+ const lines = items.length + 1;
423
+ if (render._rendered) {
424
+ process.stdout.write(`\x1b[${lines}A`);
425
+ }
426
+ render._rendered = true;
427
+
428
+ const header = `${cyan("?")} ${message}\n`;
429
+ const body = items
430
+ .map((item, i) => {
431
+ const pointer = i === idx ? cyan("›") : " ";
432
+ const text = i === idx ? bold(item.label) : item.label;
433
+ return ` ${pointer} ${text}`;
434
+ })
435
+ .join("\n");
436
+ process.stdout.write(header + body + "\n");
437
+ }
438
+
439
+ render._rendered = false;
440
+ render();
441
+
442
+ // Enter raw mode
443
+ const wasRaw = process.stdin.isRaw;
444
+ try {
445
+ process.stdin.setRawMode(true);
446
+ } catch {
447
+ // Terminal doesn't support raw mode
448
+ resolve(items[idx].value);
449
+ return;
450
+ }
451
+ process.stdin.resume();
452
+ process.stdin.setEncoding("utf-8");
453
+
454
+ function cleanup(value) {
455
+ try {
456
+ process.stdin.setRawMode(wasRaw || false);
457
+ } catch {
458
+ /* ignore */
459
+ }
460
+ process.stdin.pause();
461
+ process.stdin.removeListener("data", onData);
462
+ // Clear the rendered list and show final selection
463
+ process.stdout.write(`\x1b[${items.length + 1}A\x1b[J`);
464
+ process.stdout.write(
465
+ `${cyan("✔")} ${message}: ${bold(items[idx].label)}\n`,
466
+ );
467
+ resolve(value);
468
+ }
469
+
470
+ function onData(key) {
471
+ if (key === "\u0003") {
472
+ // Ctrl+C
473
+ cleanup(null);
474
+ process.exit(130);
475
+ } else if (key === "\u001b[A" || key === "k") {
476
+ // Up arrow
477
+ idx = (idx - 1 + items.length) % items.length;
478
+ render();
479
+ } else if (key === "\u001b[B" || key === "j") {
480
+ // Down arrow
481
+ idx = (idx + 1) % items.length;
482
+ render();
483
+ } else if (key === "\r" || key === "\n") {
484
+ cleanup(items[idx].value);
485
+ }
486
+ }
487
+
488
+ process.stdin.on("data", onData);
489
+ });
490
+ }
491
+
492
+ /**
493
+ * Multi-selection list with Space to toggle, Enter to confirm.
494
+ *
495
+ * @param {string} message
496
+ * @param {Array<string|{label:string, value:any}>} choices
497
+ * @param {object} [opts]
498
+ * @param {number[]} [opts.initialSelected=[]] - Indices to pre-select
499
+ * @returns {Promise<any[]>} Array of selected values
500
+ */
501
+ export function multiSelect(message, choices, { initialSelected = [] } = {}) {
502
+ if (choices.length === 0) return Promise.resolve([]);
503
+ if (!process.stdin.isTTY) {
504
+ // Non-TTY: return pre-selected values or empty array
505
+ const items = choices.map((c) =>
506
+ typeof c === "object" ? c : { label: String(c), value: c },
507
+ );
508
+ return Promise.resolve(
509
+ initialSelected.map((i) => items[i]?.value).filter(Boolean),
510
+ );
511
+ }
512
+
513
+ const items = choices.map((c) =>
514
+ typeof c === "object" ? c : { label: String(c), value: c },
515
+ );
516
+
517
+ return new Promise((resolve) => {
518
+ let idx = 0;
519
+ const selected = new Set(initialSelected);
520
+
521
+ function render() {
522
+ const lines = items.length + 2; // header + items + hint
523
+ if (render._rendered) {
524
+ process.stdout.write(`\x1b[${lines}A`);
525
+ }
526
+ render._rendered = true;
527
+
528
+ const header = `${cyan("?")} ${message} ${dim("(Space to toggle, Enter to confirm)")}\n`;
529
+ const body = items
530
+ .map((item, i) => {
531
+ const pointer = i === idx ? cyan("›") : " ";
532
+ const checkbox = selected.has(i) ? cyan("◉") : "○";
533
+ const text = i === idx ? bold(item.label) : item.label;
534
+ return ` ${pointer} ${checkbox} ${text}`;
535
+ })
536
+ .join("\n");
537
+ const hint = `\n ${dim(`${selected.size} selected`)}`;
538
+ process.stdout.write(header + body + hint + "\n");
539
+ }
540
+
541
+ render._rendered = false;
542
+ render();
543
+
544
+ const wasRaw = process.stdin.isRaw;
545
+ try {
546
+ process.stdin.setRawMode(true);
547
+ } catch {
548
+ resolve([]);
549
+ return;
550
+ }
551
+ process.stdin.resume();
552
+ process.stdin.setEncoding("utf-8");
553
+
554
+ function cleanup() {
555
+ try {
556
+ process.stdin.setRawMode(wasRaw || false);
557
+ } catch {
558
+ /* ignore */
559
+ }
560
+ process.stdin.pause();
561
+ process.stdin.removeListener("data", onData);
562
+
563
+ const selectedValues = items
564
+ .filter((_, i) => selected.has(i))
565
+ .map((item) => item.value);
566
+
567
+ const selectedLabels = items
568
+ .filter((_, i) => selected.has(i))
569
+ .map((item) => item.label)
570
+ .join(", ");
571
+
572
+ const lines = items.length + 2;
573
+ process.stdout.write(`\x1b[${lines}A\x1b[J`);
574
+ process.stdout.write(
575
+ `${cyan("✔")} ${message}: ${bold(selectedLabels || "(none)")}\n`,
576
+ );
577
+ resolve(selectedValues);
578
+ }
579
+
580
+ function onData(key) {
581
+ if (key === "\u0003") {
582
+ cleanup();
583
+ process.exit(130);
584
+ } else if (key === "\u001b[A" || key === "k") {
585
+ idx = (idx - 1 + items.length) % items.length;
586
+ render();
587
+ } else if (key === "\u001b[B" || key === "j") {
588
+ idx = (idx + 1) % items.length;
589
+ render();
590
+ } else if (key === " ") {
591
+ if (selected.has(idx)) selected.delete(idx);
592
+ else selected.add(idx);
593
+ render();
594
+ } else if (key === "\r" || key === "\n") {
595
+ cleanup();
596
+ }
597
+ }
598
+
599
+ process.stdin.on("data", onData);
600
+ });
601
+ }
602
+
603
+ // ---------------------------------------------------------------------------
604
+ // Step renderer
605
+ // ---------------------------------------------------------------------------
606
+
607
+ /** Valid step statuses */
608
+ const STEP_STATUS = {
609
+ pending: dim("○"),
610
+ active: cyan("●"),
611
+ done: green("✔"),
612
+ failed: red("✖"),
613
+ skipped: dim("—"),
614
+ };
615
+
616
+ /**
617
+ * Render a numbered step header.
618
+ *
619
+ * @param {number} current - Current step number (1-based)
620
+ * @param {number} total - Total steps
621
+ * @param {string} label - Step description
622
+ * @param {'pending'|'active'|'done'|'failed'|'skipped'} [status='active']
623
+ * @returns {string} Formatted step header (no trailing newline)
624
+ *
625
+ * @remarks
626
+ * **NO_COLOR behavior**: When \`NO_COLOR\` is set, ANSI color and style codes are
627
+ * suppressed, but the Unicode status symbols (●, ✔, ✖, ○, —) remain in the
628
+ * output. This is correct per the NO_COLOR specification
629
+ * (https://no-color.org/), which only covers ANSI escape codes — not Unicode
630
+ * characters. Callers that require fully plain-text output must substitute
631
+ * their own ASCII symbols.
632
+ */
633
+ export function stepHeader(current, total, label, status = "active") {
634
+ const icon = STEP_STATUS[status] || STEP_STATUS.active;
635
+ const counter = dim(`Step ${current} of ${total}`);
636
+ const dash = dim(" — ");
637
+ const name =
638
+ status === "done" ? dim(label) : status === "active" ? bold(label) : label;
639
+ return `${icon} ${counter}${dash}${name}`;
640
+ }
641
+
642
+ // ---------------------------------------------------------------------------
643
+ // Table renderer
644
+ // ---------------------------------------------------------------------------
645
+
646
+ /**
647
+ * Render a column-aligned table.
648
+ *
649
+ * @param {string[][]} rows - 2D array of strings; first row is the header
650
+ * @param {object} [opts]
651
+ * @param {boolean} [opts.header=true] - Treat first row as header (bold + underline + separator)
652
+ * @param {number} [opts.padding=2] - Column padding (spaces between columns)
653
+ * @param {string} [opts.separator="─"] - Separator line character
654
+ * @returns {string} Rendered table string (includes trailing newline)
655
+ */
656
+ export function table(
657
+ rows,
658
+ { header = true, padding = 2, separator = "─" } = {},
659
+ ) {
660
+ if (!rows || rows.length === 0) return "";
661
+
662
+ const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
663
+
664
+ // Compute column widths
665
+ const colCount = Math.max(...rows.map((r) => r.length));
666
+ const widths = Array.from({ length: colCount }, (_, c) =>
667
+ Math.max(...rows.map((r) => (r[c] ? stripAnsi(String(r[c])).length : 0))),
668
+ );
669
+
670
+ function renderRow(row, isHeader) {
671
+ return row
672
+ .map((cell, c) => {
673
+ const text = String(cell ?? "");
674
+ const visual = stripAnsi(text).length;
675
+ const spaces = " ".repeat(Math.max(0, widths[c] - visual));
676
+ const formatted = isHeader ? bold(underline(text)) : text;
677
+ const pad = c < row.length - 1 ? spaces + " ".repeat(padding) : spaces;
678
+ return formatted + pad;
679
+ })
680
+ .join("")
681
+ .trimEnd();
682
+ }
683
+
684
+ const lines = [];
685
+ rows.forEach((row, i) => {
686
+ if (i === 0 && header) {
687
+ lines.push(renderRow(row, true));
688
+ const sepLine = widths.reduce((w, c, ci) => {
689
+ return (
690
+ w +
691
+ separator.repeat(c) +
692
+ (ci < widths.length - 1 ? " ".repeat(padding) : "")
693
+ );
694
+ }, "");
695
+ lines.push(dim(sepLine));
696
+ } else {
697
+ lines.push(renderRow(row, false));
698
+ }
699
+ });
700
+
701
+ return lines.join("\n") + "\n";
702
+ }