@isomorph.ai/cli 0.2.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.
Files changed (30) hide show
  1. package/README.md +55 -0
  2. package/dist/packages/harbour-cli/src/agent-setup.js +235 -0
  3. package/dist/packages/harbour-cli/src/app-schema.js +142 -0
  4. package/dist/packages/harbour-cli/src/auth.js +197 -0
  5. package/dist/packages/harbour-cli/src/check.js +317 -0
  6. package/dist/packages/harbour-cli/src/cli.js +321 -0
  7. package/dist/packages/harbour-cli/src/config.js +97 -0
  8. package/dist/packages/harbour-cli/src/dev.js +125 -0
  9. package/dist/packages/harbour-cli/src/forwarder.js +168 -0
  10. package/dist/packages/harbour-cli/src/integrations.js +385 -0
  11. package/dist/packages/harbour-cli/src/jobs.js +53 -0
  12. package/dist/packages/harbour-cli/src/kit-bundle.js +41 -0
  13. package/dist/packages/harbour-cli/src/kit-bundle.manifest.js +32 -0
  14. package/dist/packages/harbour-cli/src/kit.js +149 -0
  15. package/dist/packages/harbour-cli/src/local-runtime.js +526 -0
  16. package/dist/packages/harbour-cli/src/operations.js +382 -0
  17. package/dist/packages/harbour-cli/src/output.js +273 -0
  18. package/dist/packages/harbour-cli/src/productionise.js +508 -0
  19. package/dist/packages/harbour-cli/src/remote-mcp-client.js +121 -0
  20. package/dist/packages/harbour-cli/src/retained-checks.js +435 -0
  21. package/dist/packages/harbour-cli/src/source-inventory.js +239 -0
  22. package/dist/packages/harbour-cli/src/starter.js +557 -0
  23. package/dist/packages/harbour-cli/src/upload.js +163 -0
  24. package/dist/packages/harbour-cli/src/version.js +1 -0
  25. package/dist/src/analyzer.js +685 -0
  26. package/dist/src/contracts.js +82 -0
  27. package/dist/src/digest.js +26 -0
  28. package/dist/src/secret-paths.js +38 -0
  29. package/dist/src/source-intake.js +125 -0
  30. package/package.json +43 -0
@@ -0,0 +1,197 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { homedir, platform } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { spawn } from "node:child_process";
7
+ export const requestedOAuthScopes = "mcp";
8
+ export function tokenStorePath(env = process.env) {
9
+ return join(env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config"), "isomorph", "tokens.json");
10
+ }
11
+ export function tokenStoreKey(mcpUrl, tenant) { return `${new URL(mcpUrl).toString()}|${tenant}`; }
12
+ export async function loadStoredToken(mcpUrl, tenant, path = tokenStorePath()) {
13
+ try {
14
+ const parsed = JSON.parse(await readFile(path, "utf8"));
15
+ const token = parsed.version === 1 ? parsed.tokens?.[tokenStoreKey(mcpUrl, tenant)] : undefined;
16
+ return validToken(token) ? token : undefined;
17
+ }
18
+ catch (error) {
19
+ if (isMissing(error))
20
+ return undefined;
21
+ throw new Error("Isomorph could not read its local sign-in record. Run `isomorph logout` and sign in again.");
22
+ }
23
+ }
24
+ export async function saveStoredToken(mcpUrl, tenant, token, path = tokenStorePath()) {
25
+ if (!validToken(token))
26
+ throw new Error("Isomorph received an incomplete sign-in response.");
27
+ let store = { version: 1, tokens: {} };
28
+ try {
29
+ store = JSON.parse(await readFile(path, "utf8"));
30
+ }
31
+ catch (error) {
32
+ if (!isMissing(error))
33
+ throw error;
34
+ }
35
+ if (store.version !== 1 || !store.tokens || typeof store.tokens !== "object")
36
+ store = { version: 1, tokens: {} };
37
+ store.tokens[tokenStoreKey(mcpUrl, tenant)] = token;
38
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
39
+ await chmod(dirname(path), 0o700);
40
+ await writeFile(path, JSON.stringify(store), { mode: 0o600 });
41
+ await chmod(path, 0o600);
42
+ }
43
+ export async function clearStoredToken(mcpUrl, tenant, path = tokenStorePath()) {
44
+ const token = await loadStoredToken(mcpUrl, tenant, path);
45
+ if (!token)
46
+ return undefined;
47
+ const store = JSON.parse(await readFile(path, "utf8"));
48
+ delete store.tokens[tokenStoreKey(mcpUrl, tenant)];
49
+ if (Object.keys(store.tokens).length)
50
+ await writeFile(path, JSON.stringify(store), { mode: 0o600 });
51
+ else
52
+ await rm(path, { force: true });
53
+ return token;
54
+ }
55
+ export async function login(mcpUrl, tenant, output) {
56
+ const metadata = await discover(mcpUrl);
57
+ if (!metadata.registration_endpoint || !metadata.authorization_endpoint || !metadata.token_endpoint)
58
+ throw new Error("Isomorph sign-in is not available on this endpoint yet.");
59
+ const callback = await loopbackCallback();
60
+ try {
61
+ const registered = await json(metadata.registration_endpoint, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ client_name: "Isomorph CLI", redirect_uris: [callback.redirectUri], grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none" }) });
62
+ if (!registered.client_id)
63
+ throw new Error("Isomorph did not register this sign-in session.");
64
+ const verifier = randomBytes(32).toString("base64url");
65
+ const state = randomBytes(24).toString("base64url");
66
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
67
+ const authorize = new URL(metadata.authorization_endpoint);
68
+ authorize.search = new URLSearchParams({ response_type: "code", client_id: registered.client_id, redirect_uri: callback.redirectUri, scope: requestedOAuthScopes, state, resource: mcpUrl, code_challenge: challenge, code_challenge_method: "S256" }).toString();
69
+ output("Isomorph is opening your browser to sign in.");
70
+ openBrowser(authorize.toString());
71
+ output(`If it does not open, visit: ${authorize}`);
72
+ const code = await callback.wait(state);
73
+ const response = await token(metadata.token_endpoint, { grant_type: "authorization_code", client_id: registered.client_id, code, redirect_uri: callback.redirectUri, code_verifier: verifier });
74
+ await saveStoredToken(mcpUrl, tenant, { ...response, clientId: registered.client_id, tokenEndpoint: metadata.token_endpoint, ...(metadata.revocation_endpoint ? { revocationEndpoint: metadata.revocation_endpoint } : {}) });
75
+ output("Isomorph sign-in is complete.");
76
+ }
77
+ finally {
78
+ callback.close();
79
+ }
80
+ }
81
+ /** Refresh only when the access token is within this margin of expiry. */
82
+ const REFRESH_MARGIN_MS = 5 * 60_000;
83
+ /**
84
+ * Returns a usable access token, refreshing only when the stored one is about
85
+ * to expire. Refresh tokens are single-use, so every CLI process refreshing on
86
+ * start meant two processes at once (a `productionise` still polling and a
87
+ * `status` beside it) spent the same refresh token and one was told to sign in
88
+ * again. A refresh that fails now re-reads the store once: if a sibling
89
+ * process rotated the pair in the meantime, its newer token is used instead.
90
+ */
91
+ export async function refreshStoredToken(mcpUrl, tenant, path = tokenStorePath(), now = Date.now) {
92
+ const stored = await loadStoredToken(mcpUrl, tenant, path);
93
+ if (!stored)
94
+ return undefined;
95
+ if (!stored.refreshToken)
96
+ return stored.accessToken;
97
+ if (stored.accessTokenExpiresAt !== undefined && stored.accessTokenExpiresAt - REFRESH_MARGIN_MS > now())
98
+ return stored.accessToken;
99
+ try {
100
+ const refreshed = await token(stored.tokenEndpoint, { grant_type: "refresh_token", client_id: stored.clientId, refresh_token: stored.refreshToken });
101
+ await saveStoredToken(mcpUrl, tenant, { ...stored, ...refreshed, refreshToken: refreshed.refreshToken ?? stored.refreshToken }, path);
102
+ return refreshed.accessToken;
103
+ }
104
+ catch {
105
+ const rotated = await loadStoredToken(mcpUrl, tenant, path);
106
+ if (rotated && rotated.refreshToken !== stored.refreshToken && (rotated.accessTokenExpiresAt === undefined || rotated.accessTokenExpiresAt - REFRESH_MARGIN_MS > now()))
107
+ return rotated.accessToken;
108
+ throw new Error("Isomorph sign-in expired or was revoked. Run `isomorph login` again.");
109
+ }
110
+ }
111
+ /**
112
+ * Removes the local record and revokes both stored tokens when the server
113
+ * advertises a revocation endpoint. Offline revocation cannot reach a token
114
+ * already held elsewhere; the local copy is removed regardless.
115
+ */
116
+ export async function logout(mcpUrl, tenant, path = tokenStorePath()) {
117
+ const stored = await clearStoredToken(mcpUrl, tenant, path);
118
+ if (!stored?.revocationEndpoint)
119
+ return;
120
+ for (const [token, hint] of [[stored.refreshToken, "refresh_token"], [stored.accessToken, "access_token"]]) {
121
+ if (!token)
122
+ continue;
123
+ try {
124
+ await fetch(stored.revocationEndpoint, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ token, token_type_hint: hint, client_id: stored.clientId }), redirect: "error" });
125
+ }
126
+ catch { /* Local credentials are removed even if offline revocation cannot complete. */ }
127
+ }
128
+ }
129
+ /** Email of the signed-in company account, from the OAuth userinfo endpoint beside the token endpoint; undefined when unavailable. */
130
+ export async function connectedAccount(mcpUrl, tenant, accessToken, path = tokenStorePath()) {
131
+ const stored = await loadStoredToken(mcpUrl, tenant, path);
132
+ if (!stored)
133
+ return undefined;
134
+ try {
135
+ const response = await fetch(stored.tokenEndpoint.replace(/\/token$/, "/userinfo"), { headers: { authorization: `Bearer ${accessToken}` }, redirect: "error" });
136
+ const body = response.ok ? await response.json() : {};
137
+ return typeof body.email === "string" ? body.email : undefined;
138
+ }
139
+ catch {
140
+ return undefined;
141
+ }
142
+ }
143
+ async function discover(mcpUrl) {
144
+ const resource = new URL(mcpUrl);
145
+ resource.pathname = `${resource.pathname.replace(/\/mcp$/, "")}/.well-known/oauth-protected-resource`.replace(/\/\//g, "/");
146
+ const protectedResource = await json(resource.toString());
147
+ const server = protectedResource.authorization_servers?.[0];
148
+ if (!server)
149
+ throw new Error("Isomorph did not advertise a sign-in service.");
150
+ const wellKnown = authorizationServerMetadataUrl(server);
151
+ const metadata = await json(wellKnown);
152
+ if (!metadata.authorization_endpoint || !metadata.token_endpoint || !metadata.registration_endpoint)
153
+ throw new Error("Isomorph sign-in metadata is incomplete.");
154
+ return { authorization_endpoint: metadata.authorization_endpoint, token_endpoint: metadata.token_endpoint, registration_endpoint: metadata.registration_endpoint, revocation_endpoint: metadata.revocation_endpoint ?? "" };
155
+ }
156
+ async function token(endpoint, values) {
157
+ const response = await json(endpoint, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(values), redirect: "error" });
158
+ if (!response.access_token)
159
+ throw new Error("Isomorph sign-in did not return an access token.");
160
+ return { accessToken: response.access_token, ...(response.refresh_token ? { refreshToken: response.refresh_token } : {}), ...(typeof response.expires_in === "number" && response.expires_in > 0 ? { accessTokenExpiresAt: Date.now() + response.expires_in * 1_000 } : {}) };
161
+ }
162
+ async function json(url, init) { const response = await fetch(url, { ...init, redirect: "error" }); if (!response.ok)
163
+ throw new Error(`Isomorph sign-in request failed (${response.status}).`); return response.json(); }
164
+ export function authorizationServerMetadataUrl(server) { return `${server.replace(/\/$/, "")}/.well-known/oauth-authorization-server`; }
165
+ function validToken(value) { return Boolean(value && typeof value === "object" && typeof value.accessToken === "string" && typeof value.clientId === "string" && typeof value.tokenEndpoint === "string"); }
166
+ function isMissing(error) { return Boolean(error && typeof error === "object" && error.code === "ENOENT"); }
167
+ function loopbackCallback() {
168
+ return new Promise((resolve, reject) => {
169
+ const server = createServer();
170
+ const timer = setTimeout(() => { server.close(); reject(new Error("Isomorph sign-in timed out. Run `isomorph login` again.")); }, 5 * 60_000);
171
+ server.on("request", (request, response) => {
172
+ const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");
173
+ const code = requestUrl.searchParams.get("code");
174
+ const state = requestUrl.searchParams.get("state");
175
+ response.writeHead(code && state ? 200 : 400, { "content-type": "text/plain; charset=utf-8" });
176
+ response.end(code && state ? "Isomorph sign-in complete. You can close this tab." : "Isomorph sign-in could not be completed.");
177
+ if (code && state)
178
+ server.callback = { code, state };
179
+ });
180
+ server.listen(0, "127.0.0.1", () => {
181
+ const address = server.address();
182
+ if (!address || typeof address === "string") {
183
+ reject(new Error("Isomorph could not start local sign-in."));
184
+ return;
185
+ }
186
+ resolve({ redirectUri: `http://127.0.0.1:${address.port}/callback`, wait: expected => new Promise((done, fail) => {
187
+ const poll = setInterval(() => { const callback = server.callback; if (!callback)
188
+ return; clearInterval(poll); clearTimeout(timer); if (callback.state !== expected)
189
+ fail(new Error("Isomorph sign-in returned an invalid state."));
190
+ else
191
+ done(callback.code); }, 25);
192
+ }), close: () => { clearTimeout(timer); server.close(); } });
193
+ });
194
+ server.on("error", reject);
195
+ });
196
+ }
197
+ function openBrowser(url) { const command = platform() === "darwin" ? "open" : platform() === "win32" ? "cmd" : "xdg-open"; const args = platform() === "win32" ? ["/c", "start", "", url] : [url]; const child = spawn(command, args, { detached: true, stdio: "ignore" }); child.unref(); child.on("error", () => { }); }
@@ -0,0 +1,317 @@
1
+ import { readFile, readdir, rm, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { readAppSchema } from "./app-schema.js";
4
+ import { describeRetainedChecks, syncRetainedChecks } from "./retained-checks.js";
5
+ import { CliError } from "./output.js";
6
+ import { DEPENDENT_READ_OPERATIONS, READ_OPERATIONS, kitPaths, readDeclaration, readKitLock, resourceNames, sourceDigest } from "./kit.js";
7
+ import { LocalRuntime, allocatePorts, ensureSdk, freePort, nodePackageCommand, readDevLock, runCommand, runningOrigin } from "./local-runtime.js";
8
+ import { CLI_VERSION } from "./version.js";
9
+ /** The name the gate reports operation coverage under — the pipeline's rule for it. */
10
+ export const FLOW_CHECK = "flow.check-failed";
11
+ /**
12
+ * Runs the kit checks and writes `.isomorph/local/check-report.json`; a source
13
+ * edit changes sourceDigest and so invalidates the previous report. The app's
14
+ * own toolchain first (typecheck, build), then the kit gate — the pipeline's
15
+ * gate, run by the pinned gateway image in this app's local session — whose
16
+ * checks are reported verbatim. The retained journeys are generated inside
17
+ * that run, from the app's tables as the gate's replayed database holds
18
+ * them, before the gate enumerates and runs them.
19
+ */
20
+ export async function runChecks(root, options) {
21
+ const { output, run, bundle } = options;
22
+ const checks = [];
23
+ const record = (check) => { checks.push(check); output(renderCheck(check)); };
24
+ const previous = await readReport(root);
25
+ if (previous && previous.sourceDigest !== (await sourceDigest(root)).digest)
26
+ output("Source changed since the last report; the previous report is no longer valid.");
27
+ const npx = nodePackageCommand("npx", ["tsc", "--noEmit"]);
28
+ const typecheck = await run(npx.command, npx.args, { cwd: root, quiet: true });
29
+ record({ name: "typecheck", status: typecheck.code === 0 ? "pass" : "fail", ...(typecheck.code === 0 ? {} : { detail: lastLines(typecheck.stdout || typecheck.stderr) }) });
30
+ const npm = nodePackageCommand("npm", ["run", "build"]);
31
+ const build = await run(npm.command, npm.args, { cwd: root, quiet: true });
32
+ record({ name: "build", status: build.code === 0 ? "pass" : "fail", ...(build.code === 0 ? {} : { detail: lastLines(build.stderr || build.stdout) }) });
33
+ // The journeys run here with the app's own node and SDK: the SDK the bundle
34
+ // pins, installed now if node_modules holds an older copy.
35
+ const sdk = await ensureSdk(root, bundle, options.env ?? process.env, run, output).catch(error => { output(error instanceof Error ? error.message : String(error)); return "missing"; });
36
+ if (sdk === "missing")
37
+ output(`The kit SDK is not installed: set ISOMORPH_KIT_SDK_TARBALL to the bundle's ${bundle.sdk.package} tarball (or use a bundle with sdk.url).`);
38
+ const gate = await runKitGate(root, { run, bundle, output, ...(options.fetch ? { fetch: options.fetch } : {}), ...(options.runtime ? { runtime: options.runtime } : {}) });
39
+ for (const check of gate.checks)
40
+ record(check);
41
+ let integrations = "not tested";
42
+ if (options.governance) {
43
+ // Reads that could not run are one check the report carries as not_run with
44
+ // the reason, beside the gate's own skipped checks where an agent reads
45
+ // outcomes — not exit 1 with the reason dropped, and not a stderr line beside
46
+ // `"not tested"`, the shape of a run that never asked. Observed 2026-09-12
47
+ // (CLI 0.1.29): every gate check passed, then the grant list's transport
48
+ // failure ended the run as `fetch failed` (CLI_FAILED) with no report written.
49
+ const skipped = (why) => record({ name: "integrations", status: "not_run", detail: why });
50
+ const lock = await readKitLock(root).catch(() => undefined);
51
+ const { declaration, errors } = await readDeclaration(root);
52
+ if (!lock?.appId)
53
+ skipped("the app is not linked yet; `isomorph dev` (signed in) or `isomorph integrations request` links it");
54
+ else if (errors.length)
55
+ skipped(`.isomorph/integrations.json is invalid: ${errors[0]}`);
56
+ else {
57
+ try {
58
+ integrations = await testIntegrationReads(lock.appId, options.governance, declaration, output);
59
+ if (!integrations.length)
60
+ output("No READY development grants with read operations; real integrations were not exercised.");
61
+ }
62
+ catch (error) {
63
+ // Governance's answer about the grant list — unreachable, sign-in gone, a refusal — is about the leg, never about the app.
64
+ if (!(error instanceof CliError))
65
+ throw error;
66
+ skipped(error.message);
67
+ }
68
+ }
69
+ }
70
+ // After the gate, because `.isomorph/checks/*.mjs` is part of the deployed
71
+ // tree (kit.ts `sourceDigest`) and the gate may have regenerated it: the
72
+ // report has to pin the tree the journeys actually ran against, or
73
+ // `isomorph productionise` would refuse the very tree this check just passed
74
+ // as CHECKS_STALE.
75
+ const source = await sourceDigest(root);
76
+ const report = {
77
+ schema: "isomorph.check-report/1.0",
78
+ createdAt: new Date().toISOString(),
79
+ sourceDigest: source.digest,
80
+ toolchain: { node: process.version, cliVersion: CLI_VERSION, bundle: { kitVersion: bundle.kitVersion, sdkTarballSha256: bundle.sdk.tarballSha256, appGateway: bundle.images.appGateway, sessionFixture: bundle.images.sessionFixture, briefFingerprint: bundle.brief.fingerprint } },
81
+ checks,
82
+ gate,
83
+ integrations,
84
+ ai: "not tested",
85
+ passed: checks.every(check => check.status !== "fail") && (integrations === "not tested" || integrations.every(item => item.status !== "fail"))
86
+ };
87
+ await writeFile(kitPaths(root).report, `${JSON.stringify(report, null, 2)}\n`).catch(() => undefined);
88
+ return report;
89
+ }
90
+ function renderCheck(check) {
91
+ const mark = check.status === "pass" ? "ok " : check.status === "fail" ? "FAIL" : "skip";
92
+ return `${mark} ${check.name}${check.detail ? ` — ${indentLines(check.detail)}` : ""}`;
93
+ }
94
+ /**
95
+ * Runs the pipeline's kit gate in this app's local session: the pinned
96
+ * native gateway's `gate` subcommand as a one-shot process using the local
97
+ * session's scratch database and files. The gate's control API drives two things
98
+ * from here: the `checks` phase, where the gate hands over the app's tables
99
+ * as its replayed database holds them and `.isomorph/checks/` is regenerated
100
+ * from them (retained-checks.ts) and handed back as the set to run; and the
101
+ * journeys, run here — the app's own node and SDK — one at a time as the
102
+ * gate asks for them. The session services are started when `isomorph dev`
103
+ * is not running and stopped again afterwards.
104
+ */
105
+ export async function runKitGate(root, options) {
106
+ const { run, bundle, output } = options;
107
+ const fetchImpl = options.fetch ?? fetch;
108
+ const runtime = options.runtime ?? new LocalRuntime(root, run);
109
+ const started = await ensureSession(root, runtime, bundle, output);
110
+ const port = await freePort();
111
+ const base = `http://127.0.0.1:${port}`;
112
+ const deadline = Date.now() + (options.timeoutMs ?? 10 * 60_000);
113
+ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
114
+ const request = async (path, init) => { try {
115
+ return await fetchImpl(`${base}${path}`, init);
116
+ }
117
+ catch {
118
+ return undefined;
119
+ } };
120
+ const post = (path, body) => request(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
121
+ // Everything after the session is up runs under one `finally`: a gate
122
+ // gate process that fails to start is exactly the case that could leave a
123
+ // session this run had started still running.
124
+ let container;
125
+ try {
126
+ container = await runtime.startGate(bundle, port, base);
127
+ let announced = false;
128
+ while (Date.now() < deadline) {
129
+ const done = await request("/_gate/report");
130
+ if (done?.status === 200) {
131
+ const result = await done.json();
132
+ if (result.report) {
133
+ // The app is mounted read-only and the gate works on a mirror, so
134
+ // what it authored for the app to ship comes home here — today the
135
+ // governed-AI inventory (`.isomorph/ai-inventory.json`), which the
136
+ // pipeline verifies against the source and which only the gate may
137
+ // write. Written before the caller pins the tree digest, like the
138
+ // regenerated retained checks.
139
+ await writeGateFiles(root, result.files ?? {}, output);
140
+ return result.report;
141
+ }
142
+ throw new CliError("LOCAL_RUNTIME_FAILED", `The kit gate could not run: ${result.error ?? "no report"}.`);
143
+ }
144
+ if (!(await runtime.gateRunning(container)))
145
+ throw new CliError("LOCAL_RUNTIME_FAILED", `The kit gate stopped before reporting: ${await runtime.gateLogs(container)}`);
146
+ const session = await request("/_gate/session");
147
+ const state = session?.status === 200 ? await session.json() : undefined;
148
+ if (state?.status === "checks") {
149
+ const files = await regenerateRetainedChecks(root, state.catalog, state.capabilities, output);
150
+ const posted = await post("/_gate/checks", { files });
151
+ if (posted?.status !== 200)
152
+ throw new CliError("LOCAL_RUNTIME_FAILED", "The kit gate did not accept the retained checks.");
153
+ continue;
154
+ }
155
+ if (state?.status === "journeys" && state.current && state.environment) {
156
+ if (!announced) {
157
+ announced = true;
158
+ output("Running the retained journeys against the gate's App Gateway.");
159
+ }
160
+ const name = state.current;
161
+ const result = await run("node", [join(kitPaths(root).checks, name)], { cwd: root, quiet: true, env: { ...state.environment, ISOMORPH_SDK_MODULE: join(root, "node_modules", ...bundle.sdk.package.split("/"), "dist", "index.js"), NODE_NO_WARNINGS: "1" } });
162
+ const posted = await post(`/_gate/journeys/${encodeURIComponent(name)}`, { code: result.code, output: `${result.stdout}${result.stderr}`.slice(-4000) });
163
+ if (posted?.status !== 200)
164
+ throw new CliError("LOCAL_RUNTIME_FAILED", `The kit gate did not accept the result of ${name}.`);
165
+ continue;
166
+ }
167
+ await sleep(options.pollMs ?? 500);
168
+ }
169
+ throw new CliError("LOCAL_RUNTIME_FAILED", "The kit gate did not finish within its budget.");
170
+ }
171
+ finally {
172
+ if (container)
173
+ await runtime.stopGate(container);
174
+ if (started)
175
+ await runtime.down();
176
+ }
177
+ }
178
+ /**
179
+ * `.isomorph/checks/` is generated from what the app declares — the tables the
180
+ * gate's replayed database holds, the capabilities the gate derived from the
181
+ * source (the set its coverage gate will demand evidence for) —
182
+ * before the gate reads it, so a check for a feature the app dropped cannot
183
+ * survive a single `isomorph check`, and a feature it added arrives with its
184
+ * journey. This is not one of the gates: it writes files and reports what it
185
+ * did, and the gate's coverage check still decides. Files Isomorph did not
186
+ * write, and generated files the builder has edited, are never touched — and
187
+ * when the catalog is not one, nothing is touched at all. Resolves to the
188
+ * complete set the gate is to run.
189
+ */
190
+ async function regenerateRetainedChecks(root, catalog, capabilities, output) {
191
+ const schema = await readAppSchema(typeof catalog === "string" ? catalog : JSON.stringify(catalog ?? null), root);
192
+ if (schema)
193
+ for (const line of describeRetainedChecks(await syncRetainedChecks(root, schema, Array.isArray(capabilities) ? capabilities : undefined)))
194
+ output(line);
195
+ else
196
+ output(".isomorph/checks/ was left as it is: the app's tables could not be read from the gate's database, so there was nothing to generate from.");
197
+ const directory = kitPaths(root).checks;
198
+ const names = (await readdir(directory).catch(() => [])).filter(name => /\.(mjs|js|cjs)$/.test(name)).sort();
199
+ const files = {};
200
+ for (const name of names)
201
+ files[name] = await readFile(join(directory, name), "utf8");
202
+ return files;
203
+ }
204
+ /** Brings the session services up when `isomorph dev` is not running; resolves to whether they were started here (and so are taken down again after the gate, data kept). */
205
+ async function ensureSession(root, runtime, bundle, output) {
206
+ const lock = await readDevLock(root);
207
+ if (lock && await runningOrigin(root)) {
208
+ await runtime.attach(bundle, lock.ports);
209
+ return false;
210
+ }
211
+ const ports = lock?.ports ?? await allocatePorts();
212
+ await runtime.writeFiles(bundle, ports);
213
+ output("isomorph dev is not running; starting the local Isomorph services for the gate (stopped again afterwards).");
214
+ await runtime.pull(bundle);
215
+ await runtime.up(output);
216
+ await runtime.sessionEnv();
217
+ return true;
218
+ }
219
+ /**
220
+ * `productionise` pre-flight for a kit app: the same gate the pipeline runs,
221
+ * here, before any operation exists. A failed check is refused with the
222
+ * pipeline's own wording (kit.check-failed: <check>); a local runtime that
223
+ * cannot start is reported and skipped — the pipeline's gate
224
+ * still runs.
225
+ */
226
+ export async function preflightKitGate(root, bundle, output, run = runCommand, fetchImpl, runtime) {
227
+ const lock = await readKitLock(root).catch(() => undefined);
228
+ if (!lock)
229
+ return "skipped";
230
+ let report;
231
+ try {
232
+ report = await runKitGate(root, { run, bundle, output, ...(fetchImpl ? { fetch: fetchImpl } : {}), ...(runtime ? { runtime } : {}) });
233
+ }
234
+ catch (error) {
235
+ if (error instanceof CliError && ["LOCAL_RUNTIME_FAILED", "KIT_IMAGES_UNAVAILABLE", "KIT_RUNTIME_UNAVAILABLE"].includes(error.code)) {
236
+ output(`The kit gate was not run locally (${error.message}); the pipeline's gate will run in CodeBuild.`);
237
+ return "skipped";
238
+ }
239
+ throw error;
240
+ }
241
+ const failed = report.checks.filter(check => check.status === "fail");
242
+ if (failed.length)
243
+ throw new CliError("KIT_GATE_FAILED", `The kit gate refused this app (${failed.length} check(s) failed):\n${failed.map(check => ` - ${check.name}: ${check.detail ?? ""}`).join("\n")}\nFix the app and run \`isomorph check\`; the pipeline would refuse this deployment as kit.check-failed: ${failed[0].name}.`);
244
+ output(`The kit gate passed locally (${report.checks.length} checks; ${report.inventory.journeys.length} journey(s), ${report.inventory.migrations} migration(s)).`);
245
+ return "passed";
246
+ }
247
+ // ---- Real integrations ----------------------------------------------------------------
248
+ /** Explicit read operations only (READ_OPERATIONS) against READY development grants; sends never run. A dependent read (gmail.message.read) is exercised through its list: the newest message of the first thread, when there is one. */
249
+ async function testIntegrationReads(appId, client, declaration, output) {
250
+ const results = [];
251
+ const { grants } = await client.list(appId);
252
+ for (const grant of grants) {
253
+ if (grant.environment !== "development" || grant.readiness !== "ready")
254
+ continue;
255
+ const declared = declaration.connections[grant.connection];
256
+ for (const operation of grant.operations) {
257
+ if (!READ_OPERATIONS.includes(operation))
258
+ continue;
259
+ const declaredOperation = declared?.operations[operation];
260
+ if (!declaredOperation)
261
+ continue;
262
+ for (const resource of resourceNames(declaredOperation)) {
263
+ const input = operation === "warehouse.view.read" ? { columns: declaredOperation.resources[resource]?.columns ?? [], limit: 1 } : { limit: 1 };
264
+ const record = (name, status, detail) => { results.push({ connection: grant.connection, operation: name, resource, status, ...(detail ? { detail } : {}) }); output(`${status === "pass" ? "ok " : "FAIL"} integration ${grant.connection} ${name} ${resource}`); };
265
+ let listed;
266
+ try {
267
+ listed = await client.execute(appId, { connection: grant.connection, operation, resource, input });
268
+ record(operation, "pass");
269
+ }
270
+ catch (error) {
271
+ record(operation, "fail", error instanceof Error ? error.message : String(error));
272
+ continue;
273
+ }
274
+ // gmail.message.read needs a message id: read the newest message of the first listed thread when the grant covers it.
275
+ const dependent = DEPENDENT_READ_OPERATIONS.find(name => name === "gmail.message.read" && operation === "gmail.thread.list" && grant.operations.includes(name) && declared?.operations[name]);
276
+ const messageId = listed?.threads?.[0]?.messageId;
277
+ if (!dependent || !messageId)
278
+ continue;
279
+ try {
280
+ await client.execute(appId, { connection: grant.connection, operation: dependent, resource, input: { messageId } });
281
+ record(dependent, "pass");
282
+ }
283
+ catch (error) {
284
+ record(dependent, "fail", error instanceof Error ? error.message : String(error));
285
+ }
286
+ }
287
+ }
288
+ }
289
+ return results;
290
+ }
291
+ export async function readReport(root) {
292
+ try {
293
+ return JSON.parse(await readFile(kitPaths(root).report, "utf8"));
294
+ }
295
+ catch {
296
+ return undefined;
297
+ }
298
+ }
299
+ function lastLines(text, count = 5) { return text.trim().split("\n").slice(-count).join(" | ").slice(0, 600); }
300
+ /** A multi-line detail (one gate violation per line) keeps its lines, each under the check name. */
301
+ function indentLines(detail) { return detail.split("\n").map((line, index) => index ? ` ${line}` : line).join("\n"); }
302
+ /** Files the gate authored for the app (path → content, or null to remove), confined to `.isomorph/`. */
303
+ export async function writeGateFiles(root, files, output) {
304
+ for (const [path, content] of Object.entries(files)) {
305
+ if (!/^\.isomorph\/[A-Za-z0-9._-]+$/.test(path))
306
+ throw new CliError("LOCAL_RUNTIME_FAILED", `The kit gate named a file outside .isomorph/: ${path}.`);
307
+ const target = join(root, path);
308
+ if (content === null) {
309
+ await rm(target, { force: true });
310
+ output(`Removed ${path}: the app no longer needs it.`);
311
+ }
312
+ else {
313
+ await writeFile(target, content);
314
+ output(`Wrote ${path}.`);
315
+ }
316
+ }
317
+ }