@fusionkit/cli 0.1.0 → 0.1.1
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 +77 -0
- package/dist/cli.d.ts +0 -6
- package/dist/cli.js +16 -0
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +136 -0
- package/dist/commands/fusion.js +70 -12
- package/dist/fusion-config.d.ts +28 -0
- package/dist/fusion-config.js +133 -0
- package/dist/fusion-init.d.ts +4 -0
- package/dist/fusion-init.js +119 -0
- package/dist/fusion-quickstart.d.ts +48 -0
- package/dist/fusion-quickstart.js +340 -131
- package/dist/gateway.d.ts +2 -0
- package/dist/gateway.js +16 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -0
- package/dist/quiet-warnings.d.ts +1 -0
- package/dist/quiet-warnings.js +24 -0
- package/dist/shared/preflight.d.ts +1 -0
- package/dist/shared/preflight.js +1 -1
- package/dist/shared/proc.d.ts +36 -5
- package/dist/shared/proc.js +133 -25
- package/dist/test/fusion-config.test.d.ts +1 -0
- package/dist/test/fusion-config.test.js +80 -0
- package/dist/test/proc.test.js +23 -1
- package/dist/test/ui.test.d.ts +1 -0
- package/dist/test/ui.test.js +24 -0
- package/dist/ui/boot.d.ts +23 -0
- package/dist/ui/boot.js +56 -0
- package/dist/ui/index.d.ts +8 -0
- package/dist/ui/index.js +6 -0
- package/dist/ui/prompt.d.ts +30 -0
- package/dist/ui/prompt.js +178 -0
- package/dist/ui/runtime.d.ts +14 -0
- package/dist/ui/runtime.js +33 -0
- package/dist/ui/spinner.d.ts +31 -0
- package/dist/ui/spinner.js +102 -0
- package/dist/ui/steps.d.ts +38 -0
- package/dist/ui/steps.js +149 -0
- package/dist/ui/theme.d.ts +35 -0
- package/dist/ui/theme.js +52 -0
- package/package.json +9 -9
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type SelectOption<T> = {
|
|
2
|
+
value: T;
|
|
3
|
+
label: string;
|
|
4
|
+
hint?: string;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Single-choice selection. On a raw-capable TTY this is arrow-key driven with a
|
|
8
|
+
* live highlighted cursor; otherwise it falls back to a numbered prompt read
|
|
9
|
+
* from stdin (so piped input and non-raw terminals still work). Returns the
|
|
10
|
+
* default when input is empty or unparseable.
|
|
11
|
+
*/
|
|
12
|
+
export declare function select<T>(input: {
|
|
13
|
+
message: string;
|
|
14
|
+
options: ReadonlyArray<SelectOption<T>>;
|
|
15
|
+
defaultIndex?: number;
|
|
16
|
+
}): Promise<T>;
|
|
17
|
+
/** Yes/no confirmation. Returns `defaultValue` on empty input. */
|
|
18
|
+
export declare function confirm(input: {
|
|
19
|
+
message: string;
|
|
20
|
+
defaultValue?: boolean;
|
|
21
|
+
}): Promise<boolean>;
|
|
22
|
+
/** Free-text prompt. Returns `defaultValue` (or "") on empty input. */
|
|
23
|
+
export declare function text(input: {
|
|
24
|
+
message: string;
|
|
25
|
+
defaultValue?: string;
|
|
26
|
+
}): Promise<string>;
|
|
27
|
+
/** A success line for the end of a wizard. */
|
|
28
|
+
export declare function done(message: string): void;
|
|
29
|
+
/** A neutral note line. */
|
|
30
|
+
export declare function note(message: string): void;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { createInterface, emitKeypressEvents } from "node:readline";
|
|
2
|
+
import { canPromptInteractively, uiStream } from "./runtime.js";
|
|
3
|
+
import { bold, cyan, dim, glyph, gray, green } from "./theme.js";
|
|
4
|
+
const out = uiStream();
|
|
5
|
+
// For non-interactive input (piped/redirected/empty stdin) we read all of stdin
|
|
6
|
+
// exactly once and serve answers line by line. This supports scripted input
|
|
7
|
+
// (`printf "2\n3\n" | fusionkit fusion init`) and falls back to "" (the prompt
|
|
8
|
+
// default) once exhausted — without the fragile behavior of attaching multiple
|
|
9
|
+
// readline interfaces to an already-ended stdin.
|
|
10
|
+
let bufferedLines;
|
|
11
|
+
let bufferedRead = false;
|
|
12
|
+
async function ensureBufferedStdin() {
|
|
13
|
+
if (bufferedRead)
|
|
14
|
+
return;
|
|
15
|
+
bufferedRead = true;
|
|
16
|
+
if (process.stdin.isTTY || process.stdin.readableEnded) {
|
|
17
|
+
bufferedLines = [];
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const chunks = [];
|
|
21
|
+
await new Promise((resolve) => {
|
|
22
|
+
process.stdin.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
|
23
|
+
process.stdin.once("end", () => resolve());
|
|
24
|
+
process.stdin.once("error", () => resolve());
|
|
25
|
+
});
|
|
26
|
+
bufferedLines = Buffer.concat(chunks).toString("utf8").split("\n");
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Read a single line from stdin, prompting on stderr. On a TTY this reads live;
|
|
30
|
+
* otherwise it draws from buffered stdin and resolves to "" when there is no
|
|
31
|
+
* more input, so callers fall back to their default instead of hanging.
|
|
32
|
+
*/
|
|
33
|
+
async function readLine(promptText) {
|
|
34
|
+
if (!process.stdin.isTTY) {
|
|
35
|
+
out.write(promptText);
|
|
36
|
+
await ensureBufferedStdin();
|
|
37
|
+
const next = bufferedLines?.shift();
|
|
38
|
+
out.write("\n");
|
|
39
|
+
return (next ?? "").trim();
|
|
40
|
+
}
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
const rl = createInterface({ input: process.stdin, output: out });
|
|
43
|
+
let answered = false;
|
|
44
|
+
rl.question(promptText, (answer) => {
|
|
45
|
+
answered = true;
|
|
46
|
+
rl.close();
|
|
47
|
+
resolve(answer.trim());
|
|
48
|
+
});
|
|
49
|
+
rl.on("close", () => {
|
|
50
|
+
if (!answered)
|
|
51
|
+
resolve("");
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Single-choice selection. On a raw-capable TTY this is arrow-key driven with a
|
|
57
|
+
* live highlighted cursor; otherwise it falls back to a numbered prompt read
|
|
58
|
+
* from stdin (so piped input and non-raw terminals still work). Returns the
|
|
59
|
+
* default when input is empty or unparseable.
|
|
60
|
+
*/
|
|
61
|
+
export async function select(input) {
|
|
62
|
+
const { options } = input;
|
|
63
|
+
if (options.length === 0)
|
|
64
|
+
throw new Error("select requires at least one option");
|
|
65
|
+
const fallbackIndex = Math.min(Math.max(input.defaultIndex ?? 0, 0), options.length - 1);
|
|
66
|
+
if (!canPromptInteractively()) {
|
|
67
|
+
return selectNumbered(input.message, options, fallbackIndex);
|
|
68
|
+
}
|
|
69
|
+
return selectInteractive(input.message, options, fallbackIndex);
|
|
70
|
+
}
|
|
71
|
+
function optionAt(options, index) {
|
|
72
|
+
const option = options[index];
|
|
73
|
+
if (option === undefined)
|
|
74
|
+
throw new Error(`option index out of range: ${index}`);
|
|
75
|
+
return option;
|
|
76
|
+
}
|
|
77
|
+
async function selectNumbered(message, options, fallbackIndex) {
|
|
78
|
+
out.write(`${bold(message)}\n`);
|
|
79
|
+
options.forEach((option, index) => {
|
|
80
|
+
const marker = index === fallbackIndex ? cyan(`${index + 1}`) : `${index + 1}`;
|
|
81
|
+
const hint = option.hint !== undefined ? dim(` — ${option.hint}`) : "";
|
|
82
|
+
out.write(` ${marker}) ${option.label}${hint}\n`);
|
|
83
|
+
});
|
|
84
|
+
const answer = await readLine(`Choose [1-${options.length}] (${fallbackIndex + 1}): `);
|
|
85
|
+
if (answer.length === 0)
|
|
86
|
+
return optionAt(options, fallbackIndex).value;
|
|
87
|
+
const byNumber = Number.parseInt(answer, 10);
|
|
88
|
+
if (Number.isInteger(byNumber) && byNumber >= 1 && byNumber <= options.length) {
|
|
89
|
+
return optionAt(options, byNumber - 1).value;
|
|
90
|
+
}
|
|
91
|
+
const byLabel = options.findIndex((option) => option.label.toLowerCase() === answer.toLowerCase());
|
|
92
|
+
if (byLabel >= 0)
|
|
93
|
+
return optionAt(options, byLabel).value;
|
|
94
|
+
return optionAt(options, fallbackIndex).value;
|
|
95
|
+
}
|
|
96
|
+
function selectInteractive(message, options, fallbackIndex) {
|
|
97
|
+
return new Promise((resolve) => {
|
|
98
|
+
let cursor = fallbackIndex;
|
|
99
|
+
let rendered = 0;
|
|
100
|
+
const stdin = process.stdin;
|
|
101
|
+
emitKeypressEvents(stdin);
|
|
102
|
+
const wasRaw = stdin.isRaw === true;
|
|
103
|
+
if (stdin.setRawMode)
|
|
104
|
+
stdin.setRawMode(true);
|
|
105
|
+
stdin.resume();
|
|
106
|
+
out.write("\u001b[?25l");
|
|
107
|
+
const render = () => {
|
|
108
|
+
if (rendered > 0) {
|
|
109
|
+
out.write(`\u001b[${rendered}A`);
|
|
110
|
+
out.write("\u001b[0J");
|
|
111
|
+
}
|
|
112
|
+
const lines = [bold(message)];
|
|
113
|
+
options.forEach((option, index) => {
|
|
114
|
+
const active = index === cursor;
|
|
115
|
+
const pointer = active ? cyan(glyph.pointer()) : " ";
|
|
116
|
+
const label = active ? cyan(option.label) : option.label;
|
|
117
|
+
const hint = option.hint !== undefined ? dim(` — ${option.hint}`) : "";
|
|
118
|
+
lines.push(`${pointer} ${label}${hint}`);
|
|
119
|
+
});
|
|
120
|
+
lines.push(dim(" (arrows to move, enter to select)"));
|
|
121
|
+
out.write(lines.join("\n") + "\n");
|
|
122
|
+
rendered = lines.length;
|
|
123
|
+
};
|
|
124
|
+
const cleanup = () => {
|
|
125
|
+
stdin.removeListener("keypress", onKey);
|
|
126
|
+
if (stdin.setRawMode)
|
|
127
|
+
stdin.setRawMode(wasRaw);
|
|
128
|
+
stdin.pause();
|
|
129
|
+
out.write("\u001b[?25h");
|
|
130
|
+
};
|
|
131
|
+
const onKey = (_str, key) => {
|
|
132
|
+
if (key.sequence === "\u0003") {
|
|
133
|
+
cleanup();
|
|
134
|
+
out.write("\n");
|
|
135
|
+
process.exit(130);
|
|
136
|
+
}
|
|
137
|
+
if (key.name === "up" || key.name === "k") {
|
|
138
|
+
cursor = (cursor - 1 + options.length) % options.length;
|
|
139
|
+
render();
|
|
140
|
+
}
|
|
141
|
+
else if (key.name === "down" || key.name === "j") {
|
|
142
|
+
cursor = (cursor + 1) % options.length;
|
|
143
|
+
render();
|
|
144
|
+
}
|
|
145
|
+
else if (key.name === "return" || key.name === "enter") {
|
|
146
|
+
cleanup();
|
|
147
|
+
resolve(optionAt(options, cursor).value);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
stdin.on("keypress", onKey);
|
|
151
|
+
render();
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
/** Yes/no confirmation. Returns `defaultValue` on empty input. */
|
|
155
|
+
export async function confirm(input) {
|
|
156
|
+
const def = input.defaultValue ?? false;
|
|
157
|
+
const hint = def ? "[Y/n]" : "[y/N]";
|
|
158
|
+
const answer = (await readLine(`${bold(input.message)} ${dim(hint)} `)).toLowerCase();
|
|
159
|
+
if (answer.length === 0)
|
|
160
|
+
return def;
|
|
161
|
+
return answer === "y" || answer === "yes";
|
|
162
|
+
}
|
|
163
|
+
/** Free-text prompt. Returns `defaultValue` (or "") on empty input. */
|
|
164
|
+
export async function text(input) {
|
|
165
|
+
const suffix = input.defaultValue !== undefined && input.defaultValue.length > 0 ? dim(` (${input.defaultValue})`) : "";
|
|
166
|
+
const answer = await readLine(`${bold(input.message)}${suffix} `);
|
|
167
|
+
if (answer.length === 0)
|
|
168
|
+
return input.defaultValue ?? "";
|
|
169
|
+
return answer;
|
|
170
|
+
}
|
|
171
|
+
/** A success line for the end of a wizard. */
|
|
172
|
+
export function done(message) {
|
|
173
|
+
out.write(`${green(glyph.tick())} ${message}\n`);
|
|
174
|
+
}
|
|
175
|
+
/** A neutral note line. */
|
|
176
|
+
export function note(message) {
|
|
177
|
+
out.write(`${gray(glyph.arrow())} ${message}\n`);
|
|
178
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interaction-mode detection. The CLI's rich surfaces (spinners, live step
|
|
3
|
+
* lists, prompts) only render when we are attached to an interactive terminal
|
|
4
|
+
* and not running under CI; otherwise everything degrades to plain line logs so
|
|
5
|
+
* pipes, captures, and `node --test` stay deterministic.
|
|
6
|
+
*/
|
|
7
|
+
/** True under a recognized CI environment. */
|
|
8
|
+
export declare function isCI(): boolean;
|
|
9
|
+
/** The stream all UI is written to (stderr; stdout is reserved for tool output). */
|
|
10
|
+
export declare function uiStream(): NodeJS.WriteStream;
|
|
11
|
+
/** True when we should render rich, animated UI to `stream`. */
|
|
12
|
+
export declare function isInteractive(stream?: NodeJS.WriteStream): boolean;
|
|
13
|
+
/** True when we can read interactive keypresses (raw mode) from stdin. */
|
|
14
|
+
export declare function canPromptInteractively(): boolean;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interaction-mode detection. The CLI's rich surfaces (spinners, live step
|
|
3
|
+
* lists, prompts) only render when we are attached to an interactive terminal
|
|
4
|
+
* and not running under CI; otherwise everything degrades to plain line logs so
|
|
5
|
+
* pipes, captures, and `node --test` stay deterministic.
|
|
6
|
+
*/
|
|
7
|
+
/** True under a recognized CI environment. */
|
|
8
|
+
export function isCI() {
|
|
9
|
+
const env = process.env;
|
|
10
|
+
return Boolean(env.CI === "true" ||
|
|
11
|
+
env.CI === "1" ||
|
|
12
|
+
env.CONTINUOUS_INTEGRATION ||
|
|
13
|
+
env.GITHUB_ACTIONS ||
|
|
14
|
+
env.GITLAB_CI ||
|
|
15
|
+
env.BUILDKITE ||
|
|
16
|
+
env.CIRCLECI);
|
|
17
|
+
}
|
|
18
|
+
/** The stream all UI is written to (stderr; stdout is reserved for tool output). */
|
|
19
|
+
export function uiStream() {
|
|
20
|
+
return process.stderr;
|
|
21
|
+
}
|
|
22
|
+
/** True when we should render rich, animated UI to `stream`. */
|
|
23
|
+
export function isInteractive(stream = uiStream()) {
|
|
24
|
+
if (process.env.FUSIONKIT_NO_TUI === "1")
|
|
25
|
+
return false;
|
|
26
|
+
if (isCI())
|
|
27
|
+
return false;
|
|
28
|
+
return Boolean(stream.isTTY);
|
|
29
|
+
}
|
|
30
|
+
/** True when we can read interactive keypresses (raw mode) from stdin. */
|
|
31
|
+
export function canPromptInteractively() {
|
|
32
|
+
return Boolean(process.stdin.isTTY) && !isCI() && process.env.FUSIONKIT_NO_TUI !== "1";
|
|
33
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A single-line spinner. On an interactive TTY it animates in place; otherwise
|
|
3
|
+
* it prints one line per state transition so logs stay readable and ordered.
|
|
4
|
+
*/
|
|
5
|
+
export declare class Spinner {
|
|
6
|
+
private timer;
|
|
7
|
+
private frame;
|
|
8
|
+
private text;
|
|
9
|
+
private readonly stream;
|
|
10
|
+
private readonly interactive;
|
|
11
|
+
private active;
|
|
12
|
+
constructor(text: string);
|
|
13
|
+
start(): this;
|
|
14
|
+
update(text: string): this;
|
|
15
|
+
succeed(text?: string): void;
|
|
16
|
+
fail(text?: string): void;
|
|
17
|
+
warn(text?: string): void;
|
|
18
|
+
info(text?: string): void;
|
|
19
|
+
stop(): void;
|
|
20
|
+
private settle;
|
|
21
|
+
private render;
|
|
22
|
+
private teardown;
|
|
23
|
+
private clearLine;
|
|
24
|
+
private hideCursor;
|
|
25
|
+
private showCursor;
|
|
26
|
+
}
|
|
27
|
+
/** Run `work` under a spinner, settling to success/failure automatically. */
|
|
28
|
+
export declare function withSpinner<T>(text: string, work: () => Promise<T>, options?: {
|
|
29
|
+
success?: (value: T) => string;
|
|
30
|
+
failure?: (error: unknown) => string;
|
|
31
|
+
}): Promise<T>;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { isInteractive, uiStream } from "./runtime.js";
|
|
2
|
+
import { SPINNER_FRAMES, cyan, dim, glyph, gray, green, red, yellow } from "./theme.js";
|
|
3
|
+
/**
|
|
4
|
+
* A single-line spinner. On an interactive TTY it animates in place; otherwise
|
|
5
|
+
* it prints one line per state transition so logs stay readable and ordered.
|
|
6
|
+
*/
|
|
7
|
+
export class Spinner {
|
|
8
|
+
timer;
|
|
9
|
+
frame = 0;
|
|
10
|
+
text;
|
|
11
|
+
stream = uiStream();
|
|
12
|
+
interactive = isInteractive();
|
|
13
|
+
active = false;
|
|
14
|
+
constructor(text) {
|
|
15
|
+
this.text = text;
|
|
16
|
+
}
|
|
17
|
+
start() {
|
|
18
|
+
if (this.active)
|
|
19
|
+
return this;
|
|
20
|
+
this.active = true;
|
|
21
|
+
if (!this.interactive) {
|
|
22
|
+
this.stream.write(`${dim(glyph.arrow())} ${this.text}\n`);
|
|
23
|
+
return this;
|
|
24
|
+
}
|
|
25
|
+
this.hideCursor();
|
|
26
|
+
this.render();
|
|
27
|
+
this.timer = setInterval(() => {
|
|
28
|
+
this.frame = (this.frame + 1) % SPINNER_FRAMES.length;
|
|
29
|
+
this.render();
|
|
30
|
+
}, 80);
|
|
31
|
+
this.timer.unref();
|
|
32
|
+
return this;
|
|
33
|
+
}
|
|
34
|
+
update(text) {
|
|
35
|
+
this.text = text;
|
|
36
|
+
if (this.active && this.interactive)
|
|
37
|
+
this.render();
|
|
38
|
+
else if (this.active)
|
|
39
|
+
this.stream.write(`${dim(glyph.arrow())} ${this.text}\n`);
|
|
40
|
+
return this;
|
|
41
|
+
}
|
|
42
|
+
succeed(text) {
|
|
43
|
+
this.settle(green(glyph.tick()), text ?? this.text);
|
|
44
|
+
}
|
|
45
|
+
fail(text) {
|
|
46
|
+
this.settle(red(glyph.cross()), text ?? this.text);
|
|
47
|
+
}
|
|
48
|
+
warn(text) {
|
|
49
|
+
this.settle(yellow(glyph.warn()), text ?? this.text);
|
|
50
|
+
}
|
|
51
|
+
info(text) {
|
|
52
|
+
this.settle(cyan(glyph.bullet()), text ?? this.text);
|
|
53
|
+
}
|
|
54
|
+
stop() {
|
|
55
|
+
this.teardown();
|
|
56
|
+
}
|
|
57
|
+
settle(symbol, text) {
|
|
58
|
+
this.teardown();
|
|
59
|
+
this.stream.write(`${symbol} ${text}\n`);
|
|
60
|
+
}
|
|
61
|
+
render() {
|
|
62
|
+
const symbol = cyan(SPINNER_FRAMES[this.frame] ?? "-");
|
|
63
|
+
this.clearLine();
|
|
64
|
+
this.stream.write(`${symbol} ${this.text}`);
|
|
65
|
+
}
|
|
66
|
+
teardown() {
|
|
67
|
+
if (this.timer !== undefined) {
|
|
68
|
+
clearInterval(this.timer);
|
|
69
|
+
this.timer = undefined;
|
|
70
|
+
}
|
|
71
|
+
if (this.active && this.interactive) {
|
|
72
|
+
this.clearLine();
|
|
73
|
+
this.showCursor();
|
|
74
|
+
}
|
|
75
|
+
this.active = false;
|
|
76
|
+
}
|
|
77
|
+
clearLine() {
|
|
78
|
+
if (this.interactive)
|
|
79
|
+
this.stream.write("\r\u001b[2K");
|
|
80
|
+
}
|
|
81
|
+
hideCursor() {
|
|
82
|
+
if (this.interactive)
|
|
83
|
+
this.stream.write("\u001b[?25l");
|
|
84
|
+
}
|
|
85
|
+
showCursor() {
|
|
86
|
+
if (this.interactive)
|
|
87
|
+
this.stream.write("\u001b[?25h");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** Run `work` under a spinner, settling to success/failure automatically. */
|
|
91
|
+
export async function withSpinner(text, work, options = {}) {
|
|
92
|
+
const spinner = new Spinner(text).start();
|
|
93
|
+
try {
|
|
94
|
+
const value = await work();
|
|
95
|
+
spinner.succeed(options.success ? options.success(value) : text);
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
spinner.fail(options.failure ? options.failure(error) : `${text} ${gray("(failed)")}`);
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export type StepStatus = "pending" | "active" | "done" | "failed" | "skipped";
|
|
2
|
+
export type StepInput = {
|
|
3
|
+
id: string;
|
|
4
|
+
label: string;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* A live checklist of stages. On a TTY it re-renders in place with per-stage
|
|
8
|
+
* spinners and elapsed time; otherwise it prints one line per state transition
|
|
9
|
+
* so non-interactive logs stay ordered and readable.
|
|
10
|
+
*/
|
|
11
|
+
export declare class StepList {
|
|
12
|
+
private readonly steps;
|
|
13
|
+
private readonly stream;
|
|
14
|
+
private readonly interactive;
|
|
15
|
+
private readonly title;
|
|
16
|
+
private timer;
|
|
17
|
+
private frame;
|
|
18
|
+
private renderedLines;
|
|
19
|
+
private started;
|
|
20
|
+
constructor(steps: readonly StepInput[], options?: {
|
|
21
|
+
title?: string;
|
|
22
|
+
});
|
|
23
|
+
start(): this;
|
|
24
|
+
setActive(id: string, detail?: string): void;
|
|
25
|
+
setDone(id: string, detail?: string): void;
|
|
26
|
+
setFailed(id: string, detail?: string): void;
|
|
27
|
+
setSkipped(id: string, detail?: string): void;
|
|
28
|
+
setDetail(id: string, detail: string): void;
|
|
29
|
+
/** Stop animation and leave the final frame in place. */
|
|
30
|
+
stop(): void;
|
|
31
|
+
private transition;
|
|
32
|
+
private printLine;
|
|
33
|
+
private render;
|
|
34
|
+
private clear;
|
|
35
|
+
private find;
|
|
36
|
+
private hideCursor;
|
|
37
|
+
private showCursor;
|
|
38
|
+
}
|
package/dist/ui/steps.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { isInteractive, uiStream } from "./runtime.js";
|
|
2
|
+
import { SPINNER_FRAMES, cyan, dim, glyph, gray, green, red, yellow } from "./theme.js";
|
|
3
|
+
function elapsedLabel(step) {
|
|
4
|
+
if (step.startedAt === undefined)
|
|
5
|
+
return "";
|
|
6
|
+
const end = step.endedAt ?? Date.now();
|
|
7
|
+
const seconds = (end - step.startedAt) / 1000;
|
|
8
|
+
if (seconds < 0.05)
|
|
9
|
+
return "";
|
|
10
|
+
return gray(` ${seconds.toFixed(1)}s`);
|
|
11
|
+
}
|
|
12
|
+
function symbolFor(step, frame) {
|
|
13
|
+
switch (step.status) {
|
|
14
|
+
case "pending":
|
|
15
|
+
return gray(glyph.pending());
|
|
16
|
+
case "active":
|
|
17
|
+
return cyan(SPINNER_FRAMES[frame] ?? "-");
|
|
18
|
+
case "done":
|
|
19
|
+
return green(glyph.tick());
|
|
20
|
+
case "failed":
|
|
21
|
+
return red(glyph.cross());
|
|
22
|
+
case "skipped":
|
|
23
|
+
return yellow(glyph.bullet());
|
|
24
|
+
default: {
|
|
25
|
+
const exhaustive = step.status;
|
|
26
|
+
throw new Error(`unknown step status: ${String(exhaustive)}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* A live checklist of stages. On a TTY it re-renders in place with per-stage
|
|
32
|
+
* spinners and elapsed time; otherwise it prints one line per state transition
|
|
33
|
+
* so non-interactive logs stay ordered and readable.
|
|
34
|
+
*/
|
|
35
|
+
export class StepList {
|
|
36
|
+
steps;
|
|
37
|
+
stream = uiStream();
|
|
38
|
+
interactive = isInteractive();
|
|
39
|
+
title;
|
|
40
|
+
timer;
|
|
41
|
+
frame = 0;
|
|
42
|
+
renderedLines = 0;
|
|
43
|
+
started = false;
|
|
44
|
+
constructor(steps, options = {}) {
|
|
45
|
+
this.steps = steps.map((step) => ({ id: step.id, label: step.label, status: "pending" }));
|
|
46
|
+
this.title = options.title;
|
|
47
|
+
}
|
|
48
|
+
start() {
|
|
49
|
+
if (this.started)
|
|
50
|
+
return this;
|
|
51
|
+
this.started = true;
|
|
52
|
+
if (this.interactive) {
|
|
53
|
+
this.hideCursor();
|
|
54
|
+
this.render();
|
|
55
|
+
this.timer = setInterval(() => {
|
|
56
|
+
this.frame = (this.frame + 1) % SPINNER_FRAMES.length;
|
|
57
|
+
this.render();
|
|
58
|
+
}, 80);
|
|
59
|
+
this.timer.unref();
|
|
60
|
+
}
|
|
61
|
+
else if (this.title !== undefined) {
|
|
62
|
+
this.stream.write(`${this.title}\n`);
|
|
63
|
+
}
|
|
64
|
+
return this;
|
|
65
|
+
}
|
|
66
|
+
setActive(id, detail) {
|
|
67
|
+
this.transition(id, "active", detail);
|
|
68
|
+
}
|
|
69
|
+
setDone(id, detail) {
|
|
70
|
+
this.transition(id, "done", detail);
|
|
71
|
+
}
|
|
72
|
+
setFailed(id, detail) {
|
|
73
|
+
this.transition(id, "failed", detail);
|
|
74
|
+
}
|
|
75
|
+
setSkipped(id, detail) {
|
|
76
|
+
this.transition(id, "skipped", detail);
|
|
77
|
+
}
|
|
78
|
+
setDetail(id, detail) {
|
|
79
|
+
const step = this.find(id);
|
|
80
|
+
step.detail = detail;
|
|
81
|
+
if (this.interactive)
|
|
82
|
+
this.render();
|
|
83
|
+
}
|
|
84
|
+
/** Stop animation and leave the final frame in place. */
|
|
85
|
+
stop() {
|
|
86
|
+
if (this.timer !== undefined) {
|
|
87
|
+
clearInterval(this.timer);
|
|
88
|
+
this.timer = undefined;
|
|
89
|
+
}
|
|
90
|
+
if (this.interactive && this.started) {
|
|
91
|
+
this.render();
|
|
92
|
+
this.showCursor();
|
|
93
|
+
}
|
|
94
|
+
this.started = false;
|
|
95
|
+
}
|
|
96
|
+
transition(id, status, detail) {
|
|
97
|
+
const step = this.find(id);
|
|
98
|
+
if (status === "active" && step.startedAt === undefined)
|
|
99
|
+
step.startedAt = Date.now();
|
|
100
|
+
if ((status === "done" || status === "failed" || status === "skipped") && step.endedAt === undefined) {
|
|
101
|
+
step.endedAt = Date.now();
|
|
102
|
+
if (step.startedAt === undefined)
|
|
103
|
+
step.startedAt = step.endedAt;
|
|
104
|
+
}
|
|
105
|
+
step.status = status;
|
|
106
|
+
if (detail !== undefined)
|
|
107
|
+
step.detail = detail;
|
|
108
|
+
if (this.interactive)
|
|
109
|
+
this.render();
|
|
110
|
+
else
|
|
111
|
+
this.printLine(step);
|
|
112
|
+
}
|
|
113
|
+
printLine(step) {
|
|
114
|
+
const detail = step.detail !== undefined ? ` ${dim(step.detail)}` : "";
|
|
115
|
+
this.stream.write(`${symbolFor(step, 0)} ${step.label}${detail}\n`);
|
|
116
|
+
}
|
|
117
|
+
render() {
|
|
118
|
+
const lines = [];
|
|
119
|
+
if (this.title !== undefined)
|
|
120
|
+
lines.push(this.title);
|
|
121
|
+
for (const step of this.steps) {
|
|
122
|
+
const detail = step.detail !== undefined ? ` ${dim(step.detail)}` : "";
|
|
123
|
+
lines.push(`${symbolFor(step, this.frame)} ${step.label}${detail}${elapsedLabel(step)}`);
|
|
124
|
+
}
|
|
125
|
+
this.clear();
|
|
126
|
+
this.stream.write(lines.join("\n") + "\n");
|
|
127
|
+
this.renderedLines = lines.length;
|
|
128
|
+
}
|
|
129
|
+
clear() {
|
|
130
|
+
if (!this.interactive || this.renderedLines === 0)
|
|
131
|
+
return;
|
|
132
|
+
this.stream.write(`\u001b[${this.renderedLines}A`);
|
|
133
|
+
this.stream.write("\u001b[0J");
|
|
134
|
+
}
|
|
135
|
+
find(id) {
|
|
136
|
+
const step = this.steps.find((candidate) => candidate.id === id);
|
|
137
|
+
if (step === undefined)
|
|
138
|
+
throw new Error(`unknown step id: ${id}`);
|
|
139
|
+
return step;
|
|
140
|
+
}
|
|
141
|
+
hideCursor() {
|
|
142
|
+
if (this.interactive)
|
|
143
|
+
this.stream.write("\u001b[?25l");
|
|
144
|
+
}
|
|
145
|
+
showCursor() {
|
|
146
|
+
if (this.interactive)
|
|
147
|
+
this.stream.write("\u001b[?25h");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zero-dependency terminal theming. All color/format helpers no-op when color
|
|
3
|
+
* is not supported (not a TTY, `NO_COLOR` set, or a dumb terminal), so output
|
|
4
|
+
* stays clean when piped or captured. UI is written to stderr by convention so
|
|
5
|
+
* the launched coding tool's stdout stays pristine.
|
|
6
|
+
*/
|
|
7
|
+
/** True when ANSI styling should be emitted to the given stream. */
|
|
8
|
+
export declare function supportsColor(stream?: NodeJS.WriteStream): boolean;
|
|
9
|
+
type Style = (text: string) => string;
|
|
10
|
+
export declare const bold: Style;
|
|
11
|
+
export declare const dim: Style;
|
|
12
|
+
export declare const italic: Style;
|
|
13
|
+
export declare const underline: Style;
|
|
14
|
+
export declare const red: Style;
|
|
15
|
+
export declare const green: Style;
|
|
16
|
+
export declare const yellow: Style;
|
|
17
|
+
export declare const blue: Style;
|
|
18
|
+
export declare const magenta: Style;
|
|
19
|
+
export declare const cyan: Style;
|
|
20
|
+
export declare const gray: Style;
|
|
21
|
+
/** Status glyphs, with ASCII fallbacks when color (≈ unicode-friendly TTY) is off. */
|
|
22
|
+
export declare const glyph: {
|
|
23
|
+
tick: () => "✔" | "[ok]";
|
|
24
|
+
cross: () => "✖" | "[x]";
|
|
25
|
+
bullet: () => "•" | "*";
|
|
26
|
+
arrow: () => "›" | ">";
|
|
27
|
+
pointer: () => ">" | "❯";
|
|
28
|
+
warn: () => "⚠" | "[!]";
|
|
29
|
+
pending: () => "○" | "( )";
|
|
30
|
+
};
|
|
31
|
+
/** Frames for the in-place spinner (braille dots when color is on). */
|
|
32
|
+
export declare const SPINNER_FRAMES: readonly string[];
|
|
33
|
+
/** The product banner shown atop interactive surfaces. */
|
|
34
|
+
export declare function brandHeader(subtitle?: string): string;
|
|
35
|
+
export {};
|
package/dist/ui/theme.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zero-dependency terminal theming. All color/format helpers no-op when color
|
|
3
|
+
* is not supported (not a TTY, `NO_COLOR` set, or a dumb terminal), so output
|
|
4
|
+
* stays clean when piped or captured. UI is written to stderr by convention so
|
|
5
|
+
* the launched coding tool's stdout stays pristine.
|
|
6
|
+
*/
|
|
7
|
+
/** True when ANSI styling should be emitted to the given stream. */
|
|
8
|
+
export function supportsColor(stream = process.stderr) {
|
|
9
|
+
if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== "")
|
|
10
|
+
return false;
|
|
11
|
+
if (process.env.FORCE_COLOR !== undefined && process.env.FORCE_COLOR !== "0")
|
|
12
|
+
return true;
|
|
13
|
+
if (process.env.TERM === "dumb")
|
|
14
|
+
return false;
|
|
15
|
+
return Boolean(stream.isTTY);
|
|
16
|
+
}
|
|
17
|
+
function wrap(open, close) {
|
|
18
|
+
return (text) => (supportsColor() ? `\u001b[${open}m${text}\u001b[${close}m` : text);
|
|
19
|
+
}
|
|
20
|
+
export const bold = wrap(1, 22);
|
|
21
|
+
export const dim = wrap(2, 22);
|
|
22
|
+
export const italic = wrap(3, 23);
|
|
23
|
+
export const underline = wrap(4, 24);
|
|
24
|
+
export const red = wrap(31, 39);
|
|
25
|
+
export const green = wrap(32, 39);
|
|
26
|
+
export const yellow = wrap(33, 39);
|
|
27
|
+
export const blue = wrap(34, 39);
|
|
28
|
+
export const magenta = wrap(35, 39);
|
|
29
|
+
export const cyan = wrap(36, 39);
|
|
30
|
+
export const gray = wrap(90, 39);
|
|
31
|
+
/** Status glyphs, with ASCII fallbacks when color (≈ unicode-friendly TTY) is off. */
|
|
32
|
+
export const glyph = {
|
|
33
|
+
tick: () => (supportsColor() ? "\u2714" : "[ok]"),
|
|
34
|
+
cross: () => (supportsColor() ? "\u2716" : "[x]"),
|
|
35
|
+
bullet: () => (supportsColor() ? "\u2022" : "*"),
|
|
36
|
+
arrow: () => (supportsColor() ? "\u203a" : ">"),
|
|
37
|
+
pointer: () => (supportsColor() ? "\u276f" : ">"),
|
|
38
|
+
warn: () => (supportsColor() ? "\u26a0" : "[!]"),
|
|
39
|
+
pending: () => (supportsColor() ? "\u25cb" : "( )")
|
|
40
|
+
};
|
|
41
|
+
/** Frames for the in-place spinner (braille dots when color is on). */
|
|
42
|
+
export const SPINNER_FRAMES = supportsColorFrames();
|
|
43
|
+
function supportsColorFrames() {
|
|
44
|
+
return ["\u280b", "\u2819", "\u2839", "\u2838", "\u283c", "\u2834", "\u2826", "\u2827", "\u2807", "\u280f"];
|
|
45
|
+
}
|
|
46
|
+
/** The product banner shown atop interactive surfaces. */
|
|
47
|
+
export function brandHeader(subtitle) {
|
|
48
|
+
const title = bold(cyan("fusionkit"));
|
|
49
|
+
const tag = dim("real model fusion behind your coding agent");
|
|
50
|
+
const head = `${title} ${tag}`;
|
|
51
|
+
return subtitle === undefined ? head : `${head}\n${dim(subtitle)}`;
|
|
52
|
+
}
|