@fusionkit/cli 0.1.0
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/dist/cli.d.ts +8 -0
- package/dist/cli.js +34 -0
- package/dist/commands/ensemble-gateway.d.ts +2 -0
- package/dist/commands/ensemble-gateway.js +114 -0
- package/dist/commands/ensemble-records.d.ts +33 -0
- package/dist/commands/ensemble-records.js +207 -0
- package/dist/commands/ensemble.d.ts +2 -0
- package/dist/commands/ensemble.js +254 -0
- package/dist/commands/fusion.d.ts +2 -0
- package/dist/commands/fusion.js +112 -0
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.js +24 -0
- package/dist/commands/lifecycle.d.ts +2 -0
- package/dist/commands/lifecycle.js +124 -0
- package/dist/commands/local.d.ts +2 -0
- package/dist/commands/local.js +25 -0
- package/dist/commands/plane.d.ts +2 -0
- package/dist/commands/plane.js +30 -0
- package/dist/commands/run.d.ts +2 -0
- package/dist/commands/run.js +149 -0
- package/dist/commands/runner.d.ts +2 -0
- package/dist/commands/runner.js +33 -0
- package/dist/commands/secrets.d.ts +2 -0
- package/dist/commands/secrets.js +21 -0
- package/dist/config.d.ts +30 -0
- package/dist/config.js +69 -0
- package/dist/fusion-quickstart.d.ts +182 -0
- package/dist/fusion-quickstart.js +673 -0
- package/dist/gateway.d.ts +63 -0
- package/dist/gateway.js +304 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +28 -0
- package/dist/local.d.ts +40 -0
- package/dist/local.js +144 -0
- package/dist/render.d.ts +7 -0
- package/dist/render.js +131 -0
- package/dist/shared/errors.d.ts +6 -0
- package/dist/shared/errors.js +9 -0
- package/dist/shared/options.d.ts +24 -0
- package/dist/shared/options.js +106 -0
- package/dist/shared/plane.d.ts +13 -0
- package/dist/shared/plane.js +46 -0
- package/dist/shared/preflight.d.ts +15 -0
- package/dist/shared/preflight.js +48 -0
- package/dist/shared/proc.d.ts +41 -0
- package/dist/shared/proc.js +122 -0
- package/dist/test/cli.test.d.ts +1 -0
- package/dist/test/cli.test.js +867 -0
- package/dist/test/e2e.test.d.ts +1 -0
- package/dist/test/e2e.test.js +250 -0
- package/dist/test/fusion-quickstart.test.d.ts +1 -0
- package/dist/test/fusion-quickstart.test.js +189 -0
- package/dist/test/gateway-e2e.test.d.ts +1 -0
- package/dist/test/gateway-e2e.test.js +606 -0
- package/dist/test/handoff.test.d.ts +1 -0
- package/dist/test/handoff.test.js +212 -0
- package/dist/test/local.test.d.ts +1 -0
- package/dist/test/local.test.js +39 -0
- package/dist/test/proc.test.d.ts +1 -0
- package/dist/test/proc.test.js +22 -0
- package/package.json +48 -0
package/dist/render.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { buildReceiptStory } from "@fusionkit/protocol";
|
|
2
|
+
/** One screen, five questions. This is the product. */
|
|
3
|
+
export function renderReceipt(bundle) {
|
|
4
|
+
const { contract, receipt } = bundle;
|
|
5
|
+
const story = buildReceiptStory(bundle);
|
|
6
|
+
const lines = [];
|
|
7
|
+
const changed = bundle.events.filter((e) => e.event.type === "file.changed");
|
|
8
|
+
const approvers = contract.approvedBy?.map((a) => a.id).join(", ");
|
|
9
|
+
lines.push(`warrant receipt ${story.runId} [${story.status}]`);
|
|
10
|
+
lines.push("");
|
|
11
|
+
lines.push("1. What moved?");
|
|
12
|
+
lines.push(` in: workspace @ ${story.workspace.baseRef.slice(0, 12)} (manifest ${story.workspace.manifestHash.slice(0, 12)})`);
|
|
13
|
+
if (contract.continuation) {
|
|
14
|
+
lines.push(` in: continuation of checkpoint ${contract.continuation.checkpointId} (envelope ${contract.continuation.envelopeHash.slice(0, 12)}, tier ${contract.continuation.tier})`);
|
|
15
|
+
}
|
|
16
|
+
lines.push(` out: ${changed.length} file(s) changed, diff ${receipt.workspaceOut.diffHash ? receipt.workspaceOut.diffHash.slice(0, 12) : "none"}, ${receipt.workspaceOut.artifactHashes.length} artifact(s)`);
|
|
17
|
+
for (const disclosure of receipt.boundaryDisclosures) {
|
|
18
|
+
lines.push(` boundary ${disclosure.direction}: ${disclosure.dataClass} ${disclosure.contentHash.slice(0, 12)}`);
|
|
19
|
+
}
|
|
20
|
+
lines.push("");
|
|
21
|
+
lines.push("2. Why did it move?");
|
|
22
|
+
lines.push(` task: ${contract.task.prompt}`);
|
|
23
|
+
lines.push(` requested by: ${contract.requestedBy.id}`);
|
|
24
|
+
lines.push("");
|
|
25
|
+
lines.push("3. Who or what approved it?");
|
|
26
|
+
lines.push(approvers
|
|
27
|
+
? ` approved by: ${approvers}`
|
|
28
|
+
: " policy: auto-allowed (no consent rule matched)");
|
|
29
|
+
lines.push(` policy snapshot: ${story.policyHash.slice(0, 12)}`);
|
|
30
|
+
lines.push("");
|
|
31
|
+
lines.push("4. Which runtime, model, tools, data, and secrets saw it?");
|
|
32
|
+
lines.push(` runner: ${receipt.runner.runnerId} (pool ${receipt.runner.pool}, attestation: ${receipt.runner.attestationTier}, isolation: ${story.isolation})`);
|
|
33
|
+
lines.push(` agent: ${story.agent}${contract.agent.version ? `@${contract.agent.version}` : ""}`);
|
|
34
|
+
lines.push(` secrets released: ${story.secrets.length > 0
|
|
35
|
+
? story.secrets.join(", ")
|
|
36
|
+
: "none"}`);
|
|
37
|
+
lines.push(` network: ${story.network.length > 0
|
|
38
|
+
? story.network.join(", ")
|
|
39
|
+
: "no egress attempted"}`);
|
|
40
|
+
lines.push(` models: ${receipt.modelsUsed.length > 0
|
|
41
|
+
? receipt.modelsUsed.map((m) => `${m.provider}/${m.model}`).join(", ")
|
|
42
|
+
: "not observable at session boundary (vendor harness)"}`);
|
|
43
|
+
lines.push("");
|
|
44
|
+
lines.push("5. How can you resume, inspect, revoke, or reproduce it?");
|
|
45
|
+
lines.push(` contract: ${receipt.contractHash.slice(0, 16)} (signed, expires ${contract.expiresAt})`);
|
|
46
|
+
lines.push(` events: ${story.eventCount} hash-chained, head ${story.eventsHead.slice(0, 12)}`);
|
|
47
|
+
lines.push(` pull results: warrant pull ${receipt.runId}`);
|
|
48
|
+
lines.push(` verify offline: ${story.verificationCommand}`);
|
|
49
|
+
return lines.join("\n");
|
|
50
|
+
}
|
|
51
|
+
export function renderDisclosure(report) {
|
|
52
|
+
const lines = [];
|
|
53
|
+
lines.push("warrant dry run: nothing moved. This is what would:");
|
|
54
|
+
lines.push("");
|
|
55
|
+
lines.push(`agent: ${report.agent.kind} on pool "${report.pool}"`);
|
|
56
|
+
lines.push(`workspace: base ${report.workspace.baseRef.slice(0, 12)}`);
|
|
57
|
+
lines.push(` bundle ${report.workspace.bundleHash.slice(0, 12)}`);
|
|
58
|
+
if (report.workspace.dirtyDiffHash) {
|
|
59
|
+
lines.push(` uncommitted diff ${report.workspace.dirtyDiffHash.slice(0, 12)}`);
|
|
60
|
+
}
|
|
61
|
+
lines.push(` untracked included: ${report.workspace.untrackedPaths.length > 0
|
|
62
|
+
? report.workspace.untrackedPaths.join(", ")
|
|
63
|
+
: "none"}`);
|
|
64
|
+
lines.push(` denied capture: ${report.workspace.deniedPaths.length > 0
|
|
65
|
+
? report.workspace.deniedPaths.join(", ")
|
|
66
|
+
: "none"}`);
|
|
67
|
+
if (report.continuation) {
|
|
68
|
+
lines.push(`continuation: checkpoint ${report.continuation.checkpointId} (envelope ${report.continuation.envelopeHash.slice(0, 12)}, tier ${report.continuation.tier})`);
|
|
69
|
+
}
|
|
70
|
+
lines.push(`secrets that would be released: ${report.secrets.length > 0
|
|
71
|
+
? report.secrets.map((s) => `${s.name} (${s.scope})`).join(", ")
|
|
72
|
+
: "none"}`);
|
|
73
|
+
lines.push(`network egress: ${report.network.defaultDeny
|
|
74
|
+
? `deny-by-default, allowlist [${report.network.allowHosts.join(", ")}]`
|
|
75
|
+
: "allow-all (policy permits)"}`);
|
|
76
|
+
lines.push(`budget: $${report.budget.maxSpendUsd ?? "policy-default"} / ${report.budget.maxDurationMin ?? "policy-default"}m`);
|
|
77
|
+
lines.push(`disclosure mode: ${report.disclosure}`);
|
|
78
|
+
lines.push(`policy decision: ${report.policyDecision.decision} (${report.policyDecision.reason})`);
|
|
79
|
+
return lines.join("\n");
|
|
80
|
+
}
|
|
81
|
+
export function renderRunList(runs) {
|
|
82
|
+
if (runs.length === 0)
|
|
83
|
+
return "no runs yet";
|
|
84
|
+
const lines = [];
|
|
85
|
+
for (const run of runs) {
|
|
86
|
+
const continuation = run.continuation ? " ↩ continuation" : "";
|
|
87
|
+
const prompt = run.prompt.length > 56 ? `${run.prompt.slice(0, 53)}...` : run.prompt;
|
|
88
|
+
lines.push(`${run.runId} [${run.status}] ${run.agentKind} @ ${run.pool}${continuation}`);
|
|
89
|
+
lines.push(` "${prompt}" — ${run.requestedBy.id}, ${run.createdAt}`);
|
|
90
|
+
}
|
|
91
|
+
return lines.join("\n");
|
|
92
|
+
}
|
|
93
|
+
export function renderTrace(events) {
|
|
94
|
+
const lines = ["handoff trace:"];
|
|
95
|
+
for (const event of events) {
|
|
96
|
+
switch (event.type) {
|
|
97
|
+
case "checkpoint.created":
|
|
98
|
+
lines.push(` ${event.ts} checkpoint.created ${event.checkpointId} (tier ${event.tier})`);
|
|
99
|
+
break;
|
|
100
|
+
case "continuation.planned":
|
|
101
|
+
lines.push(` ${event.ts} continuation.planned ${event.decision} → ${event.target}: ${event.reasons.join("; ")}`);
|
|
102
|
+
break;
|
|
103
|
+
case "envelope.created":
|
|
104
|
+
lines.push(` ${event.ts} envelope.created ${event.envelopeId} (${event.envelopeHash.slice(0, 12)}) → ${event.target}`);
|
|
105
|
+
break;
|
|
106
|
+
case "run.requested":
|
|
107
|
+
lines.push(` ${event.ts} run.requested ${event.runId} [${event.status}]`);
|
|
108
|
+
break;
|
|
109
|
+
case "run.terminal":
|
|
110
|
+
lines.push(` ${event.ts} run.terminal ${event.runId} [${event.status}]`);
|
|
111
|
+
break;
|
|
112
|
+
case "results.pulled":
|
|
113
|
+
lines.push(` ${event.ts} results.pulled ${event.runId} (${event.mode})`);
|
|
114
|
+
break;
|
|
115
|
+
case "tool.called":
|
|
116
|
+
lines.push(` ${event.ts} tool.called ${event.toolName} [${event.ok ? "ok" : "error"}] input ${event.inputHash.slice(0, 12)}${event.outputHash ? ` → ${event.outputHash.slice(0, 12)}` : ""} (${event.durationMs}ms)`);
|
|
117
|
+
break;
|
|
118
|
+
case "continuation.requested":
|
|
119
|
+
lines.push(` ${event.ts} continuation.requested ${event.reason ?? "(no reason given)"}`);
|
|
120
|
+
break;
|
|
121
|
+
case "model.routed":
|
|
122
|
+
lines.push(` ${event.ts} model.routed ${event.route}:${event.model}${event.escalated ? " (escalated)" : ""} — ${event.reason}`);
|
|
123
|
+
break;
|
|
124
|
+
default: {
|
|
125
|
+
const exhausted = event;
|
|
126
|
+
throw new Error(`unreachable trace event: ${String(exhausted)}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return lines.join("\n");
|
|
131
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared CLI failure helper: print a one-line `error: ...` to stderr and exit
|
|
3
|
+
* non-zero. Used for validation that commander does not express directly, so
|
|
4
|
+
* the wording (which tests assert) stays under our control.
|
|
5
|
+
*/
|
|
6
|
+
export declare function fail(message: string): never;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared CLI failure helper: print a one-line `error: ...` to stderr and exit
|
|
3
|
+
* non-zero. Used for validation that commander does not express directly, so
|
|
4
|
+
* the wording (which tests assert) stays under our control.
|
|
5
|
+
*/
|
|
6
|
+
export function fail(message) {
|
|
7
|
+
console.error(`error: ${message}`);
|
|
8
|
+
process.exit(1);
|
|
9
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { EnsembleModel, HarnessLiveSmokeTarget, UnifiedHarnessKind } from "@fusionkit/ensemble";
|
|
2
|
+
import type { SessionIsolation } from "@fusionkit/protocol";
|
|
3
|
+
import type { FusionTool, PanelModelSpec, PanelProvider } from "../fusion-quickstart.js";
|
|
4
|
+
/** Commander reducer for repeatable string options (`--flag a --flag b`). */
|
|
5
|
+
export declare function collect(value: string, previous?: string[]): string[];
|
|
6
|
+
/** Parse `ID=VALUE`, failing with a flag-specific message on malformed input. */
|
|
7
|
+
export declare function parseIdValue(flag: string, spec: string): {
|
|
8
|
+
id: string;
|
|
9
|
+
value: string;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Map `--model ID=MODEL` specs to ensemble models, falling back to harness-aware
|
|
13
|
+
* defaults when none are given.
|
|
14
|
+
*/
|
|
15
|
+
export declare function ensembleModels(model: string[] | undefined, harness?: string): EnsembleModel[];
|
|
16
|
+
export declare function liveSmokeTargets(targets: string[] | undefined): HarnessLiveSmokeTarget[];
|
|
17
|
+
export declare function unifiedHarnessKinds(targets: string[] | undefined): UnifiedHarnessKind[];
|
|
18
|
+
export declare function parseTimeoutMs(raw: string | undefined, fallback: number): number;
|
|
19
|
+
export declare function parsePort(raw: string | undefined, fallback: number): number;
|
|
20
|
+
export declare function isolationFlag(value: string | undefined): SessionIsolation | undefined;
|
|
21
|
+
export declare const PANEL_PROVIDERS: readonly PanelProvider[];
|
|
22
|
+
export declare function parseFusionTool(value: string | undefined): FusionTool;
|
|
23
|
+
/** Parse `id=provider:model` (or `id=model`, defaulting to the local mlx provider). */
|
|
24
|
+
export declare function parsePanelModelSpec(spec: string, keyEnvs: Record<string, string>): PanelModelSpec;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { SESSION_ISOLATIONS } from "@fusionkit/protocol";
|
|
2
|
+
import { FUSION_TOOLS } from "../fusion-quickstart.js";
|
|
3
|
+
import { fail } from "./errors.js";
|
|
4
|
+
/** Commander reducer for repeatable string options (`--flag a --flag b`). */
|
|
5
|
+
export function collect(value, previous) {
|
|
6
|
+
return [...(previous ?? []), value];
|
|
7
|
+
}
|
|
8
|
+
/** Parse `ID=VALUE`, failing with a flag-specific message on malformed input. */
|
|
9
|
+
export function parseIdValue(flag, spec) {
|
|
10
|
+
const separator = spec.indexOf("=");
|
|
11
|
+
if (separator <= 0 || separator === spec.length - 1) {
|
|
12
|
+
fail(`${flag} must be ID=VALUE, got "${spec}"`);
|
|
13
|
+
}
|
|
14
|
+
return { id: spec.slice(0, separator), value: spec.slice(separator + 1) };
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Map `--model ID=MODEL` specs to ensemble models, falling back to harness-aware
|
|
18
|
+
* defaults when none are given.
|
|
19
|
+
*/
|
|
20
|
+
export function ensembleModels(model, harness) {
|
|
21
|
+
const specs = model ?? (harness === "command" ? ["command=local-shell"] : ["fast=fake-fast", "writer=fake-writer"]);
|
|
22
|
+
return specs.map((spec) => {
|
|
23
|
+
const separator = spec.indexOf("=");
|
|
24
|
+
if (separator <= 0 || separator === spec.length - 1) {
|
|
25
|
+
fail(`--model must be ID=MODEL, got "${spec}"`);
|
|
26
|
+
}
|
|
27
|
+
return { id: spec.slice(0, separator), model: spec.slice(separator + 1) };
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
export function liveSmokeTargets(targets) {
|
|
31
|
+
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
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
export function unifiedHarnessKinds(targets) {
|
|
42
|
+
return (targets ?? ["mock", "command"])
|
|
43
|
+
.flatMap((target) => target.split(","))
|
|
44
|
+
.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
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
export function parseTimeoutMs(raw, fallback) {
|
|
60
|
+
const timeoutMs = Number(raw ?? String(fallback));
|
|
61
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
|
|
62
|
+
fail("--timeout-ms must be positive");
|
|
63
|
+
return timeoutMs;
|
|
64
|
+
}
|
|
65
|
+
export function parsePort(raw, fallback) {
|
|
66
|
+
const port = Number(raw ?? String(fallback));
|
|
67
|
+
if (!Number.isInteger(port) || port < 0)
|
|
68
|
+
fail("--port must be a non-negative integer");
|
|
69
|
+
return port;
|
|
70
|
+
}
|
|
71
|
+
export function isolationFlag(value) {
|
|
72
|
+
if (value === undefined)
|
|
73
|
+
return undefined;
|
|
74
|
+
if (!SESSION_ISOLATIONS.includes(value)) {
|
|
75
|
+
fail(`--isolation must be one of ${SESSION_ISOLATIONS.join(" | ")}`);
|
|
76
|
+
}
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
export const PANEL_PROVIDERS = [
|
|
80
|
+
"mlx",
|
|
81
|
+
"openai",
|
|
82
|
+
"anthropic",
|
|
83
|
+
"google",
|
|
84
|
+
"openai-compatible"
|
|
85
|
+
];
|
|
86
|
+
export function parseFusionTool(value) {
|
|
87
|
+
if (value === undefined || !FUSION_TOOLS.includes(value)) {
|
|
88
|
+
fail(`--tool must be one of ${FUSION_TOOLS.join(" | ")}`);
|
|
89
|
+
}
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
/** Parse `id=provider:model` (or `id=model`, defaulting to the local mlx provider). */
|
|
93
|
+
export function parsePanelModelSpec(spec, keyEnvs) {
|
|
94
|
+
const { id, value } = parseIdValue("--model", spec);
|
|
95
|
+
const colon = value.indexOf(":");
|
|
96
|
+
let provider = "mlx";
|
|
97
|
+
let model = value;
|
|
98
|
+
if (colon > 0) {
|
|
99
|
+
const maybe = value.slice(0, colon);
|
|
100
|
+
if (PANEL_PROVIDERS.includes(maybe)) {
|
|
101
|
+
provider = maybe;
|
|
102
|
+
model = value.slice(colon + 1);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { id, model, provider, ...(keyEnvs[id] !== undefined ? { keyEnv: keyEnvs[id] } : {}) };
|
|
106
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { PlaneClient } from "@fusionkit/sdk";
|
|
2
|
+
/** Poll interval while watching a run from the terminal. */
|
|
3
|
+
export declare const WATCH_POLL_MS = 500;
|
|
4
|
+
/** How long `warrant continue` waits for the run before returning. */
|
|
5
|
+
export declare const CONTINUE_WAIT_TIMEOUT_MS: number;
|
|
6
|
+
/**
|
|
7
|
+
* Resolve the fusionkit home directory: the `--dir` flag wins, then
|
|
8
|
+
* `FUSIONKIT_HOME` (legacy `WARRANT_HOME`), then `./.fusionkit` (falling back to
|
|
9
|
+
* a pre-existing `./.warrant` so older checkouts keep working).
|
|
10
|
+
*/
|
|
11
|
+
export declare function resolveDir(dirFlag: string | undefined): string;
|
|
12
|
+
export declare function clientFor(dir: string): PlaneClient;
|
|
13
|
+
export declare function waitForTerminal(client: PlaneClient, runId: string, onStatus: (status: string) => void): Promise<string>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { isTerminalStatus } from "@fusionkit/protocol";
|
|
4
|
+
import { PlaneClient } from "@fusionkit/sdk";
|
|
5
|
+
import { loadHome } from "../config.js";
|
|
6
|
+
/** Poll interval while watching a run from the terminal. */
|
|
7
|
+
export const WATCH_POLL_MS = 500;
|
|
8
|
+
/** How long `warrant continue` waits for the run before returning. */
|
|
9
|
+
export const CONTINUE_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
10
|
+
/**
|
|
11
|
+
* Resolve the fusionkit home directory: the `--dir` flag wins, then
|
|
12
|
+
* `FUSIONKIT_HOME` (legacy `WARRANT_HOME`), then `./.fusionkit` (falling back to
|
|
13
|
+
* a pre-existing `./.warrant` so older checkouts keep working).
|
|
14
|
+
*/
|
|
15
|
+
export function resolveDir(dirFlag) {
|
|
16
|
+
const fromEnv = process.env.FUSIONKIT_HOME ?? process.env.WARRANT_HOME;
|
|
17
|
+
if (dirFlag !== undefined)
|
|
18
|
+
return resolve(dirFlag);
|
|
19
|
+
if (fromEnv !== undefined)
|
|
20
|
+
return resolve(fromEnv);
|
|
21
|
+
if (existsSync(".warrant") && !existsSync(".fusionkit"))
|
|
22
|
+
return resolve(".warrant");
|
|
23
|
+
return resolve(".fusionkit");
|
|
24
|
+
}
|
|
25
|
+
export function clientFor(dir) {
|
|
26
|
+
const home = loadHome(dir);
|
|
27
|
+
return new PlaneClient(home.config.planeUrl, home.config.adminToken);
|
|
28
|
+
}
|
|
29
|
+
export async function waitForTerminal(client, runId, onStatus) {
|
|
30
|
+
let last = "";
|
|
31
|
+
for (;;) {
|
|
32
|
+
const view = await client.getRun(runId);
|
|
33
|
+
if (view.status !== last) {
|
|
34
|
+
last = view.status;
|
|
35
|
+
onStatus(view.status);
|
|
36
|
+
}
|
|
37
|
+
if (isTerminalStatus(view.status)) {
|
|
38
|
+
return view.status;
|
|
39
|
+
}
|
|
40
|
+
if (view.status === "awaiting_approval") {
|
|
41
|
+
onStatus(`awaiting approval (${view.consentRequirements.join("; ")}) — run: warrant approve ${runId}`);
|
|
42
|
+
return view.status;
|
|
43
|
+
}
|
|
44
|
+
await new Promise((resolveSleep) => setTimeout(resolveSleep, WATCH_POLL_MS));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Thrown when the environment is missing a binary or credential fusion needs. */
|
|
2
|
+
export declare class PreflightError extends Error {
|
|
3
|
+
constructor(message: string);
|
|
4
|
+
}
|
|
5
|
+
/** True when `bin` resolves on PATH. */
|
|
6
|
+
export declare function hasBinary(bin: string): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* Fail fast with actionable guidance when a required binary or API key is
|
|
9
|
+
* missing. Keeps "minimal setup" honest: clear errors instead of deep stack
|
|
10
|
+
* traces from a half-started stack.
|
|
11
|
+
*/
|
|
12
|
+
export declare function runPreflight(input: {
|
|
13
|
+
requiredBins: string[];
|
|
14
|
+
requiredEnv: string[];
|
|
15
|
+
}): void;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
/** Thrown when the environment is missing a binary or credential fusion needs. */
|
|
3
|
+
export class PreflightError extends Error {
|
|
4
|
+
constructor(message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "PreflightError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
/** True when `bin` resolves on PATH. */
|
|
10
|
+
export function hasBinary(bin) {
|
|
11
|
+
const finder = process.platform === "win32" ? "where" : "which";
|
|
12
|
+
try {
|
|
13
|
+
execFileSync(finder, [bin], { stdio: "ignore" });
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const INSTALL_HINTS = {
|
|
21
|
+
uv: "install uv: https://docs.astral.sh/uv/getting-started/installation/",
|
|
22
|
+
uvx: "install uv (ships uvx): https://docs.astral.sh/uv/getting-started/installation/",
|
|
23
|
+
codex: "install the Codex CLI: https://github.com/openai/codex",
|
|
24
|
+
claude: "install Claude Code: https://docs.anthropic.com/en/docs/claude-code/overview",
|
|
25
|
+
"cursor-agent": "install the Cursor CLI: https://cursor.com/cli"
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Fail fast with actionable guidance when a required binary or API key is
|
|
29
|
+
* missing. Keeps "minimal setup" honest: clear errors instead of deep stack
|
|
30
|
+
* traces from a half-started stack.
|
|
31
|
+
*/
|
|
32
|
+
export function runPreflight(input) {
|
|
33
|
+
const problems = [];
|
|
34
|
+
for (const bin of input.requiredBins) {
|
|
35
|
+
if (!hasBinary(bin)) {
|
|
36
|
+
problems.push(` - "${bin}" was not found on PATH — ${INSTALL_HINTS[bin] ?? `install ${bin}`}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
for (const env of [...new Set(input.requiredEnv)]) {
|
|
40
|
+
const value = process.env[env];
|
|
41
|
+
if (value === undefined || value.length === 0) {
|
|
42
|
+
problems.push(` - ${env} is not set — required by the selected panel (export it, or pass --model/--key-env)`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (problems.length > 0) {
|
|
46
|
+
throw new PreflightError(`fusionkit preflight failed:\n${problems.join("\n")}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
/** Reserve an ephemeral loopback port and return it (closed before returning). */
|
|
5
|
+
export declare function freePort(): Promise<number>;
|
|
6
|
+
/**
|
|
7
|
+
* Spawn a foreground tool with inherited stdio and resolve with its exit code.
|
|
8
|
+
* A spawn failure (e.g. binary not on PATH) rejects rather than emitting an
|
|
9
|
+
* unhandled `error` event.
|
|
10
|
+
*/
|
|
11
|
+
export declare function spawnTool(command: string, args: string[], env: Record<string, string>, cwd?: string): Promise<number>;
|
|
12
|
+
/**
|
|
13
|
+
* A spawned background child with captured output and a recorded spawn error.
|
|
14
|
+
* Always attaches an `'error'` listener so a missing binary surfaces as a clear
|
|
15
|
+
* message via {@link waitForHttp} instead of crashing the process.
|
|
16
|
+
*/
|
|
17
|
+
export type LoggedChild = {
|
|
18
|
+
child: ChildProcess;
|
|
19
|
+
/** The combined stdout+stderr captured so far. */
|
|
20
|
+
log: () => string;
|
|
21
|
+
/** The spawn `'error'` (e.g. ENOENT), if one was emitted. */
|
|
22
|
+
spawnError: () => Error | undefined;
|
|
23
|
+
};
|
|
24
|
+
export declare function spawnLogged(command: string, args: string[], options?: SpawnOptions): LoggedChild;
|
|
25
|
+
/**
|
|
26
|
+
* Poll `probeUrl` until it answers (optionally requiring a 2xx), the child fails
|
|
27
|
+
* to spawn, the child exits, or the timeout elapses. Distinguishes a failed
|
|
28
|
+
* spawn ("uv: not found") from a slow start.
|
|
29
|
+
*/
|
|
30
|
+
export declare function waitForHttp(probeUrl: string, proc: LoggedChild, options: {
|
|
31
|
+
timeoutMs: number;
|
|
32
|
+
label: string;
|
|
33
|
+
requireOk?: boolean;
|
|
34
|
+
}): Promise<void>;
|
|
35
|
+
/** Resolve once `pattern` is seen on the child's output, or reject on exit/timeout. */
|
|
36
|
+
export declare function waitForOutput(proc: LoggedChild, pattern: RegExp, options: {
|
|
37
|
+
timeoutMs: number;
|
|
38
|
+
label: string;
|
|
39
|
+
}): Promise<void>;
|
|
40
|
+
/** SIGTERM a child, escalating to SIGKILL if it ignores the grace period. */
|
|
41
|
+
export declare function terminate(child: ChildProcess, graceMs?: number): void;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createServer } from "node:net";
|
|
3
|
+
/** Shared process helpers for the CLI's launcher/gateway flows. */
|
|
4
|
+
export function sleep(ms) {
|
|
5
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
6
|
+
}
|
|
7
|
+
/** Reserve an ephemeral loopback port and return it (closed before returning). */
|
|
8
|
+
export function freePort() {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const server = createServer();
|
|
11
|
+
server.on("error", reject);
|
|
12
|
+
server.listen(0, "127.0.0.1", () => {
|
|
13
|
+
const address = server.address();
|
|
14
|
+
const port = typeof address === "object" && address !== null ? address.port : 0;
|
|
15
|
+
server.close(() => resolve(port));
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Spawn a foreground tool with inherited stdio and resolve with its exit code.
|
|
21
|
+
* A spawn failure (e.g. binary not on PATH) rejects rather than emitting an
|
|
22
|
+
* unhandled `error` event.
|
|
23
|
+
*/
|
|
24
|
+
export function spawnTool(command, args, env, cwd) {
|
|
25
|
+
return new Promise((resolveExit, reject) => {
|
|
26
|
+
const child = spawn(command, args, {
|
|
27
|
+
stdio: "inherit",
|
|
28
|
+
env: { ...process.env, ...env },
|
|
29
|
+
...(cwd !== undefined ? { cwd } : {})
|
|
30
|
+
});
|
|
31
|
+
child.on("error", reject);
|
|
32
|
+
child.on("exit", (code) => resolveExit(code ?? 0));
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
export function spawnLogged(command, args, options = {}) {
|
|
36
|
+
const child = spawn(command, args, { ...options, stdio: ["ignore", "pipe", "pipe"] });
|
|
37
|
+
let log = "";
|
|
38
|
+
let spawnError;
|
|
39
|
+
child.stdout?.on("data", (chunk) => (log += chunk.toString("utf8")));
|
|
40
|
+
child.stderr?.on("data", (chunk) => (log += chunk.toString("utf8")));
|
|
41
|
+
child.on("error", (error) => (spawnError = error));
|
|
42
|
+
return { child, log: () => log, spawnError: () => spawnError };
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Poll `probeUrl` until it answers (optionally requiring a 2xx), the child fails
|
|
46
|
+
* to spawn, the child exits, or the timeout elapses. Distinguishes a failed
|
|
47
|
+
* spawn ("uv: not found") from a slow start.
|
|
48
|
+
*/
|
|
49
|
+
export async function waitForHttp(probeUrl, proc, options) {
|
|
50
|
+
const deadline = Date.now() + options.timeoutMs;
|
|
51
|
+
let lastError = "";
|
|
52
|
+
while (Date.now() < deadline) {
|
|
53
|
+
const spawnError = proc.spawnError();
|
|
54
|
+
if (spawnError !== undefined) {
|
|
55
|
+
throw new Error(`${options.label} failed to start: ${spawnError.message}\n${proc.log().slice(-500)}`);
|
|
56
|
+
}
|
|
57
|
+
if (proc.child.exitCode !== null) {
|
|
58
|
+
throw new Error(`${options.label} exited (code ${proc.child.exitCode}) before becoming ready\n${proc.log().slice(-500)}`);
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const response = await fetch(probeUrl);
|
|
62
|
+
if (options.requireOk !== true || response.ok)
|
|
63
|
+
return;
|
|
64
|
+
lastError = `status ${response.status}`;
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
68
|
+
}
|
|
69
|
+
await sleep(400);
|
|
70
|
+
}
|
|
71
|
+
throw new Error(`${options.label} did not become ready within ${options.timeoutMs}ms (${lastError})\n${proc.log().slice(-500)}`);
|
|
72
|
+
}
|
|
73
|
+
/** Resolve once `pattern` is seen on the child's output, or reject on exit/timeout. */
|
|
74
|
+
export function waitForOutput(proc, pattern, options) {
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
const deadline = setTimeout(() => {
|
|
77
|
+
cleanup();
|
|
78
|
+
reject(new Error(`${options.label} did not start within ${options.timeoutMs}ms:\n${proc.log().slice(-500)}`));
|
|
79
|
+
}, options.timeoutMs);
|
|
80
|
+
const poll = setInterval(() => {
|
|
81
|
+
if (proc.spawnError() !== undefined) {
|
|
82
|
+
cleanup();
|
|
83
|
+
reject(new Error(`${options.label} failed to start: ${proc.spawnError()?.message}\n${proc.log().slice(-500)}`));
|
|
84
|
+
}
|
|
85
|
+
else if (pattern.test(proc.log())) {
|
|
86
|
+
cleanup();
|
|
87
|
+
resolve();
|
|
88
|
+
}
|
|
89
|
+
}, 100);
|
|
90
|
+
const onExit = () => {
|
|
91
|
+
cleanup();
|
|
92
|
+
reject(new Error(`${options.label} exited before becoming ready:\n${proc.log().slice(-500)}`));
|
|
93
|
+
};
|
|
94
|
+
proc.child.once("exit", onExit);
|
|
95
|
+
function cleanup() {
|
|
96
|
+
clearTimeout(deadline);
|
|
97
|
+
clearInterval(poll);
|
|
98
|
+
proc.child.off("exit", onExit);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/** SIGTERM a child, escalating to SIGKILL if it ignores the grace period. */
|
|
103
|
+
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 {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const timer = setTimeout(() => {
|
|
113
|
+
try {
|
|
114
|
+
child.kill("SIGKILL");
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// already gone
|
|
118
|
+
}
|
|
119
|
+
}, graceMs);
|
|
120
|
+
timer.unref();
|
|
121
|
+
child.once("exit", () => clearTimeout(timer));
|
|
122
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|