@vlandoss/clibuddy 0.6.1-git-62447c5.0 → 0.6.2-git-e008b4d.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.mts +40 -1
- package/dist/index.mjs +211 -2
- package/package.json +3 -2
- package/src/colors.ts +2 -1
- package/src/index.ts +1 -0
- package/src/task-board.ts +309 -0
package/dist/index.d.mts
CHANGED
|
@@ -14,6 +14,7 @@ declare const palette: {
|
|
|
14
14
|
dim: import("ansis").Ansis;
|
|
15
15
|
highlight: import("ansis").Ansis;
|
|
16
16
|
success: import("ansis").Ansis;
|
|
17
|
+
error: import("ansis").Ansis;
|
|
17
18
|
label: (s: string) => string;
|
|
18
19
|
};
|
|
19
20
|
//#endregion
|
|
@@ -88,10 +89,48 @@ declare const resolvePackageBin: typeof _resolvePackageBin;
|
|
|
88
89
|
//#region src/shell/utils.d.ts
|
|
89
90
|
declare function isNonZeroExitError(value: unknown): value is NonZeroExitError;
|
|
90
91
|
//#endregion
|
|
92
|
+
//#region src/task-board.d.ts
|
|
93
|
+
type TaskOutcome = {
|
|
94
|
+
/** The task's verdict — typically the wrapped process's exit code === 0. */ok: boolean;
|
|
95
|
+
/**
|
|
96
|
+
* Output to flush, grouped under the task label, once the board settles. The
|
|
97
|
+
* board prints it verbatim whenever it's non-empty (pass or fail) — the caller
|
|
98
|
+
* decides what, if anything, to surface.
|
|
99
|
+
*/
|
|
100
|
+
detail?: string;
|
|
101
|
+
};
|
|
102
|
+
type BoardTask = {
|
|
103
|
+
/** Stable identifier rendered on the row, e.g. a package name. */label: string; /** Runs the task. Must resolve to a `TaskOutcome`; rejections render as failed. */
|
|
104
|
+
run: () => Promise<TaskOutcome>;
|
|
105
|
+
};
|
|
106
|
+
type BoardOptions = {
|
|
107
|
+
/** Section header printed above the rows, e.g. `tsc · 16 packages` (framed multi-row only). */title?: string;
|
|
108
|
+
/**
|
|
109
|
+
* Force the framed (`┌ │ └`) layout even for a single task. `rr check` sets
|
|
110
|
+
* this so its sections stay visually divided; a standalone single-task
|
|
111
|
+
* command leaves it unset and renders compactly. Defaults to `tasks.length > 1`.
|
|
112
|
+
*/
|
|
113
|
+
frame?: boolean;
|
|
114
|
+
};
|
|
115
|
+
type BoardResult = {
|
|
116
|
+
/** False when any task ended not-ok. */ok: boolean;
|
|
117
|
+
outcomes: TaskOutcome[];
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* Runs `tasks` in parallel and reports their progress as a board: one row per
|
|
121
|
+
* task with a live spinner that collapses to ✔/✖ on settle. On a TTY the rows
|
|
122
|
+
* update in place; otherwise (CI, pipes) each row prints once when it settles,
|
|
123
|
+
* keeping logs deterministic. After every task settles, each task's captured
|
|
124
|
+
* detail is flushed grouped under its label, followed by a one-line summary.
|
|
125
|
+
* Parallelism is never sacrificed — the renderer only reflects work that is
|
|
126
|
+
* already running.
|
|
127
|
+
*/
|
|
128
|
+
declare function runTaskBoard(tasks: BoardTask[], options?: BoardOptions): Promise<BoardResult>;
|
|
129
|
+
//#endregion
|
|
91
130
|
//#region src/text.d.ts
|
|
92
131
|
declare const text: {
|
|
93
132
|
vland: string;
|
|
94
133
|
version: (version: string) => string;
|
|
95
134
|
};
|
|
96
135
|
//#endregion
|
|
97
|
-
export { NonZeroExitError, Pkg, type Project, RunOptions, ShellOptions, ShellService, colorize, createPkg, createShellService, cwd, dirnameOf, filenameOf, hasTTY, isCI, isNonZeroExitError, palette, resolvePackageBin, run, text };
|
|
136
|
+
export { BoardOptions, BoardResult, BoardTask, NonZeroExitError, Pkg, type Project, RunOptions, ShellOptions, ShellService, TaskOutcome, colorize, createPkg, createShellService, cwd, dirnameOf, filenameOf, hasTTY, isCI, isNonZeroExitError, palette, resolvePackageBin, run, runTaskBoard, text };
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
-
import ansis, { bold, cyan, dim, green, italic, underline } from "ansis";
|
|
2
|
+
import ansis, { bold, cyan, dim, gray, green, italic, magenta, red, underline } from "ansis";
|
|
3
3
|
import { hasTTY, isCI } from "std-env";
|
|
4
4
|
import path, { dirname } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -21,6 +21,7 @@ const palette = {
|
|
|
21
21
|
dim,
|
|
22
22
|
highlight: cyan,
|
|
23
23
|
success: green,
|
|
24
|
+
error: red,
|
|
24
25
|
label: (s) => ansis.bgMagenta.black(s)
|
|
25
26
|
};
|
|
26
27
|
//#endregion
|
|
@@ -219,10 +220,218 @@ async function _resolvePackageBin(pkg, { from, binName }) {
|
|
|
219
220
|
}
|
|
220
221
|
const resolvePackageBin = memoize(_resolvePackageBin, { cacheKey: ([pkg, opts]) => `${pkg}|${opts.from}|${opts.binName ?? ""}` });
|
|
221
222
|
//#endregion
|
|
223
|
+
//#region src/task-board.ts
|
|
224
|
+
const FRAMES = [
|
|
225
|
+
"◒",
|
|
226
|
+
"◐",
|
|
227
|
+
"◓",
|
|
228
|
+
"◑"
|
|
229
|
+
];
|
|
230
|
+
const TICK_MS = 80;
|
|
231
|
+
const PASS = green("✔");
|
|
232
|
+
const FAIL = red("✖");
|
|
233
|
+
const SEP = palette.dim(" · ");
|
|
234
|
+
const BAR = gray("│");
|
|
235
|
+
const BAR_START = gray("┌");
|
|
236
|
+
const BAR_END = gray("└");
|
|
237
|
+
/** Failing output past this many lines is truncated with a "+N more" note. */
|
|
238
|
+
const MAX_DETAIL_LINES = 60;
|
|
239
|
+
/**
|
|
240
|
+
* Runs `tasks` in parallel and reports their progress as a board: one row per
|
|
241
|
+
* task with a live spinner that collapses to ✔/✖ on settle. On a TTY the rows
|
|
242
|
+
* update in place; otherwise (CI, pipes) each row prints once when it settles,
|
|
243
|
+
* keeping logs deterministic. After every task settles, each task's captured
|
|
244
|
+
* detail is flushed grouped under its label, followed by a one-line summary.
|
|
245
|
+
* Parallelism is never sacrificed — the renderer only reflects work that is
|
|
246
|
+
* already running.
|
|
247
|
+
*/
|
|
248
|
+
async function runTaskBoard(tasks, options = {}) {
|
|
249
|
+
const live = hasTTY && !isCI;
|
|
250
|
+
const framed = options.frame ?? false;
|
|
251
|
+
return live ? runLive(tasks, options, framed) : runStatic(tasks, options, framed);
|
|
252
|
+
}
|
|
253
|
+
/** A multi-row board gets a header line — `┌ title` when framed, a plain bold title otherwise. */
|
|
254
|
+
function writeTitle(out, options, framed, multi) {
|
|
255
|
+
if (!multi || !options.title) return;
|
|
256
|
+
out.write(framed ? `${BAR_START} ${palette.bold(options.title)}\n` : `${palette.bold(options.title)}\n`);
|
|
257
|
+
}
|
|
258
|
+
async function runLive(tasks, options, framed) {
|
|
259
|
+
const out = process.stderr;
|
|
260
|
+
const multi = tasks.length > 1;
|
|
261
|
+
writeTitle(out, options, framed, multi);
|
|
262
|
+
const rows = tasks.map((t) => ({
|
|
263
|
+
label: t.label,
|
|
264
|
+
startedAt: Date.now()
|
|
265
|
+
}));
|
|
266
|
+
const width = labelWidth(tasks);
|
|
267
|
+
const prefix = rowPrefix(framed, multi);
|
|
268
|
+
let frame = 0;
|
|
269
|
+
out.write("\x1B[?25l");
|
|
270
|
+
for (const _ of rows) out.write("\n");
|
|
271
|
+
const render = () => {
|
|
272
|
+
out.write(`\x1b[${rows.length}A`);
|
|
273
|
+
for (const row of rows) out.write(`\x1b[2K${renderRow(row, width, frame, prefix)}\n`);
|
|
274
|
+
};
|
|
275
|
+
const settled = Promise.allSettled(tasks.map(async (task, i) => {
|
|
276
|
+
const outcome = await runTask(task);
|
|
277
|
+
const row = rows[i];
|
|
278
|
+
row.finishedAt = Date.now();
|
|
279
|
+
row.outcome = outcome;
|
|
280
|
+
return outcome;
|
|
281
|
+
}));
|
|
282
|
+
try {
|
|
283
|
+
render();
|
|
284
|
+
while (rows.some((r) => !r.outcome)) {
|
|
285
|
+
await delay(TICK_MS);
|
|
286
|
+
frame = (frame + 1) % FRAMES.length;
|
|
287
|
+
render();
|
|
288
|
+
}
|
|
289
|
+
render();
|
|
290
|
+
} finally {
|
|
291
|
+
out.write("\x1B[?25h");
|
|
292
|
+
}
|
|
293
|
+
await settled;
|
|
294
|
+
return finish(rows, out, framed, multi);
|
|
295
|
+
}
|
|
296
|
+
async function runStatic(tasks, options, framed) {
|
|
297
|
+
const out = process.stderr;
|
|
298
|
+
const multi = tasks.length > 1;
|
|
299
|
+
writeTitle(out, options, framed, multi);
|
|
300
|
+
const width = labelWidth(tasks);
|
|
301
|
+
const prefix = rowPrefix(framed, multi);
|
|
302
|
+
const rows = await Promise.all(tasks.map(async (task) => {
|
|
303
|
+
const startedAt = Date.now();
|
|
304
|
+
const outcome = await runTask(task);
|
|
305
|
+
return {
|
|
306
|
+
label: task.label,
|
|
307
|
+
startedAt,
|
|
308
|
+
finishedAt: Date.now(),
|
|
309
|
+
outcome
|
|
310
|
+
};
|
|
311
|
+
}));
|
|
312
|
+
for (const row of rows) out.write(`${renderRow(row, width, 0, prefix)}\n`);
|
|
313
|
+
return finish(rows, out, framed, multi);
|
|
314
|
+
}
|
|
315
|
+
async function runTask(task) {
|
|
316
|
+
try {
|
|
317
|
+
return await task.run();
|
|
318
|
+
} catch (error) {
|
|
319
|
+
return {
|
|
320
|
+
ok: false,
|
|
321
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/** Flushes each task's detail, prints the summary (framed only), returns the result. */
|
|
326
|
+
function finish(rows, out, framed, multi) {
|
|
327
|
+
const outcomes = rows.map((r) => r.outcome ?? { ok: false });
|
|
328
|
+
const blocks = rows.map((row) => ({
|
|
329
|
+
ok: row.outcome?.ok ?? false,
|
|
330
|
+
label: row.label,
|
|
331
|
+
detail: clampDetail(row.outcome?.detail?.trim())
|
|
332
|
+
})).filter((b) => Boolean(b.detail));
|
|
333
|
+
const shared = blocks.length > 1 ? sharedLeadingLine(blocks.map((b) => b.detail)) : void 0;
|
|
334
|
+
const block = (text) => framed ? gutter(text) : indent(text);
|
|
335
|
+
const spacer = () => out.write(framed ? `${BAR}\n` : "\n");
|
|
336
|
+
let flushed = false;
|
|
337
|
+
if (shared) {
|
|
338
|
+
spacer();
|
|
339
|
+
out.write(`${block(shared)}\n`);
|
|
340
|
+
flushed = true;
|
|
341
|
+
}
|
|
342
|
+
for (const b of blocks) {
|
|
343
|
+
const rest = shared ? stripLeadingLine(b.detail, shared) : b.detail;
|
|
344
|
+
if (!rest.trim()) continue;
|
|
345
|
+
const body = b.ok ? palette.dim : void 0;
|
|
346
|
+
if (multi) {
|
|
347
|
+
spacer();
|
|
348
|
+
const header = b.ok ? palette.bold(b.label) : red(palette.bold(b.label));
|
|
349
|
+
out.write(`${block(header)}\n${framed ? gutter(rest, body) : indent(rest, body)}\n`);
|
|
350
|
+
} else out.write(`${framed ? gutter(rest, body) : indent(rest, body)}\n`);
|
|
351
|
+
flushed = true;
|
|
352
|
+
}
|
|
353
|
+
if (framed) {
|
|
354
|
+
if (flushed || multi) spacer();
|
|
355
|
+
out.write(`${BAR_END} ${summary(rows)}\n`);
|
|
356
|
+
} else if (multi) out.write(`\n${summary(rows)}\n`);
|
|
357
|
+
return {
|
|
358
|
+
ok: outcomes.every((o) => o.ok),
|
|
359
|
+
outcomes
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
/** The first line, if every block starts with the same one (e.g. an identical `$ <cmd>`). */
|
|
363
|
+
function sharedLeadingLine(details) {
|
|
364
|
+
const first = details[0]?.split("\n", 1)[0];
|
|
365
|
+
if (!first) return void 0;
|
|
366
|
+
return details.every((d) => d.split("\n", 1)[0] === first) ? first : void 0;
|
|
367
|
+
}
|
|
368
|
+
/** Drops `line` from the front of `detail` (with its trailing newline) if present. */
|
|
369
|
+
function stripLeadingLine(detail, line) {
|
|
370
|
+
return detail.startsWith(line) ? detail.slice(line.length).replace(/^\n/, "") : detail;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* The leading decoration for a row. Framed: `│` per row, or `┌` for a framed
|
|
374
|
+
* single task (status rides the opening corner). Unframed: a 2-space indent
|
|
375
|
+
* under the title for a multi-row board, nothing for a lone compact row.
|
|
376
|
+
*/
|
|
377
|
+
function rowPrefix(framed, multi) {
|
|
378
|
+
if (framed) return multi ? `${BAR} ` : `${BAR_START} `;
|
|
379
|
+
return multi ? " " : "";
|
|
380
|
+
}
|
|
381
|
+
function renderRow(row, width, frame, prefix) {
|
|
382
|
+
const label = padLabel(row.label, width);
|
|
383
|
+
if (!row.outcome) return `${prefix}${magenta(FRAMES[frame])} ${label}`;
|
|
384
|
+
const duration = row.finishedAt ? palette.dim(fmtDuration(row.finishedAt - row.startedAt)) : "";
|
|
385
|
+
return `${prefix}${row.outcome.ok ? PASS : FAIL} ${label}${duration ? ` ${duration}` : ""}`;
|
|
386
|
+
}
|
|
387
|
+
function summary(rows) {
|
|
388
|
+
const outcomes = rows.map((r) => r.outcome).filter((o) => Boolean(o));
|
|
389
|
+
const failed = outcomes.filter((o) => !o.ok).length;
|
|
390
|
+
const ok = outcomes.length - failed;
|
|
391
|
+
const elapsed = rows.length ? Math.max(...rows.map((r) => r.finishedAt ?? r.startedAt)) - Math.min(...rows.map((r) => r.startedAt)) : 0;
|
|
392
|
+
const parts = failed > 0 ? [`${failed} failed`, `${ok} ok`] : [`${ok} ok`];
|
|
393
|
+
parts.push(fmtDuration(elapsed));
|
|
394
|
+
return `${failed > 0 ? FAIL : PASS} ${parts.join(SEP)}`;
|
|
395
|
+
}
|
|
396
|
+
/** Caps long output so one broken package can't bury the board; the rest is one-lined. */
|
|
397
|
+
function clampDetail(text) {
|
|
398
|
+
if (!text) return text;
|
|
399
|
+
const lines = text.split("\n");
|
|
400
|
+
if (lines.length <= MAX_DETAIL_LINES) return text;
|
|
401
|
+
const hidden = lines.length - MAX_DETAIL_LINES;
|
|
402
|
+
return `${lines.slice(0, MAX_DETAIL_LINES).join("\n")}\n${palette.dim(`… +${hidden} more lines`)}`;
|
|
403
|
+
}
|
|
404
|
+
/** Prefixes every line of `text` with the gutter (keeping the side frame intact), optionally styling each line. */
|
|
405
|
+
function gutter(text, style) {
|
|
406
|
+
return text.split("\n").map((line) => `${BAR} ${style ? style(line) : line}`).join("\n");
|
|
407
|
+
}
|
|
408
|
+
/** Indents every line of `text` by two spaces (compact mode, no gutter), optionally styling each line. */
|
|
409
|
+
function indent(text, style) {
|
|
410
|
+
return text.split("\n").map((line) => ` ${style ? style(line) : line}`).join("\n");
|
|
411
|
+
}
|
|
412
|
+
function fmtDuration(ms) {
|
|
413
|
+
return ms < 1e3 ? `${Math.round(ms)}ms` : `${(ms / 1e3).toFixed(1)}s`;
|
|
414
|
+
}
|
|
415
|
+
function labelWidth(tasks) {
|
|
416
|
+
return tasks.reduce((max, t) => Math.max(max, visibleWidth(t.label)), 0);
|
|
417
|
+
}
|
|
418
|
+
/** Right-pads `label` to `width` printable columns, ignoring its ANSI escapes. */
|
|
419
|
+
function padLabel(label, width) {
|
|
420
|
+
return label + " ".repeat(Math.max(0, width - visibleWidth(label)));
|
|
421
|
+
}
|
|
422
|
+
const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
|
|
423
|
+
/** A string's printable column count — its length with SGR color escapes removed. */
|
|
424
|
+
function visibleWidth(text) {
|
|
425
|
+
return text.replace(ANSI, "").length;
|
|
426
|
+
}
|
|
427
|
+
function delay(ms) {
|
|
428
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
429
|
+
}
|
|
430
|
+
//#endregion
|
|
222
431
|
//#region src/text.ts
|
|
223
432
|
const text = {
|
|
224
433
|
vland: palette.link(palette.primary("https://variable.land")),
|
|
225
434
|
version: (version) => palette.muted(`v${version}`)
|
|
226
435
|
};
|
|
227
436
|
//#endregion
|
|
228
|
-
export { NonZeroExitError, Pkg, ShellService, colorize, createPkg, createShellService, cwd, dirnameOf, filenameOf, hasTTY, isCI, isNonZeroExitError, palette, resolvePackageBin, run, text };
|
|
437
|
+
export { NonZeroExitError, Pkg, ShellService, colorize, createPkg, createShellService, cwd, dirnameOf, filenameOf, hasTTY, isCI, isNonZeroExitError, palette, resolvePackageBin, run, runTaskBoard, text };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vlandoss/clibuddy",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2-git-e008b4d.0",
|
|
4
4
|
"description": "A helper library to create CLIs in Variable Land",
|
|
5
5
|
"homepage": "https://github.com/variableland/dx/tree/main/shared/clibuddy#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -43,10 +43,11 @@
|
|
|
43
43
|
"node": ">=20.0.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
|
-
"@rrlab/tsdown-config": "^0.
|
|
46
|
+
"@rrlab/tsdown-config": "^0.1.0"
|
|
47
47
|
},
|
|
48
48
|
"scripts": {
|
|
49
49
|
"build": "tsdown",
|
|
50
|
+
"test": "vitest run",
|
|
50
51
|
"test:types": "rr tsc"
|
|
51
52
|
}
|
|
52
53
|
}
|
package/src/colors.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import ansis, { bold, cyan, dim, green, italic, underline } from "ansis";
|
|
1
|
+
import ansis, { bold, cyan, dim, green, italic, red, underline } from "ansis";
|
|
2
2
|
|
|
3
3
|
// hex-from-string factory; matches the previous public API.
|
|
4
4
|
export const colorize = (hex: string) => ansis.hex(hex);
|
|
@@ -16,5 +16,6 @@ export const palette = {
|
|
|
16
16
|
// semantic
|
|
17
17
|
highlight: cyan,
|
|
18
18
|
success: green,
|
|
19
|
+
error: red,
|
|
19
20
|
label: (s: string) => ansis.bgMagenta.black(s),
|
|
20
21
|
};
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { gray, green, magenta, red } from "ansis";
|
|
2
|
+
import { palette } from "./colors.ts";
|
|
3
|
+
import { hasTTY, isCI } from "./env.ts";
|
|
4
|
+
|
|
5
|
+
export type TaskOutcome = {
|
|
6
|
+
/** The task's verdict — typically the wrapped process's exit code === 0. */
|
|
7
|
+
ok: boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Output to flush, grouped under the task label, once the board settles. The
|
|
10
|
+
* board prints it verbatim whenever it's non-empty (pass or fail) — the caller
|
|
11
|
+
* decides what, if anything, to surface.
|
|
12
|
+
*/
|
|
13
|
+
detail?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type BoardTask = {
|
|
17
|
+
/** Stable identifier rendered on the row, e.g. a package name. */
|
|
18
|
+
label: string;
|
|
19
|
+
/** Runs the task. Must resolve to a `TaskOutcome`; rejections render as failed. */
|
|
20
|
+
run: () => Promise<TaskOutcome>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type BoardOptions = {
|
|
24
|
+
/** Section header printed above the rows, e.g. `tsc · 16 packages` (framed multi-row only). */
|
|
25
|
+
title?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Force the framed (`┌ │ └`) layout even for a single task. `rr check` sets
|
|
28
|
+
* this so its sections stay visually divided; a standalone single-task
|
|
29
|
+
* command leaves it unset and renders compactly. Defaults to `tasks.length > 1`.
|
|
30
|
+
*/
|
|
31
|
+
frame?: boolean;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type BoardResult = {
|
|
35
|
+
/** False when any task ended not-ok. */
|
|
36
|
+
ok: boolean;
|
|
37
|
+
outcomes: TaskOutcome[];
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Frame + gutter mirror @clack/prompts (used by `rr plugins`) so the two flows
|
|
41
|
+
// read as one family: the ◒◐◓◑ spinner and the gray │ ┌ └ gutter. The settled
|
|
42
|
+
// glyph is ✔/✖ — the verdict is the tool's exit code, never parsed from output
|
|
43
|
+
// (we can't tell a clean "Found 0 warnings" trailer from a real warning without
|
|
44
|
+
// parsing, so we don't pretend to — the tool's own output says which it is).
|
|
45
|
+
// The gutter uses the 16-color `gray` (not a fixed hex) so it adapts to the
|
|
46
|
+
// terminal theme and degrades on non-truecolor surfaces / CI log viewers.
|
|
47
|
+
const FRAMES = ["◒", "◐", "◓", "◑"];
|
|
48
|
+
const TICK_MS = 80;
|
|
49
|
+
const PASS = green("✔");
|
|
50
|
+
const FAIL = red("✖");
|
|
51
|
+
const SEP = palette.dim(" · ");
|
|
52
|
+
const BAR = gray("│");
|
|
53
|
+
const BAR_START = gray("┌");
|
|
54
|
+
const BAR_END = gray("└");
|
|
55
|
+
/** Failing output past this many lines is truncated with a "+N more" note. */
|
|
56
|
+
const MAX_DETAIL_LINES = 60;
|
|
57
|
+
|
|
58
|
+
type RowState = {
|
|
59
|
+
label: string;
|
|
60
|
+
startedAt: number;
|
|
61
|
+
finishedAt?: number;
|
|
62
|
+
outcome?: TaskOutcome;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Runs `tasks` in parallel and reports their progress as a board: one row per
|
|
67
|
+
* task with a live spinner that collapses to ✔/✖ on settle. On a TTY the rows
|
|
68
|
+
* update in place; otherwise (CI, pipes) each row prints once when it settles,
|
|
69
|
+
* keeping logs deterministic. After every task settles, each task's captured
|
|
70
|
+
* detail is flushed grouped under its label, followed by a one-line summary.
|
|
71
|
+
* Parallelism is never sacrificed — the renderer only reflects work that is
|
|
72
|
+
* already running.
|
|
73
|
+
*/
|
|
74
|
+
export async function runTaskBoard(tasks: BoardTask[], options: BoardOptions = {}): Promise<BoardResult> {
|
|
75
|
+
const live = hasTTY && !isCI;
|
|
76
|
+
// The `┌ │ └` frame is reserved for composition — `rr check` sets `frame: true`
|
|
77
|
+
// to divide its sections. A standalone command never frames, even a monorepo
|
|
78
|
+
// run with many rows (it's still one command): it gets a plain title + summary.
|
|
79
|
+
const framed = options.frame ?? false;
|
|
80
|
+
return live ? runLive(tasks, options, framed) : runStatic(tasks, options, framed);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A multi-row board gets a header line — `┌ title` when framed, a plain bold title otherwise. */
|
|
84
|
+
function writeTitle(out: NodeJS.WriteStream, options: BoardOptions, framed: boolean, multi: boolean): void {
|
|
85
|
+
if (!multi || !options.title) return;
|
|
86
|
+
out.write(framed ? `${BAR_START} ${palette.bold(options.title)}\n` : `${palette.bold(options.title)}\n`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function runLive(tasks: BoardTask[], options: BoardOptions, framed: boolean): Promise<BoardResult> {
|
|
90
|
+
const out = process.stderr;
|
|
91
|
+
const multi = tasks.length > 1;
|
|
92
|
+
writeTitle(out, options, framed, multi);
|
|
93
|
+
|
|
94
|
+
const rows: RowState[] = tasks.map((t) => ({ label: t.label, startedAt: Date.now() }));
|
|
95
|
+
const width = labelWidth(tasks);
|
|
96
|
+
const prefix = rowPrefix(framed, multi);
|
|
97
|
+
let frame = 0;
|
|
98
|
+
|
|
99
|
+
out.write("\x1b[?25l"); // hide cursor
|
|
100
|
+
for (const _ of rows) out.write("\n"); // reserve one line per row
|
|
101
|
+
|
|
102
|
+
const render = () => {
|
|
103
|
+
out.write(`\x1b[${rows.length}A`); // jump to the first row
|
|
104
|
+
for (const row of rows) out.write(`\x1b[2K${renderRow(row, width, frame, prefix)}\n`);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const settled = Promise.allSettled(
|
|
108
|
+
tasks.map(async (task, i) => {
|
|
109
|
+
const outcome = await runTask(task);
|
|
110
|
+
// biome-ignore lint/style/noNonNullAssertion: rows mirror tasks 1:1
|
|
111
|
+
const row = rows[i]!;
|
|
112
|
+
row.finishedAt = Date.now();
|
|
113
|
+
row.outcome = outcome;
|
|
114
|
+
return outcome;
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
render();
|
|
120
|
+
while (rows.some((r) => !r.outcome)) {
|
|
121
|
+
await delay(TICK_MS);
|
|
122
|
+
frame = (frame + 1) % FRAMES.length;
|
|
123
|
+
render();
|
|
124
|
+
}
|
|
125
|
+
render(); // final frame with every row collapsed
|
|
126
|
+
} finally {
|
|
127
|
+
out.write("\x1b[?25h"); // restore cursor
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
await settled;
|
|
131
|
+
return finish(rows, out, framed, multi);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function runStatic(tasks: BoardTask[], options: BoardOptions, framed: boolean): Promise<BoardResult> {
|
|
135
|
+
const out = process.stderr;
|
|
136
|
+
const multi = tasks.length > 1;
|
|
137
|
+
writeTitle(out, options, framed, multi);
|
|
138
|
+
|
|
139
|
+
const width = labelWidth(tasks);
|
|
140
|
+
const prefix = rowPrefix(framed, multi);
|
|
141
|
+
const rows: RowState[] = await Promise.all(
|
|
142
|
+
tasks.map(async (task) => {
|
|
143
|
+
const startedAt = Date.now();
|
|
144
|
+
const outcome = await runTask(task);
|
|
145
|
+
return { label: task.label, startedAt, finishedAt: Date.now(), outcome };
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// Print in input order so non-TTY logs are deterministic.
|
|
150
|
+
for (const row of rows) out.write(`${renderRow(row, width, 0, prefix)}\n`);
|
|
151
|
+
return finish(rows, out, framed, multi);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function runTask(task: BoardTask): Promise<TaskOutcome> {
|
|
155
|
+
try {
|
|
156
|
+
return await task.run();
|
|
157
|
+
} catch (error) {
|
|
158
|
+
return { ok: false, detail: error instanceof Error ? error.message : String(error) };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Flushes each task's detail, prints the summary (framed only), returns the result. */
|
|
163
|
+
function finish(rows: RowState[], out: NodeJS.WriteStream, framed: boolean, multi: boolean): BoardResult {
|
|
164
|
+
const outcomes = rows.map((r) => r.outcome ?? { ok: false });
|
|
165
|
+
|
|
166
|
+
const blocks = rows
|
|
167
|
+
.map((row) => ({ ok: row.outcome?.ok ?? false, label: row.label, detail: clampDetail(row.outcome?.detail?.trim()) }))
|
|
168
|
+
.filter((b): b is { ok: boolean; label: string; detail: string } => Boolean(b.detail));
|
|
169
|
+
|
|
170
|
+
// Hoist a line shared by every block — typically the identical `$ <cmd>` each
|
|
171
|
+
// package ran — so a monorepo shows the command once instead of per package.
|
|
172
|
+
const shared = blocks.length > 1 ? sharedLeadingLine(blocks.map((b) => b.detail)) : undefined;
|
|
173
|
+
|
|
174
|
+
// Framed sections lay their body inside the gutter (`│`); a plain board (no
|
|
175
|
+
// frame) indents it. The spacer above each is the gutter bar or a blank line.
|
|
176
|
+
const block = (text: string) => (framed ? gutter(text) : indent(text));
|
|
177
|
+
const spacer = () => out.write(framed ? `${BAR}\n` : "\n");
|
|
178
|
+
|
|
179
|
+
let flushed = false;
|
|
180
|
+
if (shared) {
|
|
181
|
+
spacer();
|
|
182
|
+
out.write(`${block(shared)}\n`); // already dim (it's the command), shown once
|
|
183
|
+
flushed = true;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// A passing task's output is dimmed — it's the tool's proof-of-work that
|
|
187
|
+
// should recede but stay visible; a failing task keeps full brightness so the
|
|
188
|
+
// diagnostic reads. A per-task header keeps each block attributable, except a
|
|
189
|
+
// single task (the row above already names it).
|
|
190
|
+
for (const b of blocks) {
|
|
191
|
+
const rest = shared ? stripLeadingLine(b.detail, shared) : b.detail;
|
|
192
|
+
if (!rest.trim()) continue; // only the shared command → nothing package-specific
|
|
193
|
+
const body = b.ok ? palette.dim : undefined;
|
|
194
|
+
if (multi) {
|
|
195
|
+
spacer();
|
|
196
|
+
const header = b.ok ? palette.bold(b.label) : red(palette.bold(b.label));
|
|
197
|
+
out.write(`${block(header)}\n${framed ? gutter(rest, body) : indent(rest, body)}\n`);
|
|
198
|
+
} else {
|
|
199
|
+
out.write(`${framed ? gutter(rest, body) : indent(rest, body)}\n`);
|
|
200
|
+
}
|
|
201
|
+
flushed = true;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Summary closes a framed section (└) or a plain multi-row board (a single
|
|
205
|
+
// command's compact output needs none — the row already carried the verdict).
|
|
206
|
+
if (framed) {
|
|
207
|
+
if (flushed || multi) spacer();
|
|
208
|
+
out.write(`${BAR_END} ${summary(rows)}\n`);
|
|
209
|
+
} else if (multi) {
|
|
210
|
+
out.write(`\n${summary(rows)}\n`);
|
|
211
|
+
}
|
|
212
|
+
return { ok: outcomes.every((o) => o.ok), outcomes };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The first line, if every block starts with the same one (e.g. an identical `$ <cmd>`). */
|
|
216
|
+
function sharedLeadingLine(details: string[]): string | undefined {
|
|
217
|
+
const first = details[0]?.split("\n", 1)[0];
|
|
218
|
+
if (!first) return undefined;
|
|
219
|
+
return details.every((d) => d.split("\n", 1)[0] === first) ? first : undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Drops `line` from the front of `detail` (with its trailing newline) if present. */
|
|
223
|
+
function stripLeadingLine(detail: string, line: string): string {
|
|
224
|
+
return detail.startsWith(line) ? detail.slice(line.length).replace(/^\n/, "") : detail;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The leading decoration for a row. Framed: `│` per row, or `┌` for a framed
|
|
229
|
+
* single task (status rides the opening corner). Unframed: a 2-space indent
|
|
230
|
+
* under the title for a multi-row board, nothing for a lone compact row.
|
|
231
|
+
*/
|
|
232
|
+
function rowPrefix(framed: boolean, multi: boolean): string {
|
|
233
|
+
if (framed) return multi ? `${BAR} ` : `${BAR_START} `;
|
|
234
|
+
return multi ? " " : "";
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function renderRow(row: RowState, width: number, frame: number, prefix: string): string {
|
|
238
|
+
// Pad by visible width so colored labels (e.g. a tool's branded ui) still
|
|
239
|
+
// align — `padEnd` would count the invisible ANSI bytes.
|
|
240
|
+
const label = padLabel(row.label, width);
|
|
241
|
+
if (!row.outcome) return `${prefix}${magenta(FRAMES[frame])} ${label}`;
|
|
242
|
+
const duration = row.finishedAt ? palette.dim(fmtDuration(row.finishedAt - row.startedAt)) : "";
|
|
243
|
+
return `${prefix}${row.outcome.ok ? PASS : FAIL} ${label}${duration ? ` ${duration}` : ""}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function summary(rows: RowState[]): string {
|
|
247
|
+
const outcomes = rows.map((r) => r.outcome).filter((o): o is TaskOutcome => Boolean(o));
|
|
248
|
+
const failed = outcomes.filter((o) => !o.ok).length;
|
|
249
|
+
const ok = outcomes.length - failed;
|
|
250
|
+
// Wall-clock span (first task started → last settled), not a single task's
|
|
251
|
+
// time. Guard the empty board so Math.min/max don't yield ±Infinity.
|
|
252
|
+
const elapsed = rows.length
|
|
253
|
+
? Math.max(...rows.map((r) => r.finishedAt ?? r.startedAt)) - Math.min(...rows.map((r) => r.startedAt))
|
|
254
|
+
: 0;
|
|
255
|
+
|
|
256
|
+
const parts = failed > 0 ? [`${failed} failed`, `${ok} ok`] : [`${ok} ok`];
|
|
257
|
+
parts.push(fmtDuration(elapsed));
|
|
258
|
+
return `${failed > 0 ? FAIL : PASS} ${parts.join(SEP)}`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Caps long output so one broken package can't bury the board; the rest is one-lined. */
|
|
262
|
+
function clampDetail(text: string | undefined): string | undefined {
|
|
263
|
+
if (!text) return text;
|
|
264
|
+
const lines = text.split("\n");
|
|
265
|
+
if (lines.length <= MAX_DETAIL_LINES) return text;
|
|
266
|
+
const hidden = lines.length - MAX_DETAIL_LINES;
|
|
267
|
+
return `${lines.slice(0, MAX_DETAIL_LINES).join("\n")}\n${palette.dim(`… +${hidden} more lines`)}`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Prefixes every line of `text` with the gutter (keeping the side frame intact), optionally styling each line. */
|
|
271
|
+
function gutter(text: string, style?: (line: string) => string): string {
|
|
272
|
+
return text
|
|
273
|
+
.split("\n")
|
|
274
|
+
.map((line) => `${BAR} ${style ? style(line) : line}`)
|
|
275
|
+
.join("\n");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Indents every line of `text` by two spaces (compact mode, no gutter), optionally styling each line. */
|
|
279
|
+
function indent(text: string, style?: (line: string) => string): string {
|
|
280
|
+
return text
|
|
281
|
+
.split("\n")
|
|
282
|
+
.map((line) => ` ${style ? style(line) : line}`)
|
|
283
|
+
.join("\n");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function fmtDuration(ms: number): string {
|
|
287
|
+
return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function labelWidth(tasks: BoardTask[]): number {
|
|
291
|
+
return tasks.reduce((max, t) => Math.max(max, visibleWidth(t.label)), 0);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Right-pads `label` to `width` printable columns, ignoring its ANSI escapes. */
|
|
295
|
+
function padLabel(label: string, width: number): string {
|
|
296
|
+
return label + " ".repeat(Math.max(0, width - visibleWidth(label)));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// SGR color escapes (`ESC [ … m`); built via fromCharCode so no literal control char in source.
|
|
300
|
+
const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
|
|
301
|
+
|
|
302
|
+
/** A string's printable column count — its length with SGR color escapes removed. */
|
|
303
|
+
function visibleWidth(text: string): number {
|
|
304
|
+
return text.replace(ANSI, "").length;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function delay(ms: number): Promise<void> {
|
|
308
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
309
|
+
}
|