@fusionkit/cli 0.1.4 → 0.1.6

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 (74) hide show
  1. package/README.md +26 -4
  2. package/dist/cli.d.ts +1 -0
  3. package/dist/cli.js +4 -17
  4. package/dist/commands/ensemble-gateway.js +0 -2
  5. package/dist/commands/ensemble-records.d.ts +2 -1
  6. package/dist/commands/ensemble-records.js +3 -1
  7. package/dist/commands/ensemble.js +3 -4
  8. package/dist/commands/fusion.js +14 -15
  9. package/dist/commands/local.js +3 -3
  10. package/dist/cursor-acp.d.ts +18 -0
  11. package/dist/cursor-acp.js +206 -0
  12. package/dist/dashboard.d.ts +65 -0
  13. package/dist/dashboard.js +587 -0
  14. package/dist/fusion/env.d.ts +108 -0
  15. package/dist/fusion/env.js +98 -0
  16. package/dist/fusion/observability.d.ts +39 -0
  17. package/dist/fusion/observability.js +227 -0
  18. package/dist/fusion/preflight.d.ts +12 -0
  19. package/dist/fusion/preflight.js +42 -0
  20. package/dist/fusion/stack.d.ts +62 -0
  21. package/dist/fusion/stack.js +295 -0
  22. package/dist/fusion-config.d.ts +0 -1
  23. package/dist/fusion-config.js +0 -6
  24. package/dist/fusion-init.js +2 -11
  25. package/dist/fusion-quickstart.d.ts +11 -222
  26. package/dist/fusion-quickstart.js +57 -759
  27. package/dist/gateway.d.ts +0 -2
  28. package/dist/gateway.js +12 -2
  29. package/dist/local.d.ts +10 -17
  30. package/dist/local.js +50 -116
  31. package/dist/shared/options.d.ts +2 -1
  32. package/dist/shared/options.js +13 -19
  33. package/dist/shared/proc.d.ts +4 -70
  34. package/dist/shared/proc.js +3 -228
  35. package/dist/test/cli.test.js +32 -142
  36. package/dist/test/dashboard.test.d.ts +1 -0
  37. package/dist/test/dashboard.test.js +214 -0
  38. package/dist/test/gateway-e2e.test.js +13 -10
  39. package/dist/test/local.test.js +4 -4
  40. package/dist/tools.d.ts +2 -0
  41. package/dist/tools.js +25 -0
  42. package/package.json +14 -9
  43. package/scope/.next/BUILD_ID +1 -1
  44. package/scope/.next/app-build-manifest.json +12 -12
  45. package/scope/.next/app-path-routes-manifest.json +3 -3
  46. package/scope/.next/build-manifest.json +2 -2
  47. package/scope/.next/prerender-manifest.json +16 -16
  48. package/scope/.next/server/app/_not-found.html +1 -1
  49. package/scope/.next/server/app/_not-found.rsc +1 -1
  50. package/scope/.next/server/app/environments.html +1 -1
  51. package/scope/.next/server/app/environments.rsc +1 -1
  52. package/scope/.next/server/app/index.html +1 -1
  53. package/scope/.next/server/app/index.rsc +1 -1
  54. package/scope/.next/server/app/models.html +1 -1
  55. package/scope/.next/server/app/models.rsc +1 -1
  56. package/scope/.next/server/app-paths-manifest.json +3 -3
  57. package/scope/.next/server/functions-config-manifest.json +2 -2
  58. package/scope/.next/server/pages/404.html +1 -1
  59. package/scope/.next/server/pages/500.html +1 -1
  60. package/scope/.next/server/server-reference-manifest.json +1 -1
  61. package/dist/commands/init.d.ts +0 -2
  62. package/dist/commands/init.js +0 -24
  63. package/dist/commands/lifecycle.d.ts +0 -2
  64. package/dist/commands/lifecycle.js +0 -124
  65. package/dist/commands/plane.d.ts +0 -2
  66. package/dist/commands/plane.js +0 -38
  67. package/dist/commands/run.d.ts +0 -2
  68. package/dist/commands/run.js +0 -149
  69. package/dist/commands/runner.d.ts +0 -2
  70. package/dist/commands/runner.js +0 -33
  71. package/dist/commands/secrets.d.ts +0 -2
  72. package/dist/commands/secrets.js +0 -21
  73. /package/scope/.next/static/{5tnFLuvnSbNZNtqRgoot8 → x7wPUCpgS31-5ZHJkcKsU}/_buildManifest.js +0 -0
  74. /package/scope/.next/static/{5tnFLuvnSbNZNtqRgoot8 → x7wPUCpgS31-5ZHJkcKsU}/_ssgManifest.js +0 -0
package/dist/gateway.d.ts CHANGED
@@ -16,7 +16,6 @@ export type GatewayRunnerConfig = {
16
16
  command?: string;
17
17
  timeoutMs?: number;
18
18
  judgeModel?: string;
19
- cursorKitDir?: string;
20
19
  fusionApiKey?: string;
21
20
  modelEndpoints?: Record<string, string>;
22
21
  };
@@ -53,7 +52,6 @@ export type GatewayAcceptanceInput = {
53
52
  sentinel: string;
54
53
  host: string;
55
54
  outPath: string;
56
- cursorKitUrl?: string;
57
55
  };
58
56
  export declare function runGatewayAcceptance(input: GatewayAcceptanceInput): Promise<{
59
57
  reportPath: string;
package/dist/gateway.js CHANGED
@@ -10,6 +10,7 @@ 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
+ import { buildCursorAcpProducer } from "./cursor-acp.js";
13
14
  // Once an interactive coding agent owns the terminal, the per-turn panel chatter
14
15
  // would corrupt its full-screen TUI. The launcher flips this off before handing
15
16
  // over; trace events (for --observe) keep flowing regardless.
@@ -90,7 +91,6 @@ export function buildFrontDoorRunner(config) {
90
91
  ...(config.command !== undefined ? { command: config.command } : {}),
91
92
  ...(config.timeoutMs !== undefined ? { timeoutMs: config.timeoutMs } : {}),
92
93
  ...(config.judgeModel !== undefined ? { judgeModel: config.judgeModel } : {}),
93
- ...(config.cursorKitDir !== undefined ? { cursorKitDir: config.cursorKitDir } : {}),
94
94
  ...(config.fusionApiKey !== undefined ? { fusionApiKey: config.fusionApiKey } : {}),
95
95
  ...(config.modelEndpoints !== undefined ? { modelEndpoints: config.modelEndpoints } : {})
96
96
  });
@@ -293,10 +293,20 @@ export async function runGatewayAcceptance(input) {
293
293
  port: 0
294
294
  });
295
295
  try {
296
+ const cursorAcp = buildCursorAcpProducer({
297
+ gatewayUrl: gateway.url(),
298
+ sentinel: input.sentinel,
299
+ repo: input.config.repo,
300
+ ...(input.config.models[0]?.id !== undefined
301
+ ? { modelName: input.config.models[0].id }
302
+ : {}),
303
+ ...(input.config.timeoutMs !== undefined ? { timeoutMs: input.config.timeoutMs } : {})
304
+ });
296
305
  const report = await runFrontDoorAcceptance({
297
306
  gatewayUrl: gateway.url(),
298
307
  sentinel: input.sentinel,
299
- acpRunner: buildAcpRunner(input.config)
308
+ acpRunner: buildAcpRunner(input.config),
309
+ ...(cursorAcp !== undefined ? { cursorAcp } : {})
300
310
  });
301
311
  mkdirSync(resolve(input.outPath, ".."), { recursive: true });
302
312
  writeFileSync(input.outPath, JSON.stringify(report, null, 2) + "\n");
package/dist/local.d.ts CHANGED
@@ -6,26 +6,19 @@ import type { BackendConfig } from "@fusionkit/model-gateway";
6
6
  * (environment, config file, or — for Cursor — IDE settings + a public
7
7
  * tunnel), then execs the real binary with the user's own arguments.
8
8
  *
9
- * The shim builders below are pure so they can be unit-tested; the dispatcher
10
- * (`runLocal`) wires them to a started gateway and the real child process.
9
+ * The per-tool launch + shim logic now lives in the `@fusionkit/tool-*`
10
+ * packages; this dispatcher wires a started gateway into a ToolLaunchContext.
11
11
  */
12
- export type LocalTool = "claude" | "codex" | "opencode" | "cursor" | "serve";
12
+ /** A launchable local tool id from the registry, or the `serve` pseudo-tool. */
13
+ export type LocalTool = string;
14
+ /** Launchable local tools (registry-derived) plus the `serve` pseudo-tool. */
13
15
  export declare const LOCAL_TOOLS: readonly LocalTool[];
14
- /** Environment for Claude Code: point it at the gateway's Anthropic surface. */
15
- export declare function claudeEnv(gatewayUrl: string, authToken?: string): Record<string, string>;
16
- /**
17
- * Codex config.toml fragment defining the gateway as a Responses provider.
18
- * Written into an ephemeral CODEX_HOME so the user's own config is untouched.
19
- */
20
- export declare function codexConfigToml(gatewayUrl: string, model: string): string;
21
- /** opencode config registering the gateway as an OpenAI-compatible provider. */
22
- export declare function opencodeConfig(gatewayUrl: string, model: string): Record<string, unknown>;
23
- /** The opencode `--model provider/model` argument for the gateway provider. */
24
- export declare function opencodeModelArg(model: string): string;
25
- /** Human-facing setup for Cursor (IDE plan/chat panel only; needs a public URL). */
26
- export declare function cursorInstructions(publicUrl: string, model: string): string;
16
+ export { claudeEnv } from "@fusionkit/tool-claude";
17
+ export { codexLaunchConfigToml as codexConfigToml } from "@fusionkit/tool-codex";
18
+ export { opencodeConfig, opencodeModelArg } from "@fusionkit/tool-opencode";
19
+ export { cursorInstructions } from "@fusionkit/tool-cursor";
27
20
  export type RunLocalOptions = {
28
- /** Public URL for Cursor's tunnel (or WARRANT_PUBLIC_URL). */
21
+ /** Public URL for Cursor's tunnel (or FUSIONKIT_PUBLIC_URL). */
29
22
  publicUrl?: string;
30
23
  /** Bearer token to require on the gateway. */
31
24
  authToken?: string;
package/dist/local.js CHANGED
@@ -1,74 +1,19 @@
1
- import { mkdtempSync, writeFileSync } from "node:fs";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
1
  import { createBackend, resolveBackendConfig, startGateway } from "@fusionkit/model-gateway";
5
- import { spawnTool } from "./shared/proc.js";
6
- export const LOCAL_TOOLS = ["claude", "codex", "opencode", "cursor", "serve"];
7
- /** The label a tool uses for the local model in its own UI. */
8
- const LOCAL_MODEL_LABEL = "warrant-local";
2
+ import { LOCAL_MODEL_LABEL, readEnv } from "@fusionkit/tools";
3
+ import { toolRegistry } from "./tools.js";
4
+ /** Launchable local tools (registry-derived) plus the `serve` pseudo-tool. */
5
+ export const LOCAL_TOOLS = [
6
+ ...toolRegistry.launchableLocal().map((tool) => tool.id),
7
+ "serve"
8
+ ];
9
9
  function backendModel(config) {
10
10
  return config.kind === "mlx" ? config.model : config.defaultModel ?? LOCAL_MODEL_LABEL;
11
11
  }
12
- // ---- pure shim builders (unit-tested) ----
13
- /** Environment for Claude Code: point it at the gateway's Anthropic surface. */
14
- export function claudeEnv(gatewayUrl, authToken) {
15
- return {
16
- ANTHROPIC_BASE_URL: gatewayUrl,
17
- ANTHROPIC_AUTH_TOKEN: authToken ?? "warrant-local",
18
- // Surface the local model in the /model picker (Anthropic discovery).
19
- CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1"
20
- };
21
- }
22
- /**
23
- * Codex config.toml fragment defining the gateway as a Responses provider.
24
- * Written into an ephemeral CODEX_HOME so the user's own config is untouched.
25
- */
26
- export function codexConfigToml(gatewayUrl, model) {
27
- return [
28
- `model = "${model}"`,
29
- `model_provider = "${LOCAL_MODEL_LABEL}"`,
30
- "",
31
- `[model_providers.${LOCAL_MODEL_LABEL}]`,
32
- `name = "Warrant local"`,
33
- `base_url = "${gatewayUrl}/v1"`,
34
- `wire_api = "responses"`,
35
- `requires_openai_auth = false`,
36
- ""
37
- ].join("\n");
38
- }
39
- /** opencode config registering the gateway as an OpenAI-compatible provider. */
40
- export function opencodeConfig(gatewayUrl, model) {
41
- return {
42
- $schema: "https://opencode.ai/config.json",
43
- provider: {
44
- [LOCAL_MODEL_LABEL]: {
45
- npm: "@ai-sdk/openai-compatible",
46
- name: "Warrant local",
47
- options: { baseURL: `${gatewayUrl}/v1` },
48
- models: { [model]: { name: model } }
49
- }
50
- }
51
- };
52
- }
53
- /** The opencode `--model provider/model` argument for the gateway provider. */
54
- export function opencodeModelArg(model) {
55
- return `${LOCAL_MODEL_LABEL}/${model}`;
56
- }
57
- /** Human-facing setup for Cursor (IDE plan/chat panel only; needs a public URL). */
58
- export function cursorInstructions(publicUrl, model) {
59
- return [
60
- "Cursor backs only its plan/chat panel with a custom model, and cannot reach",
61
- "localhost — so this uses a public tunnel. In Cursor: Settings -> Models ->",
62
- "enable 'Override OpenAI Base URL', then set:",
63
- "",
64
- ` Override OpenAI Base URL : ${publicUrl}/v1`,
65
- ` Model name : ${model}`,
66
- ` OpenAI API Key : warrant-local (any non-empty value)`,
67
- "",
68
- "Use the chat/plan panel (Cmd/Ctrl+L). Composer, inline edit, apply, and",
69
- "autocomplete remain on Cursor's own backend and are not affected."
70
- ].join("\n");
71
- }
12
+ // ---- pure shim builders (re-exported from the per-tool packages) ----
13
+ export { claudeEnv } from "@fusionkit/tool-claude";
14
+ export { codexLaunchConfigToml as codexConfigToml } from "@fusionkit/tool-codex";
15
+ export { opencodeConfig, opencodeModelArg } from "@fusionkit/tool-opencode";
16
+ export { cursorInstructions } from "@fusionkit/tool-cursor";
72
17
  async function startLocalGateway(config, authToken) {
73
18
  const backend = createBackend(config);
74
19
  const gateway = await startGateway({
@@ -85,60 +30,49 @@ export async function runLocal(tool, toolArgs, options = {}) {
85
30
  const log = options.log ?? ((line) => console.error(line));
86
31
  const config = options.config ?? resolveBackendConfig();
87
32
  const model = backendModel(config);
33
+ const publicUrl = options.publicUrl ?? readEnv(process.env, "FUSIONKIT_PUBLIC_URL");
88
34
  const gateway = await startLocalGateway(config, options.authToken);
89
- log(`warrant local: gateway on ${gateway.url} (model: ${model})`);
35
+ log(`fusionkit local: gateway on ${gateway.url} (model: ${model})`);
36
+ const disposers = [];
90
37
  try {
91
- switch (tool) {
92
- case "serve": {
93
- log(`OpenAI: ${gateway.url}/v1`);
94
- log(`Anthropic: ${gateway.url}/v1/messages`);
95
- log(`Responses: ${gateway.url}/v1/responses`);
96
- log("Press Ctrl+C to stop.");
97
- await new Promise(() => {
98
- /* run until interrupted */
99
- });
100
- return 0;
101
- }
102
- case "claude":
103
- return await spawnTool("claude", toolArgs, claudeEnv(gateway.url, options.authToken));
104
- case "codex": {
105
- const home = mkdtempSync(join(tmpdir(), "warrant-codex-"));
106
- writeFileSync(join(home, "config.toml"), codexConfigToml(gateway.url, model));
107
- return await spawnTool("codex", toolArgs, { CODEX_HOME: home });
108
- }
109
- case "opencode": {
110
- const dir = mkdtempSync(join(tmpdir(), "warrant-opencode-"));
111
- const configPath = join(dir, "opencode.json");
112
- writeFileSync(configPath, JSON.stringify(opencodeConfig(gateway.url, model), null, 2));
113
- const args = toolArgs.includes("--model") ? toolArgs : ["--model", opencodeModelArg(model), ...toolArgs];
114
- return await spawnTool("opencode", args, { OPENCODE_CONFIG: configPath });
115
- }
116
- case "cursor": {
117
- const publicUrl = options.publicUrl ?? process.env.WARRANT_PUBLIC_URL;
118
- if (publicUrl === undefined || publicUrl.length === 0) {
119
- log("");
120
- log("Cursor needs a public URL (it cannot reach localhost). Start a tunnel to");
121
- log(`${gateway.url} (e.g. 'cloudflared tunnel --url ${gateway.url}' or 'ngrok http`);
122
- log(`${gateway.url.replace(/^https?:\/\//, "")}'), then re-run with --public-url <url>`);
123
- log("or set WARRANT_PUBLIC_URL.");
124
- return 1;
125
- }
126
- log("");
127
- log(cursorInstructions(publicUrl, model));
128
- log("");
129
- log("Gateway is running; leave this process up while you use Cursor. Ctrl+C to stop.");
130
- await new Promise(() => {
131
- /* keep the gateway (and tunnel target) alive */
132
- });
133
- return 0;
134
- }
135
- default: {
136
- const unreachable = tool;
137
- throw new Error(`unknown local tool: ${String(unreachable)}`);
138
- }
38
+ if (tool === "serve") {
39
+ log(`OpenAI: ${gateway.url}/v1`);
40
+ log(`Anthropic: ${gateway.url}/v1/messages`);
41
+ log(`Responses: ${gateway.url}/v1/responses`);
42
+ log("Press Ctrl+C to stop.");
43
+ await new Promise(() => {
44
+ /* run until interrupted */
45
+ });
46
+ return 0;
139
47
  }
48
+ const integration = toolRegistry.get(tool);
49
+ if (integration === undefined || !integration.modes.includes("local")) {
50
+ throw new Error(`unknown local tool: ${String(tool)}`);
51
+ }
52
+ const ctx = {
53
+ mode: "local",
54
+ gatewayUrl: gateway.url,
55
+ modelLabel: model,
56
+ toolArgs,
57
+ ...(options.authToken !== undefined ? { authToken: options.authToken } : {}),
58
+ ...(publicUrl !== undefined ? { publicUrl } : {}),
59
+ log,
60
+ prepareForPassthrough: () => undefined,
61
+ registerPort: (_name, port) => `http://127.0.0.1:${port}`,
62
+ unregisterPort: () => undefined,
63
+ registerDisposer: (dispose) => disposers.push(dispose)
64
+ };
65
+ return await integration.launch(ctx);
140
66
  }
141
67
  finally {
68
+ for (const dispose of disposers.reverse()) {
69
+ try {
70
+ await dispose();
71
+ }
72
+ catch {
73
+ // best-effort teardown
74
+ }
75
+ }
142
76
  await gateway.close();
143
77
  }
144
78
  }
@@ -1,5 +1,6 @@
1
- import type { EnsembleModel, HarnessLiveSmokeTarget, UnifiedHarnessKind } from "@fusionkit/ensemble";
1
+ import type { EnsembleModel, UnifiedHarnessKind } from "@fusionkit/ensemble";
2
2
  import type { SessionIsolation } from "@fusionkit/protocol";
3
+ import type { HarnessLiveSmokeTarget } from "../dashboard.js";
3
4
  import type { FusionTool, PanelModelSpec, PanelProvider } from "../fusion-quickstart.js";
4
5
  /** Commander reducer for repeatable string options (`--flag a --flag b`). */
5
6
  export declare function collect(value: string, previous?: string[]): string[];
@@ -1,4 +1,5 @@
1
1
  import { SESSION_ISOLATIONS } from "@fusionkit/protocol";
2
+ import { toolRegistry } from "../tools.js";
2
3
  import { FUSION_TOOLS } from "../fusion-quickstart.js";
3
4
  import { fail } from "./errors.js";
4
5
  /** Commander reducer for repeatable string options (`--flag a --flag b`). */
@@ -28,32 +29,25 @@ export function ensembleModels(model, harness) {
28
29
  });
29
30
  }
30
31
  export function liveSmokeTargets(targets) {
32
+ const valid = new Set(toolRegistry
33
+ .dashboardTools()
34
+ .filter((tool) => tool.liveSmoke !== undefined)
35
+ .map((tool) => tool.id));
31
36
  return (targets ?? []).map((target) => {
32
- switch (target) {
33
- case "claude-code":
34
- case "codex":
35
- return target;
36
- default:
37
- fail('--live-smoke must be "claude-code" or "codex"');
38
- }
37
+ if (valid.has(target))
38
+ return target;
39
+ return fail(`--live-smoke must be one of ${[...valid].join(", ")}`);
39
40
  });
40
41
  }
41
42
  export function unifiedHarnessKinds(targets) {
43
+ const generic = ["mock", "command", "agent"];
44
+ const valid = new Set([...generic, ...toolRegistry.harnessKinds()]);
42
45
  return (targets ?? ["mock", "command"])
43
46
  .flatMap((target) => target.split(","))
44
47
  .map((target) => {
45
- switch (target) {
46
- case "mock":
47
- case "command":
48
- case "agent":
49
- case "codex":
50
- case "claude-code":
51
- case "cursor-acp":
52
- case "cursor-desktop":
53
- return target;
54
- default:
55
- fail(`--harness must be mock, command, agent, codex, claude-code, cursor-acp, or cursor-desktop; got "${target}"`);
56
- }
48
+ if (valid.has(target))
49
+ return target;
50
+ return fail(`--harness must be one of ${[...valid].join(", ")}; got "${target}"`);
57
51
  });
58
52
  }
59
53
  export function parseTimeoutMs(raw, fallback) {
@@ -1,72 +1,6 @@
1
- import type { ChildProcess, SpawnOptions } from "node:child_process";
2
- /** Shared process helpers for the CLI's launcher/gateway flows. */
3
- export declare function sleep(ms: number): Promise<void>;
4
1
  /**
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.
2
+ * Process helpers live in `@fusionkit/tools` so tool packages and the CLI share
3
+ * one implementation. Re-exported here to keep the CLI's existing import paths.
9
4
  */
10
- export declare function freePort(): Promise<number>;
11
- /**
12
- * Spawn a foreground tool with inherited stdio and resolve with its exit code.
13
- * A spawn failure (e.g. binary not on PATH) rejects rather than emitting an
14
- * unhandled `error` event.
15
- */
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
- };
23
- /**
24
- * A spawned background child with captured output and a recorded spawn error.
25
- * Always attaches an `'error'` listener so a missing binary surfaces as a clear
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.
29
- */
30
- export type LoggedChild = {
31
- child: ChildProcess;
32
- /** The most recent captured stdout+stderr, up to the ring-buffer cap. */
33
- log: () => string;
34
- /** The spawn `'error'` (e.g. ENOENT), if one was emitted. */
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;
40
- };
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;
51
- /**
52
- * Poll `probeUrl` until it answers (optionally requiring a 2xx), the child fails
53
- * to spawn, the child exits, or the timeout elapses. Distinguishes a failed
54
- * spawn ("uv: not found") from a slow start.
55
- */
56
- export declare function waitForHttp(probeUrl: string, proc: LoggedChild, options: {
57
- timeoutMs: number;
58
- label: string;
59
- requireOk?: boolean;
60
- }): Promise<void>;
61
- /** Resolve once `pattern` is seen on the child's output, or reject on exit/timeout. */
62
- export declare function waitForOutput(proc: LoggedChild, pattern: RegExp, options: {
63
- timeoutMs: number;
64
- label: string;
65
- }): Promise<void>;
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
- */
72
- export declare function terminate(child: ChildProcess, graceMs?: number): void;
5
+ export { distillLog, freePort, sleep, spawnLogged, spawnTool, terminate, waitForHttp, waitForOutput } from "@fusionkit/tools";
6
+ export type { LoggedChild, LoggedSpawnOptions } from "@fusionkit/tools";
@@ -1,230 +1,5 @@
1
- import { spawn } from "node:child_process";
2
- import { createWriteStream } from "node:fs";
3
- import { createServer } from "node:net";
4
- /** Shared process helpers for the CLI's launcher/gateway flows. */
5
- export function sleep(ms) {
6
- return new Promise((resolve) => setTimeout(resolve, ms));
7
- }
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
1
  /**
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.
2
+ * Process helpers live in `@fusionkit/tools` so tool packages and the CLI share
3
+ * one implementation. Re-exported here to keep the CLI's existing import paths.
27
4
  */
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() {
42
- return new Promise((resolve, reject) => {
43
- const server = createServer();
44
- server.on("error", reject);
45
- server.listen(0, "127.0.0.1", () => {
46
- const address = server.address();
47
- const port = typeof address === "object" && address !== null ? address.port : 0;
48
- server.close(() => resolve(port));
49
- });
50
- });
51
- }
52
- /**
53
- * Spawn a foreground tool with inherited stdio and resolve with its exit code.
54
- * A spawn failure (e.g. binary not on PATH) rejects rather than emitting an
55
- * unhandled `error` event.
56
- */
57
- export function spawnTool(command, args, env, cwd) {
58
- return new Promise((resolveExit, reject) => {
59
- const child = spawn(command, args, {
60
- stdio: "inherit",
61
- env: { ...process.env, ...env },
62
- ...(cwd !== undefined ? { cwd } : {})
63
- });
64
- child.on("error", reject);
65
- child.on("exit", (code) => resolveExit(code ?? 0));
66
- });
67
- }
68
- /** Keep at most this many bytes of a child's captured output in memory. */
69
- const DEFAULT_MAX_LOG_BYTES = 256 * 1024;
70
- export function spawnLogged(command, args, options = {}) {
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 = "";
80
- let spawnError;
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);
101
- child.on("error", (error) => (spawnError = error));
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}`;
144
- }
145
- /**
146
- * Poll `probeUrl` until it answers (optionally requiring a 2xx), the child fails
147
- * to spawn, the child exits, or the timeout elapses. Distinguishes a failed
148
- * spawn ("uv: not found") from a slow start.
149
- */
150
- export async function waitForHttp(probeUrl, proc, options) {
151
- const deadline = Date.now() + options.timeoutMs;
152
- let lastError = "";
153
- while (Date.now() < deadline) {
154
- const spawnError = proc.spawnError();
155
- if (spawnError !== undefined) {
156
- throw new Error(`${options.label} failed to start: ${spawnError.message}\n${failureDetail(proc)}`);
157
- }
158
- if (proc.child.exitCode !== null) {
159
- throw new Error(`${options.label} exited (code ${proc.child.exitCode}) before becoming ready\n${failureDetail(proc)}`);
160
- }
161
- try {
162
- const response = await fetch(probeUrl);
163
- if (options.requireOk !== true || response.ok)
164
- return;
165
- lastError = `status ${response.status}`;
166
- }
167
- catch (error) {
168
- lastError = error instanceof Error ? error.message : String(error);
169
- }
170
- await sleep(400);
171
- }
172
- throw new Error(`${options.label} did not become ready within ${options.timeoutMs}ms (${lastError})\n${failureDetail(proc)}`);
173
- }
174
- /** Resolve once `pattern` is seen on the child's output, or reject on exit/timeout. */
175
- export function waitForOutput(proc, pattern, options) {
176
- return new Promise((resolve, reject) => {
177
- const deadline = setTimeout(() => {
178
- cleanup();
179
- reject(new Error(`${options.label} did not start within ${options.timeoutMs}ms:\n${failureDetail(proc)}`));
180
- }, options.timeoutMs);
181
- const poll = setInterval(() => {
182
- if (proc.spawnError() !== undefined) {
183
- cleanup();
184
- reject(new Error(`${options.label} failed to start: ${proc.spawnError()?.message}\n${failureDetail(proc)}`));
185
- }
186
- else if (pattern.test(proc.log())) {
187
- cleanup();
188
- resolve();
189
- }
190
- }, 100);
191
- const onExit = () => {
192
- cleanup();
193
- reject(new Error(`${options.label} exited before becoming ready:\n${failureDetail(proc)}`));
194
- };
195
- proc.child.once("exit", onExit);
196
- function cleanup() {
197
- clearTimeout(deadline);
198
- clearInterval(poll);
199
- proc.child.off("exit", onExit);
200
- }
201
- });
202
- }
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
- */
209
- export function terminate(child, graceMs = 5000) {
210
- if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null)
211
- return;
212
- const pid = child.pid;
213
- const signal = (sig) => {
214
- try {
215
- process.kill(-pid, sig);
216
- }
217
- catch {
218
- try {
219
- child.kill(sig);
220
- }
221
- catch {
222
- // already gone
223
- }
224
- }
225
- };
226
- signal("SIGTERM");
227
- const timer = setTimeout(() => signal("SIGKILL"), graceMs);
228
- timer.unref();
229
- child.once("exit", () => clearTimeout(timer));
230
- }
5
+ export { distillLog, freePort, sleep, spawnLogged, spawnTool, terminate, waitForHttp, waitForOutput } from "@fusionkit/tools";