@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.
Files changed (42) hide show
  1. package/README.md +77 -0
  2. package/dist/cli.d.ts +0 -6
  3. package/dist/cli.js +16 -0
  4. package/dist/commands/doctor.d.ts +2 -0
  5. package/dist/commands/doctor.js +136 -0
  6. package/dist/commands/fusion.js +70 -12
  7. package/dist/fusion-config.d.ts +28 -0
  8. package/dist/fusion-config.js +133 -0
  9. package/dist/fusion-init.d.ts +4 -0
  10. package/dist/fusion-init.js +119 -0
  11. package/dist/fusion-quickstart.d.ts +48 -0
  12. package/dist/fusion-quickstart.js +340 -131
  13. package/dist/gateway.d.ts +2 -0
  14. package/dist/gateway.js +16 -4
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.js +1 -0
  17. package/dist/quiet-warnings.d.ts +1 -0
  18. package/dist/quiet-warnings.js +24 -0
  19. package/dist/shared/preflight.d.ts +1 -0
  20. package/dist/shared/preflight.js +1 -1
  21. package/dist/shared/proc.d.ts +36 -5
  22. package/dist/shared/proc.js +133 -25
  23. package/dist/test/fusion-config.test.d.ts +1 -0
  24. package/dist/test/fusion-config.test.js +80 -0
  25. package/dist/test/proc.test.js +23 -1
  26. package/dist/test/ui.test.d.ts +1 -0
  27. package/dist/test/ui.test.js +24 -0
  28. package/dist/ui/boot.d.ts +23 -0
  29. package/dist/ui/boot.js +56 -0
  30. package/dist/ui/index.d.ts +8 -0
  31. package/dist/ui/index.js +6 -0
  32. package/dist/ui/prompt.d.ts +30 -0
  33. package/dist/ui/prompt.js +178 -0
  34. package/dist/ui/runtime.d.ts +14 -0
  35. package/dist/ui/runtime.js +33 -0
  36. package/dist/ui/spinner.d.ts +31 -0
  37. package/dist/ui/spinner.js +102 -0
  38. package/dist/ui/steps.d.ts +38 -0
  39. package/dist/ui/steps.js +149 -0
  40. package/dist/ui/theme.d.ts +35 -0
  41. package/dist/ui/theme.js +52 -0
  42. 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
- console.error(`fusion: running panel (${config.models.map((m) => m.id).join(", ")}) for session ${sessionKey}...`);
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
- console.error(`fusion: panel produced ${trajectories.length} candidate trajectories ` +
245
- `(${trajectories.map((t) => `${t.model_id}:${t.status}`).join(", ")})`);
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.stack : String(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
- export {};
2
+ import "./quiet-warnings.js";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import "./quiet-warnings.js";
2
3
  import { PolicyDeniedError } from "@fusionkit/protocol";
3
4
  import { buildProgram } from "./cli.js";
4
5
  import { PreflightError } from "./shared/preflight.js";
@@ -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
@@ -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",
@@ -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
- /** Reserve an ephemeral loopback port and return it (closed before returning). */
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 combined stdout+stderr captured so far. */
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?: SpawnOptions): LoggedChild;
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
- /** SIGTERM a child, escalating to SIGKILL if it ignores the grace period. */
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;
@@ -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
- /** Reserve an ephemeral loopback port and return it (closed before returning). */
8
- export function freePort() {
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 child = spawn(command, args, { ...options, stdio: ["ignore", "pipe", "pipe"] });
37
- let log = "";
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
- child.stdout?.on("data", (chunk) => (log += chunk.toString("utf8")));
40
- child.stderr?.on("data", (chunk) => (log += chunk.toString("utf8")));
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 { child, log: () => log, spawnError: () => spawnError };
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.log().slice(-500)}`);
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.log().slice(-500)}`);
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.log().slice(-500)}`);
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.log().slice(-500)}`));
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.log().slice(-500)}`));
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.log().slice(-500)}`));
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
- /** SIGTERM a child, escalating to SIGKILL if it ignores the grace period. */
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 timer = setTimeout(() => {
212
+ const pid = child.pid;
213
+ const signal = (sig) => {
113
214
  try {
114
- child.kill("SIGKILL");
215
+ process.kill(-pid, sig);
115
216
  }
116
217
  catch {
117
- // already gone
218
+ try {
219
+ child.kill(sig);
220
+ }
221
+ catch {
222
+ // already gone
223
+ }
118
224
  }
119
- }, graceMs);
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
+ });
@@ -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;
@@ -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";
@@ -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";