@sdxc/cli 0.0.0-stage → 2026.10.16

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 (55) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +363 -2
  3. package/dist/argument.d.ts +120 -0
  4. package/dist/argument.js +104 -0
  5. package/dist/cli.d.ts +68 -0
  6. package/dist/cli.js +186 -0
  7. package/dist/command.d.ts +139 -0
  8. package/dist/command.js +79 -0
  9. package/dist/context.d.ts +138 -0
  10. package/dist/context.js +175 -0
  11. package/dist/errors.d.ts +67 -0
  12. package/dist/errors.js +82 -0
  13. package/dist/exec.d.ts +83 -0
  14. package/dist/exec.js +136 -0
  15. package/dist/flag.d.ts +175 -0
  16. package/dist/flag.js +91 -0
  17. package/dist/handler.d.ts +73 -0
  18. package/dist/handler.js +30 -0
  19. package/dist/help.d.ts +18 -0
  20. package/dist/help.js +141 -0
  21. package/dist/index.d.ts +35 -0
  22. package/dist/index.js +20 -0
  23. package/dist/interactions.d.ts +27 -0
  24. package/dist/interactions.js +373 -0
  25. package/dist/keys.d.ts +49 -0
  26. package/dist/keys.js +146 -0
  27. package/dist/lazy.d.ts +29 -0
  28. package/dist/lazy.js +23 -0
  29. package/dist/middleware.d.ts +78 -0
  30. package/dist/middleware.js +9 -0
  31. package/dist/parse.d.ts +38 -0
  32. package/dist/parse.js +218 -0
  33. package/dist/prompt.d.ts +153 -0
  34. package/dist/prompt.js +53 -0
  35. package/dist/resolve.d.ts +27 -0
  36. package/dist/resolve.js +294 -0
  37. package/dist/schema.d.ts +27 -0
  38. package/dist/schema.js +34 -0
  39. package/dist/screen.d.ts +42 -0
  40. package/dist/screen.js +114 -0
  41. package/dist/session.d.ts +84 -0
  42. package/dist/session.js +219 -0
  43. package/dist/style.d.ts +50 -0
  44. package/dist/style.js +72 -0
  45. package/dist/suggest.d.ts +15 -0
  46. package/dist/suggest.js +50 -0
  47. package/dist/task.d.ts +47 -0
  48. package/dist/task.js +213 -0
  49. package/dist/terminal.d.ts +21 -0
  50. package/dist/terminal.js +101 -0
  51. package/dist/test.d.ts +89 -0
  52. package/dist/test.js +140 -0
  53. package/dist/tree.d.ts +58 -0
  54. package/dist/tree.js +97 -0
  55. package/package.json +40 -5
@@ -0,0 +1,219 @@
1
+ /**
2
+ * One run's process context: its working directory, environment and streams, whether it may
3
+ * ask questions, the terminal owner tasks and prompts draw through, and the signal Ctrl+C
4
+ * aborts. Built from `cli.run`'s options, each defaulting to the process's own.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { failure, isFailure } from "@sdxc/result";
10
+ import { CancelledError } from "./errors.js";
11
+ import { spawnExec } from "./exec.js";
12
+ import { interaction } from "./interactions.js";
13
+ import { isCancel, openKeyboard } from "./keys.js";
14
+ import { Screen } from "./screen.js";
15
+ import { colorEnabled, createStyle } from "./style.js";
16
+ import { Tasks } from "./task.js";
17
+ /** Whether the environment asks for plain task output: in CI or on a dumb terminal. */
18
+ function plainProgress(env) {
19
+ let ci = env.CI;
20
+ return (ci !== undefined && ci !== "" && ci !== "0" && ci !== "false") || env.TERM === "dumb";
21
+ }
22
+ /** The process's environment as a plain record. */
23
+ function processEnv() {
24
+ return { ...process.env };
25
+ }
26
+ /**
27
+ * Opens the session for one run.
28
+ *
29
+ * @param noProgress - Whether `--no-progress` was passed
30
+ * @param script - How a test answers prompts; `undefined` for a person at a terminal
31
+ */
32
+ export function openSession(io, noProgress, script) {
33
+ let env = io.env ?? processEnv();
34
+ let stdin = io.stdin ?? process.stdin;
35
+ let stdout = io.stdout ?? process.stdout;
36
+ let stderr = io.stderr ?? process.stderr;
37
+ let controller = new AbortController();
38
+ let unlink = [];
39
+ if (io.signal !== undefined) {
40
+ let forward = () => controller.abort(new CancelledError());
41
+ if (io.signal.aborted)
42
+ forward();
43
+ else
44
+ io.signal.addEventListener("abort", forward, { once: true });
45
+ unlink.push(() => io.signal?.removeEventListener("abort", forward));
46
+ }
47
+ let screen = new Screen(stderr);
48
+ let raw = false;
49
+ let restore = () => {
50
+ screen.restore();
51
+ if (raw)
52
+ stdin.setRawMode?.(false);
53
+ raw = false;
54
+ };
55
+ if (io.signal === undefined && io.stderr === undefined) {
56
+ let onSignal = (name) => () => {
57
+ if (controller.signal.aborted) {
58
+ restore();
59
+ process.exit(name === "SIGINT" ? 130 : 143);
60
+ }
61
+ controller.abort(new CancelledError());
62
+ };
63
+ let onInt = onSignal("SIGINT");
64
+ let onTerm = onSignal("SIGTERM");
65
+ process.on("SIGINT", onInt);
66
+ process.on("SIGTERM", onTerm);
67
+ process.on("exit", restore);
68
+ unlink.push(() => {
69
+ process.off("SIGINT", onInt);
70
+ process.off("SIGTERM", onTerm);
71
+ process.off("exit", restore);
72
+ });
73
+ }
74
+ let terminal = stdin.isTTY === true && stderr.isTTY === true;
75
+ let interactive = script === undefined ? terminal : script.interactive;
76
+ let animate = stderr.isTTY === true && !noProgress && !plainProgress(env);
77
+ let stderrStyle = createStyle(colorEnabled(stderr, env));
78
+ let style = Object.assign(createStyle(colorEnabled(stdout, env)), { stderr: stderrStyle });
79
+ let tasks = new Tasks(screen, stderrStyle, animate, controller.signal);
80
+ let exec = io.exec ?? spawnExec;
81
+ let answered = 0;
82
+ let cwd = io.cwd ?? process.cwd();
83
+ /** A scripted answer for `request`, or `undefined` when the script has none. */
84
+ let scripted = (request) => {
85
+ let answers = script?.answers;
86
+ if (answers === undefined)
87
+ return undefined;
88
+ if (Array.isArray(answers)) {
89
+ if (answered >= answers.length)
90
+ return undefined;
91
+ answered += 1;
92
+ return { value: answers[answered - 1] };
93
+ }
94
+ let byName = answers;
95
+ for (let name of request.names) {
96
+ if (Object.hasOwn(byName, name))
97
+ return { value: byName[name] };
98
+ }
99
+ return undefined;
100
+ };
101
+ /** The line an answered prompt leaves behind. */
102
+ let summaryLine = (request, answer) => {
103
+ let shown = interaction(request.kind, request.options, request.initial).summary(answer);
104
+ let message = stderrStyle.bold(request.options.message);
105
+ return `${stderrStyle.green("✓")} ${message}${shown === "" ? "" : ` ${stderrStyle.dim(shown)}`}\n`;
106
+ };
107
+ /** Asks on the terminal, key by key, until an answer passes its check or the user cancels. */
108
+ let converse = async (request, keyboard) => {
109
+ let state = interaction(request.kind, request.options, request.initial);
110
+ let issue;
111
+ let header = `${stderrStyle.cyan("?")} ${stderrStyle.bold(request.options.message)}`;
112
+ screen.hold();
113
+ try {
114
+ for (;;) {
115
+ let lines = state.render(stderrStyle, header);
116
+ if (issue !== undefined)
117
+ lines.push(stderrStyle.red(` ${issue}`));
118
+ screen.paint(lines);
119
+ let key = await keyboard.read(controller.signal);
120
+ if (key === null) {
121
+ screen.paint([]);
122
+ if (controller.signal.aborted)
123
+ return failure(new CancelledError());
124
+ return failure(new Error(`No more keys while asking ${request.label}`));
125
+ }
126
+ if (isCancel(key)) {
127
+ screen.paint([]);
128
+ return failure(new CancelledError());
129
+ }
130
+ let step = state.key(key);
131
+ if (step === undefined) {
132
+ issue = undefined;
133
+ continue;
134
+ }
135
+ if ("error" in step) {
136
+ issue = step.error;
137
+ continue;
138
+ }
139
+ let checked = await request.check(step.submit);
140
+ if (isFailure(checked)) {
141
+ issue = checked.error.message;
142
+ continue;
143
+ }
144
+ screen.paint([]);
145
+ stderr.write(summaryLine(request, step.submit));
146
+ return checked;
147
+ }
148
+ }
149
+ finally {
150
+ screen.release();
151
+ }
152
+ };
153
+ return {
154
+ cwd,
155
+ env,
156
+ stdin,
157
+ stdout,
158
+ stderr,
159
+ style,
160
+ screen,
161
+ tasks,
162
+ signal: controller.signal,
163
+ interactive,
164
+ animate,
165
+ async ask(request) {
166
+ if (controller.signal.aborted)
167
+ return failure(new CancelledError());
168
+ if (script !== undefined && script.keyboard === undefined) {
169
+ let answer = scripted(request);
170
+ if (answer === undefined) {
171
+ return failure(new Error(`invoke() has no scripted answer for ${request.label} ("${request.options.message}")`));
172
+ }
173
+ let checked = await request.check(answer.value);
174
+ if (isFailure(checked)) {
175
+ return failure(new Error(`invoke()'s answer for ${request.label} was rejected: ${checked.error.message}`));
176
+ }
177
+ screen.write(stderr, summaryLine(request, answer.value));
178
+ return checked;
179
+ }
180
+ let keyboard = script?.keyboard?.() ??
181
+ openKeyboard(stdin, (on) => {
182
+ raw = on;
183
+ });
184
+ try {
185
+ return await converse(request, keyboard);
186
+ }
187
+ finally {
188
+ keyboard.close();
189
+ }
190
+ },
191
+ async exec(command, options = {}) {
192
+ let stdio = options.stdio ?? "inherit";
193
+ let shares = stdio === "inherit" || stdio === "stderr";
194
+ if (shares)
195
+ screen.hold();
196
+ try {
197
+ return await exec(command, {
198
+ cwd: options.cwd ?? cwd,
199
+ env: { ...env, ...options.env },
200
+ stdio,
201
+ signal: options.signal ?? controller.signal,
202
+ input: options.input,
203
+ stdout,
204
+ stderr,
205
+ });
206
+ }
207
+ finally {
208
+ if (shares)
209
+ screen.release();
210
+ }
211
+ },
212
+ close() {
213
+ tasks.close();
214
+ restore();
215
+ for (let each of unlink)
216
+ each();
217
+ },
218
+ };
219
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Text styling for terminal output: colours and weights as ANSI SGR sequences, decided once per
3
+ * stream, so the same handler writes styled text to a terminal and plain text to a pipe or a
4
+ * file. Also measures text as a terminal lays it out, which redrawing depends on.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ /** The environment variables the colour decision reads. */
10
+ export type Env = Readonly<Record<string, string | undefined>>;
11
+ /** Wraps text in one ANSI style, or answers it unchanged when styling is off. */
12
+ export type Paint = (text: string) => string;
13
+ /** The styles a command writes with; every one answers its input unchanged when `enabled` is false. */
14
+ export interface Style {
15
+ /** Whether this stream receives ANSI sequences. */
16
+ readonly enabled: boolean;
17
+ readonly bold: Paint;
18
+ readonly dim: Paint;
19
+ readonly italic: Paint;
20
+ readonly underline: Paint;
21
+ readonly inverse: Paint;
22
+ readonly red: Paint;
23
+ readonly green: Paint;
24
+ readonly yellow: Paint;
25
+ readonly blue: Paint;
26
+ readonly magenta: Paint;
27
+ readonly cyan: Paint;
28
+ readonly gray: Paint;
29
+ }
30
+ /**
31
+ * A style set. Nested styles keep the outer one: closing an inner colour re-opens the colour
32
+ * around it, so `red(\`a ${bold("b")} c\`)` stays red after the bold part.
33
+ */
34
+ export declare function createStyle(enabled: boolean): Style;
35
+ /**
36
+ * Whether a stream receives colour: `FORCE_COLOR` (other than `0`) turns it on, `NO_COLOR` or
37
+ * `TERM=dumb` turns it off, and otherwise it follows whether the stream is a terminal.
38
+ */
39
+ export declare function colorEnabled(stream: {
40
+ isTTY?: boolean;
41
+ }, env: Env): boolean;
42
+ /** `text` without ANSI escape sequences. */
43
+ export declare function stripStyle(text: string): string;
44
+ /** `text` as user-perceived characters, the unit a line editor moves and deletes by. */
45
+ export declare function graphemes(text: string): string[];
46
+ /**
47
+ * How many terminal columns `text` occupies: ANSI sequences take none, and each user-perceived
48
+ * character takes one, so East Asian wide characters and emoji count as one column.
49
+ */
50
+ export declare function visibleWidth(text: string): number;
package/dist/style.js ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Text styling for terminal output: colours and weights as ANSI SGR sequences, decided once per
3
+ * stream, so the same handler writes styled text to a terminal and plain text to a pipe or a
4
+ * file. Also measures text as a terminal lays it out, which redrawing depends on.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ /** Each style's opening and closing SGR codes; closing codes reset only what they opened. */
10
+ const CODES = {
11
+ bold: [1, 22],
12
+ dim: [2, 22],
13
+ italic: [3, 23],
14
+ underline: [4, 24],
15
+ inverse: [7, 27],
16
+ red: [31, 39],
17
+ green: [32, 39],
18
+ yellow: [33, 39],
19
+ blue: [34, 39],
20
+ magenta: [35, 39],
21
+ cyan: [36, 39],
22
+ gray: [90, 39],
23
+ };
24
+ /** Every ANSI escape sequence this package or a child process may write: SGR, cursor and erase. */
25
+ // oxlint-disable-next-line eslint/no-control-regex -- ANSI sequences start with the ESC control character
26
+ const ANSI_SEQUENCE = /\u001B\[[0-9;?]*[A-Za-z]/g;
27
+ /**
28
+ * A style set. Nested styles keep the outer one: closing an inner colour re-opens the colour
29
+ * around it, so `red(\`a ${bold("b")} c\`)` stays red after the bold part.
30
+ */
31
+ export function createStyle(enabled) {
32
+ let entries = Object.entries(CODES).map(([name, [open, close]]) => {
33
+ let openCode = `\u001B[${open}m`;
34
+ let closeCode = `\u001B[${close}m`;
35
+ let paint = enabled
36
+ ? (text) => `${openCode}${text.split(closeCode).join(`${closeCode}${openCode}`)}${closeCode}`
37
+ : (text) => text;
38
+ return [name, paint];
39
+ });
40
+ return { enabled, ...Object.fromEntries(entries) };
41
+ }
42
+ /**
43
+ * Whether a stream receives colour: `FORCE_COLOR` (other than `0`) turns it on, `NO_COLOR` or
44
+ * `TERM=dumb` turns it off, and otherwise it follows whether the stream is a terminal.
45
+ */
46
+ export function colorEnabled(stream, env) {
47
+ let force = env.FORCE_COLOR;
48
+ if (force !== undefined)
49
+ return force !== "0" && force !== "false";
50
+ if (env.NO_COLOR !== undefined && env.NO_COLOR !== "")
51
+ return false;
52
+ if (env.TERM === "dumb")
53
+ return false;
54
+ return stream.isTTY === true;
55
+ }
56
+ /** `text` without ANSI escape sequences. */
57
+ export function stripStyle(text) {
58
+ return text.replace(ANSI_SEQUENCE, "");
59
+ }
60
+ /** Splits text into user-perceived characters, so an emoji with modifiers is one character. */
61
+ const SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
62
+ /** `text` as user-perceived characters, the unit a line editor moves and deletes by. */
63
+ export function graphemes(text) {
64
+ return Array.from(SEGMENTER.segment(text), (segment) => segment.segment);
65
+ }
66
+ /**
67
+ * How many terminal columns `text` occupies: ANSI sequences take none, and each user-perceived
68
+ * character takes one, so East Asian wide characters and emoji count as one column.
69
+ */
70
+ export function visibleWidth(text) {
71
+ return graphemes(stripStyle(text)).length;
72
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Closest-name suggestions for a mistyped command or flag, so a usage error can say what the
3
+ * user most likely meant instead of only what was wrong.
4
+ *
5
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
6
+ * @copyright Sergio Xalambrí 2026
7
+ */
8
+ /**
9
+ * The candidate nearest to `input` by edit distance, counting a swap of two neighbouring
10
+ * characters as one edit. A candidate qualifies within a third of the input's length (at least
11
+ * one edit, at most three), so a short unrelated name suggests nothing.
12
+ *
13
+ * @example closest("instal", ["install", "init"]) // "install"
14
+ */
15
+ export declare function closest(input: string, candidates: Iterable<string>): string | undefined;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Closest-name suggestions for a mistyped command or flag, so a usage error can say what the
3
+ * user most likely meant instead of only what was wrong.
4
+ *
5
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
6
+ * @copyright Sergio Xalambrí 2026
7
+ */
8
+ /**
9
+ * The candidate nearest to `input` by edit distance, counting a swap of two neighbouring
10
+ * characters as one edit. A candidate qualifies within a third of the input's length (at least
11
+ * one edit, at most three), so a short unrelated name suggests nothing.
12
+ *
13
+ * @example closest("instal", ["install", "init"]) // "install"
14
+ */
15
+ export function closest(input, candidates) {
16
+ let limit = Math.min(3, Math.max(1, Math.floor(input.length / 3)));
17
+ let best;
18
+ let bestDistance = Infinity;
19
+ for (let candidate of candidates) {
20
+ let distance = editDistance(input, candidate);
21
+ if (distance <= limit && distance < bestDistance) {
22
+ best = candidate;
23
+ bestDistance = distance;
24
+ }
25
+ }
26
+ return best;
27
+ }
28
+ /** Optimal string alignment distance: insertions, deletions, substitutions and adjacent swaps. */
29
+ function editDistance(a, b) {
30
+ let rows = [];
31
+ for (let i = 0; i <= a.length; i++) {
32
+ let row = [i];
33
+ for (let j = 1; j <= b.length; j++)
34
+ row.push(i === 0 ? j : 0);
35
+ rows.push(row);
36
+ }
37
+ for (let i = 1; i <= a.length; i++) {
38
+ for (let j = 1; j <= b.length; j++) {
39
+ let cost = a[i - 1] === b[j - 1] ? 0 : 1;
40
+ let above = rows[i - 1];
41
+ let row = rows[i];
42
+ let value = Math.min(above[j] + 1, row[j - 1] + 1, above[j - 1] + cost);
43
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
44
+ value = Math.min(value, rows[i - 2][j - 2] + 1);
45
+ }
46
+ row[j] = value;
47
+ }
48
+ }
49
+ return rows[a.length][b.length];
50
+ }
package/dist/task.d.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Tasks: async work the user waits on, shown as a spinner or a progress bar that ends in one
3
+ * ✓ or ✗ line. A task answers a `Result`, so a thrown error is a failure instead of an
4
+ * exception, and tasks started inside a task's callback draw as its indented steps.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Result } from "@sdxc/result";
10
+ import type { Screen } from "./screen.js";
11
+ import type { Style } from "./style.js";
12
+ /** What a task's callback shapes it through. */
13
+ export interface Task {
14
+ /**
15
+ * Aborts when the user presses Ctrl+C. Pass it to `fetch`, `ctx.exec` or anything else that
16
+ * takes a signal, so the work stops with the task; nested tasks share it.
17
+ */
18
+ readonly signal: AbortSignal;
19
+ /** Changes what the spinner says; the last label is the one the final line prints. */
20
+ update(label: string): void;
21
+ /** Turns the spinner into a bar out of `count`. Call again as the work grows. */
22
+ total(count: number): void;
23
+ /** Counts finished work; the count never passes the total. */
24
+ advance(by?: number): void;
25
+ }
26
+ /** What `ctx.task` answers: a returned `Result` as it is, any other value as its success. */
27
+ export type TaskResult<T> = [T] extends [Result<infer Data, infer Failed extends Error>] ? Result<Data, Failed | Error> : Result<T, Error>;
28
+ /** Runs a task; the label is optional. */
29
+ export interface RunTask {
30
+ <T>(fn: (task: Task) => T | Promise<T>): Promise<TaskResult<Awaited<T>>>;
31
+ <T>(label: string, fn: (task: Task) => T | Promise<T>): Promise<TaskResult<Awaited<T>>>;
32
+ }
33
+ /** Every task of one run, and how they draw. */
34
+ export declare class Tasks {
35
+ #private;
36
+ /**
37
+ * @param screen - The terminal owner the frame is drawn through
38
+ * @param style - Styling for stderr
39
+ * @param animate - Whether stderr is a terminal that redraws
40
+ * @param signal - Aborts on Ctrl+C
41
+ */
42
+ constructor(screen: Screen, style: Style, animate: boolean, signal: AbortSignal);
43
+ /** The `ctx.task` function bound to this run. */
44
+ readonly task: RunTask;
45
+ /** Stops the spinner and clears the frame, leaving finished lines where they are. */
46
+ close(): void;
47
+ }
package/dist/task.js ADDED
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Tasks: async work the user waits on, shown as a spinner or a progress bar that ends in one
3
+ * ✓ or ✗ line. A task answers a `Result`, so a thrown error is a failure instead of an
4
+ * exception, and tasks started inside a task's callback draw as its indented steps.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { AsyncLocalStorage } from "node:async_hooks";
10
+ import { failure, isFailure, success } from "@sdxc/result";
11
+ import { CancelledError } from "./errors.js";
12
+ /** The spinner's frames, one every {@link FRAME_MS}. */
13
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
14
+ /** How long each spinner frame shows. */
15
+ const FRAME_MS = 80;
16
+ /** How many cells the progress bar takes. */
17
+ const BAR_WIDTH = 20;
18
+ /** Whether a callback returned a `Result` rather than a plain value. */
19
+ function isResult(value) {
20
+ if (typeof value !== "object" || value === null || !("status" in value))
21
+ return false;
22
+ return ((value.status === "success" && "data" in value) ||
23
+ (value.status === "failure" && "error" in value));
24
+ }
25
+ /** How long a task took, as its final line prints it. */
26
+ function duration(ms) {
27
+ return ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(1)} s`;
28
+ }
29
+ /** Every task of one run, and how they draw. */
30
+ export class Tasks {
31
+ #screen;
32
+ #style;
33
+ /** Spinner frames and bars redraw in place; otherwise each task prints two plain lines. */
34
+ #animate;
35
+ #signal;
36
+ #roots = [];
37
+ #current = new AsyncLocalStorage();
38
+ #frame = 0;
39
+ #timer;
40
+ /**
41
+ * @param screen - The terminal owner the frame is drawn through
42
+ * @param style - Styling for stderr
43
+ * @param animate - Whether stderr is a terminal that redraws
44
+ * @param signal - Aborts on Ctrl+C
45
+ */
46
+ constructor(screen, style, animate, signal) {
47
+ this.#screen = screen;
48
+ this.#style = style;
49
+ this.#animate = animate;
50
+ this.#signal = signal;
51
+ screen.onRefresh(() => this.#draw());
52
+ }
53
+ /** The `ctx.task` function bound to this run. */
54
+ task = (async (labelOrFn, maybeFn) => {
55
+ let label = typeof labelOrFn === "string" ? labelOrFn : undefined;
56
+ let fn = (typeof labelOrFn === "string" ? maybeFn : labelOrFn);
57
+ return this.#run(label, fn);
58
+ });
59
+ /** Stops the spinner and clears the frame, leaving finished lines where they are. */
60
+ close() {
61
+ this.#stopTimer();
62
+ this.#roots = [];
63
+ if (this.#animate && !this.#screen.held)
64
+ this.#screen.paint([]);
65
+ }
66
+ async #run(label, fn) {
67
+ let parent = this.#current.getStore();
68
+ let node = {
69
+ label,
70
+ count: 0,
71
+ total: undefined,
72
+ depth: parent === undefined ? 0 : parent.depth + 1,
73
+ children: [],
74
+ status: "running",
75
+ started: performance.now(),
76
+ ended: 0,
77
+ };
78
+ if (parent === undefined)
79
+ this.#roots.push(node);
80
+ else
81
+ parent.children.push(node);
82
+ if (this.#animate) {
83
+ this.#startTimer();
84
+ this.#draw();
85
+ }
86
+ else if (label !== undefined) {
87
+ this.#screen.write(this.#screen.stderr, `${this.#indent(node)}${label}\n`);
88
+ }
89
+ let task = {
90
+ signal: this.#signal,
91
+ update: (next) => {
92
+ node.label = next;
93
+ if (this.#animate)
94
+ this.#draw();
95
+ },
96
+ total: (count) => {
97
+ node.total = Math.max(0, count);
98
+ node.count = Math.min(node.count, node.total);
99
+ if (this.#animate)
100
+ this.#draw();
101
+ },
102
+ advance: (by = 1) => {
103
+ node.count += by;
104
+ if (node.total !== undefined)
105
+ node.count = Math.min(node.count, node.total);
106
+ if (this.#animate)
107
+ this.#draw();
108
+ },
109
+ };
110
+ let outcome;
111
+ try {
112
+ let value = await this.#current.run(node, () => fn(task));
113
+ outcome = isResult(value) ? value : success(value);
114
+ }
115
+ catch (error) {
116
+ outcome = failure(error instanceof Error ? error : new Error(String(error)));
117
+ }
118
+ if (isFailure(outcome) && this.#signal.aborted && !(outcome.error instanceof CancelledError)) {
119
+ outcome = failure(new CancelledError());
120
+ }
121
+ node.status = isFailure(outcome) ? "failure" : "success";
122
+ node.ended = performance.now();
123
+ this.#finish(node);
124
+ return outcome;
125
+ }
126
+ /** Prints a finished task: a root leaves the frame for good, a step stays drawn in it. */
127
+ #finish(node) {
128
+ if (!this.#animate) {
129
+ if (node.label !== undefined) {
130
+ this.#screen.write(this.#screen.stderr, `${this.#finalLine(node)}\n`);
131
+ }
132
+ return;
133
+ }
134
+ if (node.depth > 0) {
135
+ this.#draw();
136
+ return;
137
+ }
138
+ this.#roots = this.#roots.filter((root) => root !== node);
139
+ let lines = this.#finalLines(node);
140
+ if (this.#roots.length === 0)
141
+ this.#stopTimer();
142
+ if (!this.#screen.held)
143
+ this.#screen.paint([]);
144
+ if (lines.length > 0)
145
+ this.#screen.write(this.#screen.stderr, `${lines.join("\n")}\n`);
146
+ this.#draw();
147
+ }
148
+ /** Redraws the frame: every running root and its steps. */
149
+ #draw() {
150
+ if (!this.#animate || this.#screen.held)
151
+ return;
152
+ let lines = [];
153
+ for (let root of this.#roots)
154
+ this.#collect(root, lines);
155
+ this.#screen.paint(lines);
156
+ }
157
+ /** Adds `node` and its steps to the frame. */
158
+ #collect(node, lines) {
159
+ if (node.status !== "running") {
160
+ if (node.label !== undefined)
161
+ lines.push(this.#finalLine(node));
162
+ }
163
+ else {
164
+ let spinner = this.#style.cyan(SPINNER[this.#frame % SPINNER.length]);
165
+ let text = node.label === undefined ? "" : ` ${node.label}`;
166
+ lines.push(`${this.#indent(node)}${spinner}${text}${this.#bar(node)}`);
167
+ }
168
+ for (let child of node.children)
169
+ this.#collect(child, lines);
170
+ }
171
+ /** A finished root and its steps, as they stay on screen. */
172
+ #finalLines(node) {
173
+ let lines = [];
174
+ if (node.label !== undefined)
175
+ lines.push(this.#finalLine(node));
176
+ for (let child of node.children)
177
+ lines.push(...this.#finalLines(child));
178
+ return lines;
179
+ }
180
+ /** `✓ label 3/3 (240 ms)`; the time only when animating, so plain output stays stable. */
181
+ #finalLine(node) {
182
+ let style = this.#style;
183
+ let mark = node.status === "success" ? style.green("✓") : style.red("✗");
184
+ let count = node.total === undefined ? "" : ` ${node.count}/${node.total}`;
185
+ let time = this.#animate ? ` ${style.dim(`(${duration(node.ended - node.started)})`)}` : "";
186
+ return `${this.#indent(node)}${mark} ${node.label ?? ""}${count}${time}`;
187
+ }
188
+ /** The progress bar of a counted task, empty for one without a total. */
189
+ #bar(node) {
190
+ if (node.total === undefined)
191
+ return "";
192
+ let filled = node.total === 0 ? BAR_WIDTH : Math.round((node.count / node.total) * BAR_WIDTH);
193
+ let bar = `${"█".repeat(filled)}${this.#style.dim("░".repeat(BAR_WIDTH - filled))}`;
194
+ return ` ${bar} ${node.count}/${node.total}`;
195
+ }
196
+ #indent(node) {
197
+ return " ".repeat(node.depth);
198
+ }
199
+ #startTimer() {
200
+ if (this.#timer !== undefined)
201
+ return;
202
+ this.#timer = setInterval(() => {
203
+ this.#frame += 1;
204
+ this.#draw();
205
+ }, FRAME_MS);
206
+ this.#timer.unref?.();
207
+ }
208
+ #stopTimer() {
209
+ if (this.#timer !== undefined)
210
+ clearInterval(this.#timer);
211
+ this.#timer = undefined;
212
+ }
213
+ }