@fusionkit/cli 0.1.3 → 0.1.5
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 +2 -2
- package/dist/cli.js +3 -17
- package/dist/commands/fusion.js +28 -9
- package/dist/cursor-acp.d.ts +20 -0
- package/dist/cursor-acp.js +205 -0
- package/dist/fusion-config.d.ts +1 -0
- package/dist/fusion-config.js +5 -0
- package/dist/fusion-quickstart.d.ts +33 -34
- package/dist/fusion-quickstart.js +324 -278
- package/dist/gateway.js +13 -1
- package/dist/shared/portless.d.ts +97 -0
- package/dist/shared/portless.js +253 -0
- package/dist/test/cli.test.js +24 -139
- package/dist/test/portless.test.d.ts +1 -0
- package/dist/test/portless.test.js +65 -0
- package/package.json +12 -9
- package/scope/.next/BUILD_ID +1 -1
- package/scope/.next/app-build-manifest.json +14 -14
- package/scope/.next/app-path-routes-manifest.json +4 -4
- package/scope/.next/build-manifest.json +2 -2
- package/scope/.next/prerender-manifest.json +10 -10
- package/scope/.next/required-server-files.json +4 -0
- package/scope/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- 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/api/environments/route_client-reference-manifest.js +1 -1
- package/scope/.next/server/app/api/ingest/route_client-reference-manifest.js +1 -1
- package/scope/.next/server/app/api/models/route_client-reference-manifest.js +1 -1
- package/scope/.next/server/app/api/replay/route_client-reference-manifest.js +1 -1
- package/scope/.next/server/app/api/sessions/[traceId]/route_client-reference-manifest.js +1 -1
- package/scope/.next/server/app/api/sessions/route_client-reference-manifest.js +1 -1
- package/scope/.next/server/app/api/stream/route_client-reference-manifest.js +1 -1
- package/scope/.next/server/app/environments/page_client-reference-manifest.js +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/page_client-reference-manifest.js +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/page_client-reference-manifest.js +1 -1
- package/scope/.next/server/app/sessions/[traceId]/page_client-reference-manifest.js +1 -1
- package/scope/.next/server/app-paths-manifest.json +4 -4
- package/scope/.next/server/functions-config-manifest.json +3 -3
- 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/scope/package.json +3 -1
- package/scope/server.js +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 -30
- 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/{8b1kwXDxrKvteRVkOC_Z2 → vxqImMqlOwssVTua5Facf}/_buildManifest.js +0 -0
- /package/scope/.next/static/{8b1kwXDxrKvteRVkOC_Z2 → vxqImMqlOwssVTua5Facf}/_ssgManifest.js +0 -0
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.
|
|
@@ -293,10 +294,21 @@ export async function runGatewayAcceptance(input) {
|
|
|
293
294
|
port: 0
|
|
294
295
|
});
|
|
295
296
|
try {
|
|
297
|
+
const cursorAcp = buildCursorAcpProducer({
|
|
298
|
+
cursorKitDir: input.config.cursorKitDir,
|
|
299
|
+
gatewayUrl: gateway.url(),
|
|
300
|
+
sentinel: input.sentinel,
|
|
301
|
+
repo: input.config.repo,
|
|
302
|
+
...(input.config.models[0]?.id !== undefined
|
|
303
|
+
? { modelName: input.config.models[0].id }
|
|
304
|
+
: {}),
|
|
305
|
+
...(input.config.timeoutMs !== undefined ? { timeoutMs: input.config.timeoutMs } : {})
|
|
306
|
+
});
|
|
296
307
|
const report = await runFrontDoorAcceptance({
|
|
297
308
|
gatewayUrl: gateway.url(),
|
|
298
309
|
sentinel: input.sentinel,
|
|
299
|
-
acpRunner: buildAcpRunner(input.config)
|
|
310
|
+
acpRunner: buildAcpRunner(input.config),
|
|
311
|
+
...(cursorAcp !== undefined ? { cursorAcp } : {})
|
|
300
312
|
});
|
|
301
313
|
mkdirSync(resolve(input.outPath, ".."), { recursive: true });
|
|
302
314
|
writeFileSync(input.outPath, JSON.stringify(report, null, 2) + "\n");
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Programmatic portless integration for the fusion stack.
|
|
3
|
+
*
|
|
4
|
+
* Every dev server and CLI-spawned service the launcher starts is registered
|
|
5
|
+
* with portless as a stable, named `.localhost` route, so humans and external
|
|
6
|
+
* coding agents get clean HTTPS URLs instead of raw ports, and a service can be
|
|
7
|
+
* discovered + reused across runs (a singleton) via the shared route table.
|
|
8
|
+
*
|
|
9
|
+
* Routes are managed entirely against the `portless` *library* (its exported
|
|
10
|
+
* `RouteStore` writes the same file-locked `routes.json` the running proxy reads
|
|
11
|
+
* live) — we never shell out to the `portless` binary. The privileged TLS proxy
|
|
12
|
+
* daemon + CA trust are a one-time user setup (`portless service install` +
|
|
13
|
+
* `portless trust`); here we only detect that it is running and register routes.
|
|
14
|
+
*
|
|
15
|
+
* `portless` requires Node >= 24 and is declared an optional dependency, so on
|
|
16
|
+
* older Node (or when the package/proxy is absent) the session degrades to
|
|
17
|
+
* plain loopback URLs with no discovery — identical code paths, just unproxied.
|
|
18
|
+
*/
|
|
19
|
+
/** Resolve the portless state directory (honoring `PORTLESS_STATE_DIR`). */
|
|
20
|
+
export declare function stateDir(): string;
|
|
21
|
+
/** Path to the portless CA, for `NODE_EXTRA_CA_CERTS` / `SSL_CERT_FILE`. */
|
|
22
|
+
export declare function caCertPath(): string;
|
|
23
|
+
/** The TLD portless serves routes under (default `.localhost`). */
|
|
24
|
+
export declare function tld(): string;
|
|
25
|
+
/** A running portless proxy, as detected from on-disk state + a live probe. */
|
|
26
|
+
export type DetectedProxy = {
|
|
27
|
+
port: number;
|
|
28
|
+
tls: boolean;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Detect a running portless proxy: read `<stateDir>/proxy.port`, confirm the
|
|
32
|
+
* owning pid is alive, and probe the port for the `X-Portless` header (a plain
|
|
33
|
+
* HTTP probe works even against a TLS proxy, which 302-redirects to https and
|
|
34
|
+
* still stamps the header). Returns `undefined` when no proxy is reachable.
|
|
35
|
+
*/
|
|
36
|
+
export declare function detectProxy(): Promise<DetectedProxy | undefined>;
|
|
37
|
+
/** A service the launcher started, addressable through portless. */
|
|
38
|
+
export type SpawnedService = {
|
|
39
|
+
port: number;
|
|
40
|
+
/** Owning pid for the route (defaults to this process). */
|
|
41
|
+
pid?: number;
|
|
42
|
+
close: () => Promise<void> | void;
|
|
43
|
+
};
|
|
44
|
+
export type DiscoverOrSpawnInput = {
|
|
45
|
+
/** Short service name; the `.fusion` project suffix + TLD are added here. */
|
|
46
|
+
name: string;
|
|
47
|
+
/** Expected identity token of a reusable instance (model set, config hash, ...). */
|
|
48
|
+
identity: string;
|
|
49
|
+
/** Probe a candidate instance on its loopback URL; return its identity token. */
|
|
50
|
+
healthCheck: (loopbackUrl: string) => Promise<string | undefined>;
|
|
51
|
+
/** Start a fresh instance when none can be reused. */
|
|
52
|
+
spawn: () => Promise<SpawnedService>;
|
|
53
|
+
};
|
|
54
|
+
export type DiscoverOrSpawnResult = {
|
|
55
|
+
/** The URL callers should surface/inject (portless name, or loopback). */
|
|
56
|
+
url: string;
|
|
57
|
+
/** The loopback URL for in-process CLI fetches (avoids the CA-at-startup hazard). */
|
|
58
|
+
loopbackUrl: string;
|
|
59
|
+
port: number;
|
|
60
|
+
/** True when this run spawned the instance (and therefore owns teardown). */
|
|
61
|
+
owned: boolean;
|
|
62
|
+
/** Tear down only an owned instance; reused instances are left running. */
|
|
63
|
+
close: () => Promise<void> | void;
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* A live portless session threaded through the launcher. When `enabled` is
|
|
67
|
+
* false (portless off, package absent, or no proxy detected) every method
|
|
68
|
+
* degrades to plain loopback behavior with no proxy registration or discovery.
|
|
69
|
+
*/
|
|
70
|
+
export type PortlessSession = {
|
|
71
|
+
enabled: boolean;
|
|
72
|
+
/** Portless CA path when active, else undefined. */
|
|
73
|
+
caCertPath: string | undefined;
|
|
74
|
+
/** Register `127.0.0.1:<port>` under `<name>.<project>.localhost`; returns the URL. */
|
|
75
|
+
register(name: string, appPort: number): string;
|
|
76
|
+
/** Remove a route this process owns. */
|
|
77
|
+
unregister(name: string): void;
|
|
78
|
+
/** Reuse a compatible running instance, or spawn + register a new one. */
|
|
79
|
+
discoverOrSpawn(input: DiscoverOrSpawnInput): Promise<DiscoverOrSpawnResult>;
|
|
80
|
+
};
|
|
81
|
+
export type CreateSessionInput = {
|
|
82
|
+
/** Whether portless is requested (CLI flag / config / PORTLESS env). */
|
|
83
|
+
enabled: boolean;
|
|
84
|
+
log?: (line: string) => void;
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* Create a portless session. Returns a disabled (loopback) session when
|
|
88
|
+
* portless is off, the library is unavailable (Node < 24 / not installed), or
|
|
89
|
+
* no proxy is running; otherwise an active session backed by `RouteStore`.
|
|
90
|
+
*/
|
|
91
|
+
export declare function createPortlessSession(input: CreateSessionInput): Promise<PortlessSession>;
|
|
92
|
+
/**
|
|
93
|
+
* Reap fusion singleton services: terminate the owning pid of every registered
|
|
94
|
+
* `*.fusion.<tld>` / `scope.<tld>` route and drop the route. Returns the number
|
|
95
|
+
* of services stopped. A no-op when portless is unavailable.
|
|
96
|
+
*/
|
|
97
|
+
export declare function reapFusionServices(log?: (line: string) => void): Promise<number>;
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Programmatic portless integration for the fusion stack.
|
|
3
|
+
*
|
|
4
|
+
* Every dev server and CLI-spawned service the launcher starts is registered
|
|
5
|
+
* with portless as a stable, named `.localhost` route, so humans and external
|
|
6
|
+
* coding agents get clean HTTPS URLs instead of raw ports, and a service can be
|
|
7
|
+
* discovered + reused across runs (a singleton) via the shared route table.
|
|
8
|
+
*
|
|
9
|
+
* Routes are managed entirely against the `portless` *library* (its exported
|
|
10
|
+
* `RouteStore` writes the same file-locked `routes.json` the running proxy reads
|
|
11
|
+
* live) — we never shell out to the `portless` binary. The privileged TLS proxy
|
|
12
|
+
* daemon + CA trust are a one-time user setup (`portless service install` +
|
|
13
|
+
* `portless trust`); here we only detect that it is running and register routes.
|
|
14
|
+
*
|
|
15
|
+
* `portless` requires Node >= 24 and is declared an optional dependency, so on
|
|
16
|
+
* older Node (or when the package/proxy is absent) the session degrades to
|
|
17
|
+
* plain loopback URLs with no discovery — identical code paths, just unproxied.
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
/** The npm scope-less project prefix for fusion service hostnames. */
|
|
23
|
+
const PROJECT = "fusion";
|
|
24
|
+
/** Resolve the portless state directory (honoring `PORTLESS_STATE_DIR`). */
|
|
25
|
+
export function stateDir() {
|
|
26
|
+
return process.env.PORTLESS_STATE_DIR ?? join(homedir(), ".portless");
|
|
27
|
+
}
|
|
28
|
+
/** Path to the portless CA, for `NODE_EXTRA_CA_CERTS` / `SSL_CERT_FILE`. */
|
|
29
|
+
export function caCertPath() {
|
|
30
|
+
return join(stateDir(), "ca.pem");
|
|
31
|
+
}
|
|
32
|
+
/** The TLD portless serves routes under (default `.localhost`). */
|
|
33
|
+
export function tld() {
|
|
34
|
+
return process.env.PORTLESS_TLD ?? "localhost";
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Detect a running portless proxy: read `<stateDir>/proxy.port`, confirm the
|
|
38
|
+
* owning pid is alive, and probe the port for the `X-Portless` header (a plain
|
|
39
|
+
* HTTP probe works even against a TLS proxy, which 302-redirects to https and
|
|
40
|
+
* still stamps the header). Returns `undefined` when no proxy is reachable.
|
|
41
|
+
*/
|
|
42
|
+
export async function detectProxy() {
|
|
43
|
+
const dir = stateDir();
|
|
44
|
+
const portFile = join(dir, "proxy.port");
|
|
45
|
+
if (!existsSync(portFile))
|
|
46
|
+
return undefined;
|
|
47
|
+
const port = Number.parseInt(readFileSync(portFile, "utf8").trim(), 10);
|
|
48
|
+
if (!Number.isInteger(port) || port <= 0)
|
|
49
|
+
return undefined;
|
|
50
|
+
const pidFile = join(dir, "proxy.pid");
|
|
51
|
+
if (existsSync(pidFile)) {
|
|
52
|
+
const pid = Number.parseInt(readFileSync(pidFile, "utf8").trim(), 10);
|
|
53
|
+
if (Number.isInteger(pid) && pid > 0 && !isAlive(pid))
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
// Prefer the proxy's recorded TLS mode; fall back to inferring it from the
|
|
57
|
+
// redirect a plain-HTTP probe gets (a TLS proxy 302s to https).
|
|
58
|
+
const tlsFile = join(dir, "proxy.tls");
|
|
59
|
+
const tlsFromFile = existsSync(tlsFile)
|
|
60
|
+
? ["1", "true"].includes(readFileSync(tlsFile, "utf8").trim().toLowerCase())
|
|
61
|
+
: undefined;
|
|
62
|
+
try {
|
|
63
|
+
const response = await fetch(`http://127.0.0.1:${port}/`, {
|
|
64
|
+
redirect: "manual",
|
|
65
|
+
signal: AbortSignal.timeout(1500)
|
|
66
|
+
});
|
|
67
|
+
if (response.headers.get("x-portless") === null)
|
|
68
|
+
return undefined;
|
|
69
|
+
const location = response.headers.get("location");
|
|
70
|
+
const tls = tlsFromFile ?? (port === 443 || (location !== null && location.startsWith("https://")));
|
|
71
|
+
return { port, tls };
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function isAlive(pid) {
|
|
78
|
+
try {
|
|
79
|
+
process.kill(pid, 0);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
// EPERM means the process exists but is owned by another user (e.g. the
|
|
84
|
+
// portless proxy installed as a root LaunchDaemon) — still alive. Only
|
|
85
|
+
// ESRCH ("no such process") means it is actually gone.
|
|
86
|
+
return error?.code === "EPERM";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function loadPortless() {
|
|
90
|
+
// Variable specifier + dynamic import: `portless` is an optional dependency
|
|
91
|
+
// (Node >= 24 only), so this must not be a static import — it would break the
|
|
92
|
+
// build/install on Node 22. Documented exception to the no-inline-imports rule.
|
|
93
|
+
const specifier = "portless";
|
|
94
|
+
try {
|
|
95
|
+
return (await import(specifier));
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Names that stay bare (`<name>.localhost`) instead of being namespaced under the project. */
|
|
102
|
+
const BARE_NAMES = new Set(["scope"]);
|
|
103
|
+
/** Map a short service name to its portless hostname. */
|
|
104
|
+
function hostnameFor(portless, name) {
|
|
105
|
+
// `scope` stays bare (`scope.localhost`); everything else is namespaced under
|
|
106
|
+
// the project (`gateway.fusion.localhost`). A name already containing a dot or
|
|
107
|
+
// equal to the project is treated as an explicit subdomain path.
|
|
108
|
+
const full = name === PROJECT || name.includes(".") || BARE_NAMES.has(name) ? name : `${name}.${PROJECT}`;
|
|
109
|
+
return portless.parseHostname(full, tld());
|
|
110
|
+
}
|
|
111
|
+
const loopback = (port) => `http://127.0.0.1:${port}`;
|
|
112
|
+
/** Build a disabled session: pure loopback, no proxy, no discovery. */
|
|
113
|
+
function disabledSession() {
|
|
114
|
+
return {
|
|
115
|
+
enabled: false,
|
|
116
|
+
caCertPath: undefined,
|
|
117
|
+
register: (_name, appPort) => loopback(appPort),
|
|
118
|
+
unregister: () => { },
|
|
119
|
+
discoverOrSpawn: async (input) => {
|
|
120
|
+
const service = await input.spawn();
|
|
121
|
+
const url = loopback(service.port);
|
|
122
|
+
return { url, loopbackUrl: url, port: service.port, owned: true, close: service.close };
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Create a portless session. Returns a disabled (loopback) session when
|
|
128
|
+
* portless is off, the library is unavailable (Node < 24 / not installed), or
|
|
129
|
+
* no proxy is running; otherwise an active session backed by `RouteStore`.
|
|
130
|
+
*/
|
|
131
|
+
export async function createPortlessSession(input) {
|
|
132
|
+
if (!input.enabled)
|
|
133
|
+
return disabledSession();
|
|
134
|
+
const portless = await loadPortless();
|
|
135
|
+
if (portless === undefined) {
|
|
136
|
+
input.log?.("fusion: portless not installed (needs Node >= 24); using loopback URLs");
|
|
137
|
+
return disabledSession();
|
|
138
|
+
}
|
|
139
|
+
const proxy = await detectProxy();
|
|
140
|
+
if (proxy === undefined) {
|
|
141
|
+
// Portless is installed but its proxy isn't running: degrade to loopback
|
|
142
|
+
// (never block a run) and point the user at the one-time setup for stable
|
|
143
|
+
// HTTPS names.
|
|
144
|
+
input.log?.("fusion: portless proxy not running; using loopback URLs " +
|
|
145
|
+
"(run `portless service install` + `portless trust` for stable https://*.localhost names)");
|
|
146
|
+
return disabledSession();
|
|
147
|
+
}
|
|
148
|
+
const store = new portless.RouteStore(stateDir(), {
|
|
149
|
+
onWarning: (message) => input.log?.(`fusion: portless: ${message}`)
|
|
150
|
+
});
|
|
151
|
+
const urlFor = (hostname) => portless.formatUrl(hostname, proxy.port, proxy.tls);
|
|
152
|
+
const register = (name, appPort) => {
|
|
153
|
+
try {
|
|
154
|
+
const hostname = hostnameFor(portless, name);
|
|
155
|
+
store.addRoute(hostname, appPort, process.pid, true);
|
|
156
|
+
return urlFor(hostname);
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
// Never let a routing hiccup break a run; fall back to the raw port.
|
|
160
|
+
input.log?.(`fusion: portless register(${name}) failed: ${errorText(error)}`);
|
|
161
|
+
return loopback(appPort);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const unregister = (name) => {
|
|
165
|
+
try {
|
|
166
|
+
store.removeRoute(hostnameFor(portless, name), process.pid);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
// best-effort
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
const discoverOrSpawn = async (req) => {
|
|
173
|
+
const hostname = hostnameFor(portless, req.name);
|
|
174
|
+
// Discover: is a compatible instance already registered and alive?
|
|
175
|
+
try {
|
|
176
|
+
const existing = store.loadRoutes().find((route) => route.hostname === hostname);
|
|
177
|
+
if (existing !== undefined) {
|
|
178
|
+
const candidate = loopback(existing.port);
|
|
179
|
+
const identity = await req.healthCheck(candidate);
|
|
180
|
+
if (identity === req.identity) {
|
|
181
|
+
return {
|
|
182
|
+
url: urlFor(hostname),
|
|
183
|
+
loopbackUrl: candidate,
|
|
184
|
+
port: existing.port,
|
|
185
|
+
owned: false,
|
|
186
|
+
close: () => { }
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
input.log?.(`fusion: portless discover(${req.name}) failed: ${errorText(error)}`);
|
|
193
|
+
}
|
|
194
|
+
// Spawn + register a fresh instance, owned by the service pid so the proxy's
|
|
195
|
+
// liveness filter keeps the route across runs and only drops it when the
|
|
196
|
+
// service itself exits.
|
|
197
|
+
const service = await req.spawn();
|
|
198
|
+
let url = loopback(service.port);
|
|
199
|
+
try {
|
|
200
|
+
store.addRoute(hostname, service.port, service.pid ?? process.pid, true);
|
|
201
|
+
url = urlFor(hostname);
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
input.log?.(`fusion: portless register(${req.name}) failed: ${errorText(error)}`);
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
url,
|
|
208
|
+
loopbackUrl: loopback(service.port),
|
|
209
|
+
port: service.port,
|
|
210
|
+
owned: true,
|
|
211
|
+
close: service.close
|
|
212
|
+
};
|
|
213
|
+
};
|
|
214
|
+
return { enabled: true, caCertPath: caCertPath(), register, unregister, discoverOrSpawn };
|
|
215
|
+
}
|
|
216
|
+
function errorText(error) {
|
|
217
|
+
return error instanceof Error ? error.message : String(error);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Reap fusion singleton services: terminate the owning pid of every registered
|
|
221
|
+
* `*.fusion.<tld>` / `scope.<tld>` route and drop the route. Returns the number
|
|
222
|
+
* of services stopped. A no-op when portless is unavailable.
|
|
223
|
+
*/
|
|
224
|
+
export async function reapFusionServices(log) {
|
|
225
|
+
const portless = await loadPortless();
|
|
226
|
+
if (portless === undefined)
|
|
227
|
+
return 0;
|
|
228
|
+
const store = new portless.RouteStore(stateDir(), {
|
|
229
|
+
onWarning: (message) => log?.(`fusion: portless: ${message}`)
|
|
230
|
+
});
|
|
231
|
+
const suffix = `.${PROJECT}.${tld()}`;
|
|
232
|
+
const scopeHost = portless.parseHostname("scope", tld());
|
|
233
|
+
let stopped = 0;
|
|
234
|
+
for (const route of store.loadRoutes()) {
|
|
235
|
+
if (!route.hostname.endsWith(suffix) && route.hostname !== scopeHost)
|
|
236
|
+
continue;
|
|
237
|
+
try {
|
|
238
|
+
process.kill(route.pid, "SIGTERM");
|
|
239
|
+
stopped += 1;
|
|
240
|
+
log?.(`fusion: stopped ${route.hostname} (pid ${route.pid})`);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
// process already gone; still drop the stale route below
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
store.removeRoute(route.hostname);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
// best-effort
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return stopped;
|
|
253
|
+
}
|
package/dist/test/cli.test.js
CHANGED
|
@@ -6,16 +6,14 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync
|
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
|
-
import {
|
|
9
|
+
import { test } from "node:test";
|
|
10
10
|
import { MODEL_FUSION_SCHEMA_BUNDLE_HASH } from "@fusionkit/protocol";
|
|
11
|
-
import { makeRepo as makeStackRepo, mockRunRequest, startStack, uploadWorkspace } from "@fusionkit/testkit";
|
|
12
11
|
const CLI = fileURLToPath(new URL("../index.js", import.meta.url));
|
|
13
12
|
const SMOKE_ENV_KEYS = [
|
|
14
13
|
"WARRANT_CLAUDE_SMOKE",
|
|
15
14
|
"WARRANT_CODEX_SMOKE",
|
|
16
15
|
"WARRANT_ENSEMBLE_LIVE_SMOKE"
|
|
17
16
|
];
|
|
18
|
-
let home;
|
|
19
17
|
async function readBody(req) {
|
|
20
18
|
const chunks = [];
|
|
21
19
|
for await (const chunk of req)
|
|
@@ -100,7 +98,7 @@ function warrant(args, options = {}) {
|
|
|
100
98
|
else
|
|
101
99
|
env[key] = value;
|
|
102
100
|
}
|
|
103
|
-
const result = spawnSync(process.execPath, [CLI,
|
|
101
|
+
const result = spawnSync(process.execPath, [CLI, ...args], {
|
|
104
102
|
encoding: "utf8",
|
|
105
103
|
env,
|
|
106
104
|
input: options.input
|
|
@@ -122,7 +120,7 @@ async function warrantAsync(args, options = {}) {
|
|
|
122
120
|
env[key] = value;
|
|
123
121
|
}
|
|
124
122
|
return await new Promise((resolve) => {
|
|
125
|
-
const child = spawn(process.execPath, [CLI,
|
|
123
|
+
const child = spawn(process.execPath, [CLI, ...args], {
|
|
126
124
|
env,
|
|
127
125
|
stdio: ["pipe", "pipe", "pipe"]
|
|
128
126
|
});
|
|
@@ -145,18 +143,11 @@ async function warrantAsync(args, options = {}) {
|
|
|
145
143
|
}
|
|
146
144
|
});
|
|
147
145
|
}
|
|
148
|
-
before(() => {
|
|
149
|
-
home = mkdtempSync(join(tmpdir(), "warrant-cli-test-"));
|
|
150
|
-
rmSync(home, { recursive: true, force: true });
|
|
151
|
-
});
|
|
152
|
-
after(() => {
|
|
153
|
-
rmSync(home, { recursive: true, force: true });
|
|
154
|
-
});
|
|
155
146
|
test("help prints usage and lists the top-level commands", () => {
|
|
156
147
|
const result = warrant(["help"]);
|
|
157
148
|
assert.equal(result.status, 0);
|
|
158
149
|
assert.match(result.stdout, /real model fusion behind your coding agent/);
|
|
159
|
-
for (const command of ["
|
|
150
|
+
for (const command of ["init", "ensemble", "local", "fusion", "codex", "claude", "cursor", "serve"]) {
|
|
160
151
|
assert.match(result.stdout, new RegExp(`\\b${command}\\b`));
|
|
161
152
|
}
|
|
162
153
|
});
|
|
@@ -203,78 +194,29 @@ test("fusion help documents the flags-before-tool contract", () => {
|
|
|
203
194
|
assert.equal(result.status, 0);
|
|
204
195
|
assert.match(result.stdout, /must precede the tool name/);
|
|
205
196
|
});
|
|
206
|
-
test("init
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
assert.match(again.stderr, /already initialized/);
|
|
225
|
-
});
|
|
226
|
-
test("secrets are stored encrypted and listed by name only", () => {
|
|
227
|
-
const set = warrant(["secrets", "set", "NPM_TOKEN", "super-secret-value"]);
|
|
228
|
-
assert.equal(set.status, 0, set.stderr);
|
|
229
|
-
assert.match(set.stdout, /encrypted at rest/);
|
|
230
|
-
const list = warrant(["secrets", "list"]);
|
|
231
|
-
assert.equal(list.status, 0);
|
|
232
|
-
assert.equal(list.stdout.trim(), "NPM_TOKEN");
|
|
233
|
-
const stored = readFileSync(join(home, "secrets.enc"), "utf8");
|
|
234
|
-
assert.ok(!stored.includes("super-secret-value"), "value must be encrypted");
|
|
235
|
-
});
|
|
236
|
-
test("ui prints the control panel address and login token", () => {
|
|
237
|
-
const result = warrant(["ui"]);
|
|
238
|
-
assert.equal(result.status, 0);
|
|
239
|
-
assert.match(result.stdout, /control panel: http:\/\/127\.0\.0\.1:7172\/ui\//);
|
|
240
|
-
assert.match(result.stdout, /login token: {3}\S+/);
|
|
197
|
+
test("init scaffolds a fusionkit.json and refuses to clobber without --force", () => {
|
|
198
|
+
const fixture = makeRepo();
|
|
199
|
+
try {
|
|
200
|
+
const result = warrant(["init", "--repo", fixture.repo]);
|
|
201
|
+
assert.equal(result.status, 0, result.stderr);
|
|
202
|
+
const configPath = join(fixture.repo, "fusionkit.json");
|
|
203
|
+
assert.ok(existsSync(configPath));
|
|
204
|
+
const config = JSON.parse(readFileSync(configPath, "utf8"));
|
|
205
|
+
assert.equal(config.version, "fusionkit.fusion.v1");
|
|
206
|
+
const again = warrant(["init", "--repo", fixture.repo]);
|
|
207
|
+
assert.equal(again.status, 1);
|
|
208
|
+
assert.match(again.stderr, /already exists/);
|
|
209
|
+
const forced = warrant(["init", "--repo", fixture.repo, "--force"]);
|
|
210
|
+
assert.equal(forced.status, 0, forced.stderr);
|
|
211
|
+
}
|
|
212
|
+
finally {
|
|
213
|
+
fixture.cleanup();
|
|
214
|
+
}
|
|
241
215
|
});
|
|
242
|
-
test("unknown commands
|
|
216
|
+
test("unknown commands fail with guidance", () => {
|
|
243
217
|
const unknown = warrant(["frobnicate"]);
|
|
244
218
|
assert.equal(unknown.status, 1);
|
|
245
219
|
assert.match(unknown.stderr, /unknown command/);
|
|
246
|
-
const missingAgent = warrant(["run", "do things"]);
|
|
247
|
-
assert.equal(missingAgent.status, 1);
|
|
248
|
-
assert.match(missingAgent.stderr, /--agent is required/);
|
|
249
|
-
const missingTask = warrant(["continue", "--agent", "mock"]);
|
|
250
|
-
assert.equal(missingTask.status, 1);
|
|
251
|
-
assert.match(missingTask.stderr, /task prompt is required/);
|
|
252
|
-
const badAgent = warrant(["continue", "--agent", "nonsense", "task"]);
|
|
253
|
-
assert.equal(badAgent.status, 1);
|
|
254
|
-
assert.match(badAgent.stderr, /unknown agent kind/);
|
|
255
|
-
});
|
|
256
|
-
test("verify fails closed on a tampered bundle file", () => {
|
|
257
|
-
const path = join(home, "garbage.bundle.json");
|
|
258
|
-
const fake = {
|
|
259
|
-
version: "warrant.bundle.v1",
|
|
260
|
-
contract: { signatures: [], workspace: { baseRef: "x" } },
|
|
261
|
-
receipt: {
|
|
262
|
-
contractHash: "0".repeat(64),
|
|
263
|
-
signatures: [],
|
|
264
|
-
status: "completed",
|
|
265
|
-
workspaceIn: { baseRef: "y", manifestHash: "z" },
|
|
266
|
-
workspaceOut: { diffHash: "", artifactHashes: [] },
|
|
267
|
-
secretsReleased: [],
|
|
268
|
-
eventsHead: "",
|
|
269
|
-
eventCount: 0
|
|
270
|
-
},
|
|
271
|
-
events: [],
|
|
272
|
-
keys: { planePublicKeyPem: "", runnerPublicKeyPem: "" }
|
|
273
|
-
};
|
|
274
|
-
writeFileSync(path, JSON.stringify(fake));
|
|
275
|
-
const result = warrant(["verify", path]);
|
|
276
|
-
assert.equal(result.status, 1);
|
|
277
|
-
assert.match(result.stderr, /VERIFICATION FAILED/);
|
|
278
220
|
});
|
|
279
221
|
function makeRepo() {
|
|
280
222
|
const root = mkdtempSync(join(tmpdir(), "warrant-ensemble-cli-"));
|
|
@@ -630,7 +572,7 @@ test("ensemble dashboard writes markdown and run-result records", () => {
|
|
|
630
572
|
assert.match(result.stdout, /records: 6/);
|
|
631
573
|
assert.ok(existsSync(join(fixture.output, "dashboard.md")));
|
|
632
574
|
assert.ok(existsSync(join(fixture.output, "harness-run-results", "mock-success.json")));
|
|
633
|
-
assert.ok(existsSync(join(fixture.output, "harness-run-results", "cursor-
|
|
575
|
+
assert.ok(existsSync(join(fixture.output, "harness-run-results", "cursor-skipped.json")));
|
|
634
576
|
const dashboard = readFileSync(join(fixture.output, "dashboard.md"), "utf8");
|
|
635
577
|
assert.match(dashboard, /Capability Matrix/);
|
|
636
578
|
assert.match(dashboard, /command-failure/);
|
|
@@ -808,60 +750,3 @@ test("ensemble gateway test runs the unified front-door acceptance suite", async
|
|
|
808
750
|
fixture.cleanup();
|
|
809
751
|
}
|
|
810
752
|
});
|
|
811
|
-
test("lifecycle commands read a real run from a live plane", async () => {
|
|
812
|
-
const stack = await startStack({
|
|
813
|
-
policy: (policy) => {
|
|
814
|
-
policy.agents.allow = ["mock"];
|
|
815
|
-
}
|
|
816
|
-
});
|
|
817
|
-
const repo = makeStackRepo({ files: { "README.md": "# cli lifecycle\n" } });
|
|
818
|
-
const liveHome = mkdtempSync(join(tmpdir(), "warrant-cli-live-"));
|
|
819
|
-
rmSync(liveHome, { recursive: true, force: true });
|
|
820
|
-
try {
|
|
821
|
-
// The plane runs in this test process, so every CLI call must use the async
|
|
822
|
-
// spawner: a synchronous spawn would block the event loop and deadlock the
|
|
823
|
-
// in-process plane.
|
|
824
|
-
const init = await warrantAsync(["init"], { dir: liveHome });
|
|
825
|
-
assert.equal(init.status, 0, init.stderr);
|
|
826
|
-
// Point the freshly initialized home at the in-process test stack.
|
|
827
|
-
const configPath = join(liveHome, "config.json");
|
|
828
|
-
const config = JSON.parse(readFileSync(configPath, "utf8"));
|
|
829
|
-
config.planeUrl = stack.planeUrl;
|
|
830
|
-
config.adminToken = stack.adminToken;
|
|
831
|
-
writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
832
|
-
// Create one completed run through the SDK so the CLI has something to read.
|
|
833
|
-
const captured = await uploadWorkspace(stack.client, repo);
|
|
834
|
-
const created = await stack.client.requestRun(mockRunRequest({ prompt: "lifecycle probe", pool: stack.pool, workspace: captured.manifest }));
|
|
835
|
-
if (created.status === "awaiting_approval") {
|
|
836
|
-
await stack.client.approve(created.runId, { kind: "human", id: "cli-tester" });
|
|
837
|
-
}
|
|
838
|
-
assert.ok(await stack.runOnce());
|
|
839
|
-
const runs = await warrantAsync(["runs"], { dir: liveHome });
|
|
840
|
-
assert.equal(runs.status, 0, runs.stderr);
|
|
841
|
-
assert.match(runs.stdout, new RegExp(created.runId));
|
|
842
|
-
const receipt = await warrantAsync(["receipt", created.runId], { dir: liveHome });
|
|
843
|
-
assert.equal(receipt.status, 0, receipt.stderr);
|
|
844
|
-
const bundlePath = join(liveHome, "out.bundle.json");
|
|
845
|
-
const bundle = await warrantAsync(["bundle", created.runId, "--out", bundlePath], {
|
|
846
|
-
dir: liveHome
|
|
847
|
-
});
|
|
848
|
-
assert.equal(bundle.status, 0, bundle.stderr);
|
|
849
|
-
assert.match(bundle.stdout, /bundle written to/);
|
|
850
|
-
assert.ok(existsSync(bundlePath));
|
|
851
|
-
// The CLI round-trips its own bundle through offline verification.
|
|
852
|
-
const verify = await warrantAsync(["verify", bundlePath], { dir: liveHome });
|
|
853
|
-
assert.equal(verify.status, 0, verify.stderr);
|
|
854
|
-
assert.match(verify.stdout, /VERIFIED/);
|
|
855
|
-
const exported = await warrantAsync(["export"], { dir: liveHome });
|
|
856
|
-
assert.equal(exported.status, 0, exported.stderr);
|
|
857
|
-
assert.match(exported.stdout, new RegExp(created.runId));
|
|
858
|
-
const pull = await warrantAsync(["pull", created.runId, "--repo", repo], { dir: liveHome });
|
|
859
|
-
assert.equal(pull.status, 0, pull.stderr);
|
|
860
|
-
assert.match(pull.stdout, /applied|nothing to pull|branch/);
|
|
861
|
-
}
|
|
862
|
-
finally {
|
|
863
|
-
await stack.stop();
|
|
864
|
-
rmSync(repo, { recursive: true, force: true });
|
|
865
|
-
rmSync(liveHome, { recursive: true, force: true });
|
|
866
|
-
}
|
|
867
|
-
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { after, test } from "node:test";
|
|
6
|
+
import { caCertPath, createPortlessSession, detectProxy, stateDir } from "../shared/portless.js";
|
|
7
|
+
const tmpRoots = [];
|
|
8
|
+
function freshStateDir() {
|
|
9
|
+
const dir = mkdtempSync(join(tmpdir(), "portless-state-"));
|
|
10
|
+
tmpRoots.push(dir);
|
|
11
|
+
process.env.PORTLESS_STATE_DIR = dir;
|
|
12
|
+
return dir;
|
|
13
|
+
}
|
|
14
|
+
after(() => {
|
|
15
|
+
delete process.env.PORTLESS_STATE_DIR;
|
|
16
|
+
for (const dir of tmpRoots)
|
|
17
|
+
rmSync(dir, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
test("stateDir + caCertPath honor PORTLESS_STATE_DIR", () => {
|
|
20
|
+
const dir = freshStateDir();
|
|
21
|
+
assert.equal(stateDir(), dir);
|
|
22
|
+
assert.equal(caCertPath(), join(dir, "ca.pem"));
|
|
23
|
+
});
|
|
24
|
+
test("detectProxy returns undefined when no proxy.port file exists", async () => {
|
|
25
|
+
freshStateDir();
|
|
26
|
+
assert.equal(await detectProxy(), undefined);
|
|
27
|
+
});
|
|
28
|
+
test("detectProxy returns undefined for a dead proxy pid", async () => {
|
|
29
|
+
const dir = freshStateDir();
|
|
30
|
+
writeFileSync(join(dir, "proxy.port"), "443");
|
|
31
|
+
// pid 1 is alive but won't answer a portless probe; a clearly-dead pid keeps
|
|
32
|
+
// this deterministic without binding a port.
|
|
33
|
+
writeFileSync(join(dir, "proxy.pid"), "999999999");
|
|
34
|
+
assert.equal(await detectProxy(), undefined);
|
|
35
|
+
});
|
|
36
|
+
test("a disabled session uses loopback URLs and never registers", async () => {
|
|
37
|
+
const session = await createPortlessSession({ enabled: false });
|
|
38
|
+
assert.equal(session.enabled, false);
|
|
39
|
+
assert.equal(session.caCertPath, undefined);
|
|
40
|
+
assert.equal(session.register("scope", 4317), "http://127.0.0.1:4317");
|
|
41
|
+
assert.equal(session.register("gateway", 5123), "http://127.0.0.1:5123");
|
|
42
|
+
session.unregister("scope"); // no throw
|
|
43
|
+
let spawned = 0;
|
|
44
|
+
const result = await session.discoverOrSpawn({
|
|
45
|
+
name: "router",
|
|
46
|
+
identity: "gpt,sonnet",
|
|
47
|
+
healthCheck: async () => "gpt,sonnet",
|
|
48
|
+
spawn: async () => {
|
|
49
|
+
spawned += 1;
|
|
50
|
+
return { port: 6001, close: () => { } };
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
assert.equal(spawned, 1, "a disabled session always spawns (no discovery)");
|
|
54
|
+
assert.equal(result.owned, true);
|
|
55
|
+
assert.equal(result.url, "http://127.0.0.1:6001");
|
|
56
|
+
assert.equal(result.loopbackUrl, "http://127.0.0.1:6001");
|
|
57
|
+
});
|
|
58
|
+
test("enabled session with no reachable proxy degrades to loopback", async () => {
|
|
59
|
+
freshStateDir(); // empty: no proxy.port
|
|
60
|
+
const session = await createPortlessSession({ enabled: true });
|
|
61
|
+
// Whether or not portless is installed, an unreachable proxy degrades to
|
|
62
|
+
// loopback (never a hard failure) so a fresh install always runs.
|
|
63
|
+
assert.equal(session.enabled, false, "no proxy detected -> disabled");
|
|
64
|
+
assert.equal(session.register("scope", 4317), "http://127.0.0.1:4317");
|
|
65
|
+
});
|