@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.
- package/README.md +26 -4
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +4 -17
- package/dist/commands/ensemble-gateway.js +0 -2
- package/dist/commands/ensemble-records.d.ts +2 -1
- package/dist/commands/ensemble-records.js +3 -1
- package/dist/commands/ensemble.js +3 -4
- package/dist/commands/fusion.js +14 -15
- package/dist/commands/local.js +3 -3
- package/dist/cursor-acp.d.ts +18 -0
- package/dist/cursor-acp.js +206 -0
- package/dist/dashboard.d.ts +65 -0
- package/dist/dashboard.js +587 -0
- package/dist/fusion/env.d.ts +108 -0
- package/dist/fusion/env.js +98 -0
- package/dist/fusion/observability.d.ts +39 -0
- package/dist/fusion/observability.js +227 -0
- package/dist/fusion/preflight.d.ts +12 -0
- package/dist/fusion/preflight.js +42 -0
- package/dist/fusion/stack.d.ts +62 -0
- package/dist/fusion/stack.js +295 -0
- package/dist/fusion-config.d.ts +0 -1
- package/dist/fusion-config.js +0 -6
- package/dist/fusion-init.js +2 -11
- package/dist/fusion-quickstart.d.ts +11 -222
- package/dist/fusion-quickstart.js +57 -759
- package/dist/gateway.d.ts +0 -2
- package/dist/gateway.js +12 -2
- package/dist/local.d.ts +10 -17
- package/dist/local.js +50 -116
- package/dist/shared/options.d.ts +2 -1
- package/dist/shared/options.js +13 -19
- package/dist/shared/proc.d.ts +4 -70
- package/dist/shared/proc.js +3 -228
- package/dist/test/cli.test.js +32 -142
- package/dist/test/dashboard.test.d.ts +1 -0
- package/dist/test/dashboard.test.js +214 -0
- package/dist/test/gateway-e2e.test.js +13 -10
- package/dist/test/local.test.js +4 -4
- package/dist/tools.d.ts +2 -0
- package/dist/tools.js +25 -0
- package/package.json +14 -9
- package/scope/.next/BUILD_ID +1 -1
- package/scope/.next/app-build-manifest.json +12 -12
- package/scope/.next/app-path-routes-manifest.json +3 -3
- package/scope/.next/build-manifest.json +2 -2
- package/scope/.next/prerender-manifest.json +16 -16
- package/scope/.next/server/app/_not-found.html +1 -1
- package/scope/.next/server/app/_not-found.rsc +1 -1
- package/scope/.next/server/app/environments.html +1 -1
- package/scope/.next/server/app/environments.rsc +1 -1
- package/scope/.next/server/app/index.html +1 -1
- package/scope/.next/server/app/index.rsc +1 -1
- package/scope/.next/server/app/models.html +1 -1
- package/scope/.next/server/app/models.rsc +1 -1
- package/scope/.next/server/app-paths-manifest.json +3 -3
- package/scope/.next/server/functions-config-manifest.json +2 -2
- package/scope/.next/server/pages/404.html +1 -1
- package/scope/.next/server/pages/500.html +1 -1
- package/scope/.next/server/server-reference-manifest.json +1 -1
- package/dist/commands/init.d.ts +0 -2
- package/dist/commands/init.js +0 -24
- package/dist/commands/lifecycle.d.ts +0 -2
- package/dist/commands/lifecycle.js +0 -124
- package/dist/commands/plane.d.ts +0 -2
- package/dist/commands/plane.js +0 -38
- package/dist/commands/run.d.ts +0 -2
- package/dist/commands/run.js +0 -149
- package/dist/commands/runner.d.ts +0 -2
- package/dist/commands/runner.js +0 -33
- package/dist/commands/secrets.d.ts +0 -2
- package/dist/commands/secrets.js +0 -21
- /package/scope/.next/static/{5tnFLuvnSbNZNtqRgoot8 → x7wPUCpgS31-5ZHJkcKsU}/_buildManifest.js +0 -0
- /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
|
|
10
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
export
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
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 {
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
const
|
|
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 (
|
|
13
|
-
|
|
14
|
-
export
|
|
15
|
-
|
|
16
|
-
|
|
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(`
|
|
35
|
+
log(`fusionkit local: gateway on ${gateway.url} (model: ${model})`);
|
|
36
|
+
const disposers = [];
|
|
90
37
|
try {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
}
|
package/dist/shared/options.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { EnsembleModel,
|
|
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[];
|
package/dist/shared/options.js
CHANGED
|
@@ -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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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) {
|
package/dist/shared/proc.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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";
|
package/dist/shared/proc.js
CHANGED
|
@@ -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
|
-
*
|
|
24
|
-
*
|
|
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
|
|
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";
|