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