@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
package/dist/gateway.js
CHANGED
|
@@ -10,6 +10,14 @@ import { join, resolve } from "node:path";
|
|
|
10
10
|
import { runFusionPanels, runUnifiedHarnessE2E } from "@fusionkit/ensemble";
|
|
11
11
|
import { emitTrace, newSpanId, newTraceId } from "@fusionkit/protocol";
|
|
12
12
|
import { FusionBackend, installAcpAdapters, runAcpAgent, runFrontDoorAcceptance, startFusionGateway, startGateway } from "@fusionkit/model-gateway";
|
|
13
|
+
// Once an interactive coding agent owns the terminal, the per-turn panel chatter
|
|
14
|
+
// would corrupt its full-screen TUI. The launcher flips this off before handing
|
|
15
|
+
// over; trace events (for --observe) keep flowing regardless.
|
|
16
|
+
let gatewayChatter = true;
|
|
17
|
+
/** Enable/disable the gateway's per-turn stderr chatter (default on). */
|
|
18
|
+
export function setGatewayChatter(enabled) {
|
|
19
|
+
gatewayChatter = enabled;
|
|
20
|
+
}
|
|
13
21
|
function mapStatus(status) {
|
|
14
22
|
if (status === "succeeded")
|
|
15
23
|
return "succeeded";
|
|
@@ -224,7 +232,9 @@ export async function startFusionStepGateway(input) {
|
|
|
224
232
|
}
|
|
225
233
|
}
|
|
226
234
|
});
|
|
227
|
-
|
|
235
|
+
if (gatewayChatter) {
|
|
236
|
+
console.error(`fusion: running panel (${config.models.map((m) => m.id).join(", ")}) for session ${sessionKey}...`);
|
|
237
|
+
}
|
|
228
238
|
try {
|
|
229
239
|
const wire = await runFusionPanels({
|
|
230
240
|
id: `panels_${sessionKey}_t${turn}`,
|
|
@@ -241,12 +251,14 @@ export async function startFusionStepGateway(input) {
|
|
|
241
251
|
...(config.timeoutMs !== undefined ? { timeoutMs: config.timeoutMs } : {})
|
|
242
252
|
});
|
|
243
253
|
const trajectories = normalizeWireTrajectories(wire);
|
|
244
|
-
|
|
245
|
-
`
|
|
254
|
+
if (gatewayChatter) {
|
|
255
|
+
console.error(`fusion: panel produced ${trajectories.length} candidate trajectories ` +
|
|
256
|
+
`(${trajectories.map((t) => `${t.model_id}:${t.status}`).join(", ")})`);
|
|
257
|
+
}
|
|
246
258
|
return trajectories;
|
|
247
259
|
}
|
|
248
260
|
catch (error) {
|
|
249
|
-
console.error(`fusion: panel run failed: ${error instanceof Error ? error.
|
|
261
|
+
console.error(`fusion: panel run failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
250
262
|
throw error;
|
|
251
263
|
}
|
|
252
264
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
import "./quiet-warnings.js";
|
package/dist/index.js
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const original = process.emitWarning.bind(process);
|
|
2
|
+
function isExperimental(warning, typeOrOptions) {
|
|
3
|
+
if (warning instanceof Error && warning.name === "ExperimentalWarning")
|
|
4
|
+
return true;
|
|
5
|
+
if (typeof typeOrOptions === "string")
|
|
6
|
+
return typeOrOptions === "ExperimentalWarning";
|
|
7
|
+
if (typeOrOptions !== null && typeof typeOrOptions === "object" && "type" in typeOrOptions) {
|
|
8
|
+
return typeOrOptions.type === "ExperimentalWarning";
|
|
9
|
+
}
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
process.emitWarning = function patchedEmitWarning(warning, typeOrOptions, ...rest) {
|
|
13
|
+
if (isExperimental(warning, typeOrOptions))
|
|
14
|
+
return;
|
|
15
|
+
original(warning, typeOrOptions, ...rest);
|
|
16
|
+
};
|
|
17
|
+
// Children inherit process.env, so make Node subprocesses (the launched coding
|
|
18
|
+
// agent, the cursor bridge, the dashboard) start with experimental warnings
|
|
19
|
+
// disabled too. Non-Node children (python via uv/uvx) ignore NODE_OPTIONS.
|
|
20
|
+
const DISABLE_EXPERIMENTAL = "--disable-warning=ExperimentalWarning";
|
|
21
|
+
if (!(process.env.NODE_OPTIONS ?? "").includes(DISABLE_EXPERIMENTAL)) {
|
|
22
|
+
process.env.NODE_OPTIONS = [process.env.NODE_OPTIONS, DISABLE_EXPERIMENTAL].filter(Boolean).join(" ");
|
|
23
|
+
}
|
|
24
|
+
export {};
|
|
@@ -4,6 +4,7 @@ export declare class PreflightError extends Error {
|
|
|
4
4
|
}
|
|
5
5
|
/** True when `bin` resolves on PATH. */
|
|
6
6
|
export declare function hasBinary(bin: string): boolean;
|
|
7
|
+
export declare const INSTALL_HINTS: Record<string, string>;
|
|
7
8
|
/**
|
|
8
9
|
* Fail fast with actionable guidance when a required binary or API key is
|
|
9
10
|
* missing. Keeps "minimal setup" honest: clear errors instead of deep stack
|
package/dist/shared/preflight.js
CHANGED
|
@@ -17,7 +17,7 @@ export function hasBinary(bin) {
|
|
|
17
17
|
return false;
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
-
const INSTALL_HINTS = {
|
|
20
|
+
export const INSTALL_HINTS = {
|
|
21
21
|
uv: "install uv: https://docs.astral.sh/uv/getting-started/installation/",
|
|
22
22
|
uvx: "install uv (ships uvx): https://docs.astral.sh/uv/getting-started/installation/",
|
|
23
23
|
codex: "install the Codex CLI: https://github.com/openai/codex",
|
package/dist/shared/proc.d.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import type { ChildProcess, SpawnOptions } from "node:child_process";
|
|
2
2
|
/** Shared process helpers for the CLI's launcher/gateway flows. */
|
|
3
3
|
export declare function sleep(ms: number): Promise<void>;
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* Reserve an ephemeral loopback port and return it. The probe socket is closed
|
|
6
|
+
* before returning (children bind it themselves), but the number is held out of
|
|
7
|
+
* circulation briefly so concurrent callers do not collide. Retries a bounded
|
|
8
|
+
* number of times if the OS hands back a number we just reserved.
|
|
9
|
+
*/
|
|
5
10
|
export declare function freePort(): Promise<number>;
|
|
6
11
|
/**
|
|
7
12
|
* Spawn a foreground tool with inherited stdio and resolve with its exit code.
|
|
@@ -9,19 +14,40 @@ export declare function freePort(): Promise<number>;
|
|
|
9
14
|
* unhandled `error` event.
|
|
10
15
|
*/
|
|
11
16
|
export declare function spawnTool(command: string, args: string[], env: Record<string, string>, cwd?: string): Promise<number>;
|
|
17
|
+
export type LoggedSpawnOptions = SpawnOptions & {
|
|
18
|
+
/** Tee the child's full stdout+stderr to this path for post-mortem. */
|
|
19
|
+
logFile?: string;
|
|
20
|
+
/** Cap the in-memory ring buffer (default 256 KiB). */
|
|
21
|
+
maxLogBytes?: number;
|
|
22
|
+
};
|
|
12
23
|
/**
|
|
13
24
|
* A spawned background child with captured output and a recorded spawn error.
|
|
14
25
|
* Always attaches an `'error'` listener so a missing binary surfaces as a clear
|
|
15
|
-
* message via {@link waitForHttp} instead of crashing the process.
|
|
26
|
+
* message via {@link waitForHttp} instead of crashing the process. The captured
|
|
27
|
+
* log is a bounded ring buffer (so long sessions cannot leak memory); the full,
|
|
28
|
+
* untruncated output is written to `logFile` when one is provided.
|
|
16
29
|
*/
|
|
17
30
|
export type LoggedChild = {
|
|
18
31
|
child: ChildProcess;
|
|
19
|
-
/** The
|
|
32
|
+
/** The most recent captured stdout+stderr, up to the ring-buffer cap. */
|
|
20
33
|
log: () => string;
|
|
21
34
|
/** The spawn `'error'` (e.g. ENOENT), if one was emitted. */
|
|
22
35
|
spawnError: () => Error | undefined;
|
|
36
|
+
/** The full log file path, when teeing was requested. */
|
|
37
|
+
logFile: () => string | undefined;
|
|
38
|
+
/** Flush and close the log file stream (best-effort). */
|
|
39
|
+
closeLog: () => void;
|
|
23
40
|
};
|
|
24
|
-
export declare function spawnLogged(command: string, args: string[], options?:
|
|
41
|
+
export declare function spawnLogged(command: string, args: string[], options?: LoggedSpawnOptions): LoggedChild;
|
|
42
|
+
/**
|
|
43
|
+
* Distill the most useful slice of captured output for an error message. Prefers
|
|
44
|
+
* lines that look like errors (so the root cause is not buried under `uvx`
|
|
45
|
+
* resolve/build noise), then falls back to the head and tail of the log. The
|
|
46
|
+
* full log lives in the child's `logFile` when one was provided.
|
|
47
|
+
*/
|
|
48
|
+
export declare function distillLog(raw: string, options?: {
|
|
49
|
+
maxLines?: number;
|
|
50
|
+
}): string;
|
|
25
51
|
/**
|
|
26
52
|
* Poll `probeUrl` until it answers (optionally requiring a 2xx), the child fails
|
|
27
53
|
* to spawn, the child exits, or the timeout elapses. Distinguishes a failed
|
|
@@ -37,5 +63,10 @@ export declare function waitForOutput(proc: LoggedChild, pattern: RegExp, option
|
|
|
37
63
|
timeoutMs: number;
|
|
38
64
|
label: string;
|
|
39
65
|
}): Promise<void>;
|
|
40
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* SIGTERM a child's whole process group, escalating to SIGKILL if it ignores the
|
|
68
|
+
* grace period. Killing the group (`process.kill(-pid, ...)`) tears down wrapper
|
|
69
|
+
* trees like `uvx -> uv -> python`; if the child was not spawned detached (no
|
|
70
|
+
* group), it falls back to signalling the child directly.
|
|
71
|
+
*/
|
|
41
72
|
export declare function terminate(child: ChildProcess, graceMs?: number): void;
|
package/dist/shared/proc.js
CHANGED
|
@@ -1,11 +1,44 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { createWriteStream } from "node:fs";
|
|
2
3
|
import { createServer } from "node:net";
|
|
3
4
|
/** Shared process helpers for the CLI's launcher/gateway flows. */
|
|
4
5
|
export function sleep(ms) {
|
|
5
6
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
6
7
|
}
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
// Ports we have handed out very recently but whose child may not have bound
|
|
9
|
+
// yet. Holding them out of circulation for a short window closes the race where
|
|
10
|
+
// two concurrent `freePort()` callers (parallel server startup) receive the
|
|
11
|
+
// same number between the probe socket closing and the child binding.
|
|
12
|
+
const recentlyReserved = new Map();
|
|
13
|
+
const RESERVATION_MS = 5000;
|
|
14
|
+
function reserve(port) {
|
|
15
|
+
const existing = recentlyReserved.get(port);
|
|
16
|
+
if (existing !== undefined)
|
|
17
|
+
clearTimeout(existing);
|
|
18
|
+
const timer = setTimeout(() => recentlyReserved.delete(port), RESERVATION_MS);
|
|
19
|
+
timer.unref();
|
|
20
|
+
recentlyReserved.set(port, timer);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Reserve an ephemeral loopback port and return it. The probe socket is closed
|
|
24
|
+
* before returning (children bind it themselves), but the number is held out of
|
|
25
|
+
* circulation briefly so concurrent callers do not collide. Retries a bounded
|
|
26
|
+
* number of times if the OS hands back a number we just reserved.
|
|
27
|
+
*/
|
|
28
|
+
export async function freePort() {
|
|
29
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
30
|
+
const port = await probeEphemeralPort();
|
|
31
|
+
if (!recentlyReserved.has(port)) {
|
|
32
|
+
reserve(port);
|
|
33
|
+
return port;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Extremely unlikely; fall back to whatever the OS last offered.
|
|
37
|
+
const port = await probeEphemeralPort();
|
|
38
|
+
reserve(port);
|
|
39
|
+
return port;
|
|
40
|
+
}
|
|
41
|
+
function probeEphemeralPort() {
|
|
9
42
|
return new Promise((resolve, reject) => {
|
|
10
43
|
const server = createServer();
|
|
11
44
|
server.on("error", reject);
|
|
@@ -32,14 +65,82 @@ export function spawnTool(command, args, env, cwd) {
|
|
|
32
65
|
child.on("exit", (code) => resolveExit(code ?? 0));
|
|
33
66
|
});
|
|
34
67
|
}
|
|
68
|
+
/** Keep at most this many bytes of a child's captured output in memory. */
|
|
69
|
+
const DEFAULT_MAX_LOG_BYTES = 256 * 1024;
|
|
35
70
|
export function spawnLogged(command, args, options = {}) {
|
|
36
|
-
const
|
|
37
|
-
|
|
71
|
+
const { logFile, maxLogBytes, ...spawnOptions } = options;
|
|
72
|
+
const cap = maxLogBytes ?? DEFAULT_MAX_LOG_BYTES;
|
|
73
|
+
// `detached: true` makes the child its own process-group leader so that
|
|
74
|
+
// `terminate()` can signal the whole tree. This matters for wrappers like
|
|
75
|
+
// `uvx` (uvx -> uv -> python): signalling only the immediate child would
|
|
76
|
+
// orphan the grandchildren. Output is still piped; we never `unref()`, so the
|
|
77
|
+
// parent keeps managing the child's lifecycle.
|
|
78
|
+
const child = spawn(command, args, { ...spawnOptions, detached: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
79
|
+
let buffer = "";
|
|
38
80
|
let spawnError;
|
|
39
|
-
|
|
40
|
-
|
|
81
|
+
let file;
|
|
82
|
+
if (logFile !== undefined) {
|
|
83
|
+
try {
|
|
84
|
+
file = createWriteStream(logFile, { flags: "a" });
|
|
85
|
+
// A broken log sink must never crash the run.
|
|
86
|
+
file.on("error", () => { });
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
file = undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const onChunk = (chunk) => {
|
|
93
|
+
const text = chunk.toString("utf8");
|
|
94
|
+
file?.write(text);
|
|
95
|
+
buffer += text;
|
|
96
|
+
if (buffer.length > cap)
|
|
97
|
+
buffer = buffer.slice(buffer.length - cap);
|
|
98
|
+
};
|
|
99
|
+
child.stdout?.on("data", onChunk);
|
|
100
|
+
child.stderr?.on("data", onChunk);
|
|
41
101
|
child.on("error", (error) => (spawnError = error));
|
|
42
|
-
return {
|
|
102
|
+
return {
|
|
103
|
+
child,
|
|
104
|
+
log: () => buffer,
|
|
105
|
+
spawnError: () => spawnError,
|
|
106
|
+
logFile: () => logFile,
|
|
107
|
+
closeLog: () => {
|
|
108
|
+
try {
|
|
109
|
+
file?.end();
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// already closed
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Distill the most useful slice of captured output for an error message. Prefers
|
|
119
|
+
* lines that look like errors (so the root cause is not buried under `uvx`
|
|
120
|
+
* resolve/build noise), then falls back to the head and tail of the log. The
|
|
121
|
+
* full log lives in the child's `logFile` when one was provided.
|
|
122
|
+
*/
|
|
123
|
+
export function distillLog(raw, options = {}) {
|
|
124
|
+
const maxLines = options.maxLines ?? 16;
|
|
125
|
+
const lines = raw.split("\n").filter((line) => line.trim().length > 0);
|
|
126
|
+
if (lines.length === 0)
|
|
127
|
+
return "";
|
|
128
|
+
const errorPattern = /error|exception|traceback|fatal|denied|unauthorized|forbidden|invalid|not found|refused|timed? ?out|missing|failed|panic|429|401|403|500/i;
|
|
129
|
+
const errorLines = lines.filter((line) => errorPattern.test(line));
|
|
130
|
+
if (errorLines.length > 0) {
|
|
131
|
+
return errorLines.slice(-maxLines).join("\n");
|
|
132
|
+
}
|
|
133
|
+
if (lines.length <= maxLines)
|
|
134
|
+
return lines.join("\n");
|
|
135
|
+
const head = lines.slice(0, Math.ceil(maxLines / 2));
|
|
136
|
+
const tail = lines.slice(-Math.floor(maxLines / 2));
|
|
137
|
+
return [...head, "...", ...tail].join("\n");
|
|
138
|
+
}
|
|
139
|
+
function failureDetail(proc) {
|
|
140
|
+
const distilled = distillLog(proc.log());
|
|
141
|
+
const logPath = proc.logFile();
|
|
142
|
+
const pathNote = logPath !== undefined ? `\n(full log: ${logPath})` : "";
|
|
143
|
+
return `${distilled}${pathNote}`;
|
|
43
144
|
}
|
|
44
145
|
/**
|
|
45
146
|
* Poll `probeUrl` until it answers (optionally requiring a 2xx), the child fails
|
|
@@ -52,10 +153,10 @@ export async function waitForHttp(probeUrl, proc, options) {
|
|
|
52
153
|
while (Date.now() < deadline) {
|
|
53
154
|
const spawnError = proc.spawnError();
|
|
54
155
|
if (spawnError !== undefined) {
|
|
55
|
-
throw new Error(`${options.label} failed to start: ${spawnError.message}\n${proc
|
|
156
|
+
throw new Error(`${options.label} failed to start: ${spawnError.message}\n${failureDetail(proc)}`);
|
|
56
157
|
}
|
|
57
158
|
if (proc.child.exitCode !== null) {
|
|
58
|
-
throw new Error(`${options.label} exited (code ${proc.child.exitCode}) before becoming ready\n${proc
|
|
159
|
+
throw new Error(`${options.label} exited (code ${proc.child.exitCode}) before becoming ready\n${failureDetail(proc)}`);
|
|
59
160
|
}
|
|
60
161
|
try {
|
|
61
162
|
const response = await fetch(probeUrl);
|
|
@@ -68,19 +169,19 @@ export async function waitForHttp(probeUrl, proc, options) {
|
|
|
68
169
|
}
|
|
69
170
|
await sleep(400);
|
|
70
171
|
}
|
|
71
|
-
throw new Error(`${options.label} did not become ready within ${options.timeoutMs}ms (${lastError})\n${proc
|
|
172
|
+
throw new Error(`${options.label} did not become ready within ${options.timeoutMs}ms (${lastError})\n${failureDetail(proc)}`);
|
|
72
173
|
}
|
|
73
174
|
/** Resolve once `pattern` is seen on the child's output, or reject on exit/timeout. */
|
|
74
175
|
export function waitForOutput(proc, pattern, options) {
|
|
75
176
|
return new Promise((resolve, reject) => {
|
|
76
177
|
const deadline = setTimeout(() => {
|
|
77
178
|
cleanup();
|
|
78
|
-
reject(new Error(`${options.label} did not start within ${options.timeoutMs}ms:\n${proc
|
|
179
|
+
reject(new Error(`${options.label} did not start within ${options.timeoutMs}ms:\n${failureDetail(proc)}`));
|
|
79
180
|
}, options.timeoutMs);
|
|
80
181
|
const poll = setInterval(() => {
|
|
81
182
|
if (proc.spawnError() !== undefined) {
|
|
82
183
|
cleanup();
|
|
83
|
-
reject(new Error(`${options.label} failed to start: ${proc.spawnError()?.message}\n${proc
|
|
184
|
+
reject(new Error(`${options.label} failed to start: ${proc.spawnError()?.message}\n${failureDetail(proc)}`));
|
|
84
185
|
}
|
|
85
186
|
else if (pattern.test(proc.log())) {
|
|
86
187
|
cleanup();
|
|
@@ -89,7 +190,7 @@ export function waitForOutput(proc, pattern, options) {
|
|
|
89
190
|
}, 100);
|
|
90
191
|
const onExit = () => {
|
|
91
192
|
cleanup();
|
|
92
|
-
reject(new Error(`${options.label} exited before becoming ready:\n${proc
|
|
193
|
+
reject(new Error(`${options.label} exited before becoming ready:\n${failureDetail(proc)}`));
|
|
93
194
|
};
|
|
94
195
|
proc.child.once("exit", onExit);
|
|
95
196
|
function cleanup() {
|
|
@@ -99,24 +200,31 @@ export function waitForOutput(proc, pattern, options) {
|
|
|
99
200
|
}
|
|
100
201
|
});
|
|
101
202
|
}
|
|
102
|
-
/**
|
|
203
|
+
/**
|
|
204
|
+
* SIGTERM a child's whole process group, escalating to SIGKILL if it ignores the
|
|
205
|
+
* grace period. Killing the group (`process.kill(-pid, ...)`) tears down wrapper
|
|
206
|
+
* trees like `uvx -> uv -> python`; if the child was not spawned detached (no
|
|
207
|
+
* group), it falls back to signalling the child directly.
|
|
208
|
+
*/
|
|
103
209
|
export function terminate(child, graceMs = 5000) {
|
|
104
|
-
if (child.exitCode !== null || child.signalCode !== null)
|
|
105
|
-
return;
|
|
106
|
-
try {
|
|
107
|
-
child.kill("SIGTERM");
|
|
108
|
-
}
|
|
109
|
-
catch {
|
|
210
|
+
if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null)
|
|
110
211
|
return;
|
|
111
|
-
|
|
112
|
-
const
|
|
212
|
+
const pid = child.pid;
|
|
213
|
+
const signal = (sig) => {
|
|
113
214
|
try {
|
|
114
|
-
|
|
215
|
+
process.kill(-pid, sig);
|
|
115
216
|
}
|
|
116
217
|
catch {
|
|
117
|
-
|
|
218
|
+
try {
|
|
219
|
+
child.kill(sig);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
// already gone
|
|
223
|
+
}
|
|
118
224
|
}
|
|
119
|
-
}
|
|
225
|
+
};
|
|
226
|
+
signal("SIGTERM");
|
|
227
|
+
const timer = setTimeout(() => signal("SIGKILL"), graceMs);
|
|
120
228
|
timer.unref();
|
|
121
229
|
child.once("exit", () => clearTimeout(timer));
|
|
122
230
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { after, test } from "node:test";
|
|
6
|
+
import { FUSION_CONFIG_VERSION, FusionConfigError, fusionConfigPath, loadFusionConfig, parseFusionConfig, writeFusionConfig } from "../fusion-config.js";
|
|
7
|
+
const tmpRoots = [];
|
|
8
|
+
function freshDir() {
|
|
9
|
+
const dir = mkdtempSync(join(tmpdir(), "fusion-config-"));
|
|
10
|
+
tmpRoots.push(dir);
|
|
11
|
+
return dir;
|
|
12
|
+
}
|
|
13
|
+
after(() => {
|
|
14
|
+
for (const dir of tmpRoots)
|
|
15
|
+
rmSync(dir, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
test("parseFusionConfig accepts a valid config", () => {
|
|
18
|
+
const raw = {
|
|
19
|
+
version: FUSION_CONFIG_VERSION,
|
|
20
|
+
tool: "codex",
|
|
21
|
+
panel: [{ id: "gpt", model: "gpt-5.5", provider: "openai", keyEnv: "OPENAI_API_KEY" }],
|
|
22
|
+
judgeModel: "gpt-5.5",
|
|
23
|
+
local: false,
|
|
24
|
+
observe: true,
|
|
25
|
+
port: 1234
|
|
26
|
+
};
|
|
27
|
+
const config = parseFusionConfig(raw, "test");
|
|
28
|
+
assert.equal(config.tool, "codex");
|
|
29
|
+
assert.equal(config.panel?.length, 1);
|
|
30
|
+
assert.equal(config.panel?.[0]?.provider, "openai");
|
|
31
|
+
assert.equal(config.observe, true);
|
|
32
|
+
assert.equal(config.port, 1234);
|
|
33
|
+
});
|
|
34
|
+
test("parseFusionConfig rejects an unsupported version", () => {
|
|
35
|
+
assert.throws(() => parseFusionConfig({ version: "nope" }, "test"), FusionConfigError);
|
|
36
|
+
});
|
|
37
|
+
test("parseFusionConfig rejects an unknown panel provider", () => {
|
|
38
|
+
assert.throws(() => parseFusionConfig({ version: FUSION_CONFIG_VERSION, panel: [{ id: "x", model: "m", provider: "bogus" }] }, "test"), /panel\[0\]\.provider/);
|
|
39
|
+
});
|
|
40
|
+
test("parseFusionConfig rejects a panel entry missing model", () => {
|
|
41
|
+
assert.throws(() => parseFusionConfig({ version: FUSION_CONFIG_VERSION, panel: [{ id: "x" }] }, "test"), /panel\[0\]\.model/);
|
|
42
|
+
});
|
|
43
|
+
test("parseFusionConfig rejects a non-object", () => {
|
|
44
|
+
assert.throws(() => parseFusionConfig(["not", "an", "object"], "test"), FusionConfigError);
|
|
45
|
+
});
|
|
46
|
+
test("loadFusionConfig returns undefined when the file is absent", () => {
|
|
47
|
+
assert.equal(loadFusionConfig(freshDir()), undefined);
|
|
48
|
+
});
|
|
49
|
+
test("write then load round-trips the config", () => {
|
|
50
|
+
const dir = freshDir();
|
|
51
|
+
const config = {
|
|
52
|
+
version: FUSION_CONFIG_VERSION,
|
|
53
|
+
tool: "claude",
|
|
54
|
+
panel: [
|
|
55
|
+
{ id: "gpt", model: "gpt-5.5", provider: "openai", keyEnv: "OPENAI_API_KEY" },
|
|
56
|
+
{ id: "sonnet", model: "claude-sonnet-4-6", provider: "anthropic", keyEnv: "ANTHROPIC_API_KEY" }
|
|
57
|
+
],
|
|
58
|
+
judgeModel: "gpt-5.5",
|
|
59
|
+
local: false,
|
|
60
|
+
observe: false
|
|
61
|
+
};
|
|
62
|
+
const path = writeFusionConfig(dir, config);
|
|
63
|
+
assert.equal(path, fusionConfigPath(dir));
|
|
64
|
+
const loaded = loadFusionConfig(dir);
|
|
65
|
+
assert.deepEqual(loaded, config);
|
|
66
|
+
});
|
|
67
|
+
test("writeFusionConfig refuses to clobber without force, overwrites with it", () => {
|
|
68
|
+
const dir = freshDir();
|
|
69
|
+
const base = { version: FUSION_CONFIG_VERSION, tool: "codex" };
|
|
70
|
+
writeFusionConfig(dir, base);
|
|
71
|
+
assert.throws(() => writeFusionConfig(dir, { version: FUSION_CONFIG_VERSION, tool: "claude" }), FusionConfigError);
|
|
72
|
+
writeFusionConfig(dir, { version: FUSION_CONFIG_VERSION, tool: "claude" }, { force: true });
|
|
73
|
+
const reloaded = loadFusionConfig(dir);
|
|
74
|
+
assert.equal(reloaded?.tool, "claude");
|
|
75
|
+
});
|
|
76
|
+
test("loadFusionConfig surfaces invalid JSON as a FusionConfigError", () => {
|
|
77
|
+
const dir = freshDir();
|
|
78
|
+
writeFileSync(fusionConfigPath(dir), "{ this is not json");
|
|
79
|
+
assert.throws(() => loadFusionConfig(dir), FusionConfigError);
|
|
80
|
+
});
|
package/dist/test/proc.test.js
CHANGED
|
@@ -1,11 +1,33 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { test } from "node:test";
|
|
3
|
-
import { freePort, spawnLogged, spawnTool, waitForHttp } from "../shared/proc.js";
|
|
3
|
+
import { distillLog, freePort, spawnLogged, spawnTool, waitForHttp } from "../shared/proc.js";
|
|
4
4
|
const MISSING_BINARY = "warrant-definitely-not-a-real-binary-xyz";
|
|
5
5
|
test("freePort returns a usable ephemeral port", async () => {
|
|
6
6
|
const port = await freePort();
|
|
7
7
|
assert.ok(Number.isInteger(port) && port > 0);
|
|
8
8
|
});
|
|
9
|
+
test("freePort does not hand the same number to concurrent callers", async () => {
|
|
10
|
+
const ports = await Promise.all(Array.from({ length: 8 }, () => freePort()));
|
|
11
|
+
assert.equal(new Set(ports).size, ports.length, "all concurrently reserved ports must be distinct");
|
|
12
|
+
});
|
|
13
|
+
test("distillLog prefers error-looking lines over surrounding noise", () => {
|
|
14
|
+
const log = [
|
|
15
|
+
"resolving fusionkit@0.1.0",
|
|
16
|
+
"building wheels...",
|
|
17
|
+
"downloading numpy",
|
|
18
|
+
"Error: invalid API key for provider openai",
|
|
19
|
+
"extra trailing noise line"
|
|
20
|
+
].join("\n");
|
|
21
|
+
const distilled = distillLog(log);
|
|
22
|
+
assert.match(distilled, /invalid API key/);
|
|
23
|
+
});
|
|
24
|
+
test("distillLog falls back to head and tail when no errors are present", () => {
|
|
25
|
+
const lines = Array.from({ length: 40 }, (_, i) => `line ${i}`);
|
|
26
|
+
const distilled = distillLog(lines.join("\n"), { maxLines: 6 });
|
|
27
|
+
assert.match(distilled, /line 0/);
|
|
28
|
+
assert.match(distilled, /line 39/);
|
|
29
|
+
assert.match(distilled, /\.\.\./);
|
|
30
|
+
});
|
|
9
31
|
test("spawnTool resolves with the child's exit code", async () => {
|
|
10
32
|
assert.equal(await spawnTool(process.execPath, ["-e", "process.exit(3)"], {}), 3);
|
|
11
33
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { isInteractive } from "../ui/runtime.js";
|
|
4
|
+
import { bold, cyan, glyph, supportsColor } from "../ui/theme.js";
|
|
5
|
+
test("color helpers no-op and glyphs use ASCII when color is disabled", () => {
|
|
6
|
+
const prev = process.env.NO_COLOR;
|
|
7
|
+
process.env.NO_COLOR = "1";
|
|
8
|
+
try {
|
|
9
|
+
assert.equal(supportsColor(), false);
|
|
10
|
+
assert.equal(bold("hi"), "hi");
|
|
11
|
+
assert.equal(cyan("hi"), "hi");
|
|
12
|
+
assert.equal(glyph.tick(), "[ok]");
|
|
13
|
+
assert.equal(glyph.cross(), "[x]");
|
|
14
|
+
}
|
|
15
|
+
finally {
|
|
16
|
+
if (prev === undefined)
|
|
17
|
+
delete process.env.NO_COLOR;
|
|
18
|
+
else
|
|
19
|
+
process.env.NO_COLOR = prev;
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
test("isInteractive is false under the test runner (no TTY)", () => {
|
|
23
|
+
assert.equal(isInteractive(), false);
|
|
24
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { StackReporter } from "../fusion-quickstart.js";
|
|
2
|
+
export type BootView = {
|
|
3
|
+
/** Feed this to `startFusionStack({ report })` to drive the live checklist. */
|
|
4
|
+
report: StackReporter;
|
|
5
|
+
/** Settle the checklist and leave the final frame in place. */
|
|
6
|
+
stop: () => void;
|
|
7
|
+
};
|
|
8
|
+
export type BootServer = {
|
|
9
|
+
id: string;
|
|
10
|
+
label: string;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* A live boot checklist: one row per panel server, an optional synthesizer row,
|
|
14
|
+
* and the gateway row. Maps {@link StackEvent}s onto a {@link StepList} so the
|
|
15
|
+
* user watches the real stack come up (spinners, elapsed time, check marks)
|
|
16
|
+
* instead of staring at a silent pause.
|
|
17
|
+
*/
|
|
18
|
+
export declare function createBootView(input: {
|
|
19
|
+
servers: readonly BootServer[];
|
|
20
|
+
includeSynth: boolean;
|
|
21
|
+
includeDashboard?: boolean;
|
|
22
|
+
title?: string;
|
|
23
|
+
}): BootView;
|
package/dist/ui/boot.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { StepList } from "./steps.js";
|
|
2
|
+
/**
|
|
3
|
+
* A live boot checklist: one row per panel server, an optional synthesizer row,
|
|
4
|
+
* and the gateway row. Maps {@link StackEvent}s onto a {@link StepList} so the
|
|
5
|
+
* user watches the real stack come up (spinners, elapsed time, check marks)
|
|
6
|
+
* instead of staring at a silent pause.
|
|
7
|
+
*/
|
|
8
|
+
export function createBootView(input) {
|
|
9
|
+
const steps = [
|
|
10
|
+
...(input.includeDashboard === true ? [{ id: "dashboard", label: "observability dashboard" }] : []),
|
|
11
|
+
...input.servers.map((server) => ({ id: `srv:${server.id}`, label: `panel · ${server.label}` })),
|
|
12
|
+
...(input.includeSynth ? [{ id: "synth", label: "synthesizer (fusionkit serve)" }] : []),
|
|
13
|
+
{ id: "gw", label: "fusion gateway" }
|
|
14
|
+
];
|
|
15
|
+
const list = new StepList(steps, input.title !== undefined ? { title: input.title } : {});
|
|
16
|
+
list.start();
|
|
17
|
+
const report = (event) => {
|
|
18
|
+
switch (event.kind) {
|
|
19
|
+
case "dashboard.start":
|
|
20
|
+
list.setActive("dashboard");
|
|
21
|
+
break;
|
|
22
|
+
case "dashboard.ready":
|
|
23
|
+
list.setDone("dashboard", event.detail);
|
|
24
|
+
break;
|
|
25
|
+
case "dashboard.fail":
|
|
26
|
+
list.setFailed("dashboard", event.detail);
|
|
27
|
+
break;
|
|
28
|
+
case "server.start":
|
|
29
|
+
list.setActive(`srv:${event.id}`);
|
|
30
|
+
break;
|
|
31
|
+
case "server.ready":
|
|
32
|
+
list.setDone(`srv:${event.id}`, event.detail);
|
|
33
|
+
break;
|
|
34
|
+
case "server.fail":
|
|
35
|
+
list.setFailed(`srv:${event.id}`, event.detail);
|
|
36
|
+
break;
|
|
37
|
+
case "synth.start":
|
|
38
|
+
list.setActive("synth");
|
|
39
|
+
break;
|
|
40
|
+
case "synth.ready":
|
|
41
|
+
list.setDone("synth", event.detail);
|
|
42
|
+
break;
|
|
43
|
+
case "gateway.start":
|
|
44
|
+
list.setActive("gw");
|
|
45
|
+
break;
|
|
46
|
+
case "gateway.ready":
|
|
47
|
+
list.setDone("gw", event.detail);
|
|
48
|
+
break;
|
|
49
|
+
default: {
|
|
50
|
+
const exhaustive = event;
|
|
51
|
+
throw new Error(`unknown stack event: ${String(exhaustive)}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
return { report, stop: () => list.stop() };
|
|
56
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** The fusionkit CLI's zero-dependency terminal UI layer. */
|
|
2
|
+
export * from "./theme.js";
|
|
3
|
+
export * from "./runtime.js";
|
|
4
|
+
export { Spinner, withSpinner } from "./spinner.js";
|
|
5
|
+
export { StepList } from "./steps.js";
|
|
6
|
+
export type { StepInput, StepStatus } from "./steps.js";
|
|
7
|
+
export { select, confirm, text, done, note } from "./prompt.js";
|
|
8
|
+
export type { SelectOption } from "./prompt.js";
|
package/dist/ui/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** The fusionkit CLI's zero-dependency terminal UI layer. */
|
|
2
|
+
export * from "./theme.js";
|
|
3
|
+
export * from "./runtime.js";
|
|
4
|
+
export { Spinner, withSpinner } from "./spinner.js";
|
|
5
|
+
export { StepList } from "./steps.js";
|
|
6
|
+
export { select, confirm, text, done, note } from "./prompt.js";
|