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.
- package/README.md +19 -2
- package/bin/forgedock.mjs +3084 -80
- package/bin/github-app-manifest.json +24 -0
- package/bin/tui.mjs +702 -0
- package/commands/analytics.md +129 -52
- package/commands/audit-agents.md +29 -5
- package/commands/audit.md +55 -17
- package/commands/autopilot.md +11 -6
- package/commands/cleanup.md +44 -24
- package/commands/deploy-info.md +7 -3
- package/commands/forge-stats.md +13 -4
- package/commands/forgedock-init.md +534 -0
- package/commands/geo-audit.md +114 -26
- package/commands/incident-response.md +4 -1
- package/commands/issue.md +26 -25
- package/commands/milestone.md +80 -59
- package/commands/orchestrate.md +38 -21
- package/commands/pipeline-health.md +26 -15
- package/commands/qa-sweep.md +68 -5
- package/commands/quality-gate.md +18 -0
- package/commands/review-pr-agents.md +58 -90
- package/commands/review-pr.md +58 -16
- package/commands/rollback.md +8 -5
- package/commands/security-audit.md +68 -12
- package/commands/sync-ecosystem.md +196 -156
- package/commands/work-on/build/architect.md +2 -2
- package/commands/work-on/build/context.md +2 -2
- package/commands/work-on/build/implement.md +3 -3
- package/commands/work-on/build/validate.md +3 -3
- package/commands/work-on/build.md +6 -6
- package/commands/work-on/close.md +59 -30
- package/commands/work-on/decompose.md +6 -6
- package/commands/work-on/investigate.md +16 -20
- package/commands/work-on/review.md +4 -4
- package/commands/work-on-monolithic.md +15 -16
- package/commands/work-on.md +45 -33
- package/docs/CODEX.md +1 -1
- package/docs/CONFIG.md +398 -0
- package/docs/WORKFLOW.md +115 -72
- package/package.json +1 -1
- package/scripts/verify-host-headers.sh +14 -9
- package/scripts/verify-route-registration.sh +10 -10
- 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
|
+
}
|