@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.
- package/README.md +55 -0
- package/dist/packages/harbour-cli/src/agent-setup.js +235 -0
- package/dist/packages/harbour-cli/src/app-schema.js +142 -0
- package/dist/packages/harbour-cli/src/auth.js +197 -0
- package/dist/packages/harbour-cli/src/check.js +317 -0
- package/dist/packages/harbour-cli/src/cli.js +321 -0
- package/dist/packages/harbour-cli/src/config.js +97 -0
- package/dist/packages/harbour-cli/src/dev.js +125 -0
- package/dist/packages/harbour-cli/src/forwarder.js +168 -0
- package/dist/packages/harbour-cli/src/integrations.js +385 -0
- package/dist/packages/harbour-cli/src/jobs.js +53 -0
- package/dist/packages/harbour-cli/src/kit-bundle.js +41 -0
- package/dist/packages/harbour-cli/src/kit-bundle.manifest.js +32 -0
- package/dist/packages/harbour-cli/src/kit.js +149 -0
- package/dist/packages/harbour-cli/src/local-runtime.js +526 -0
- package/dist/packages/harbour-cli/src/operations.js +382 -0
- package/dist/packages/harbour-cli/src/output.js +273 -0
- package/dist/packages/harbour-cli/src/productionise.js +508 -0
- package/dist/packages/harbour-cli/src/remote-mcp-client.js +121 -0
- package/dist/packages/harbour-cli/src/retained-checks.js +435 -0
- package/dist/packages/harbour-cli/src/source-inventory.js +239 -0
- package/dist/packages/harbour-cli/src/starter.js +557 -0
- package/dist/packages/harbour-cli/src/upload.js +163 -0
- package/dist/packages/harbour-cli/src/version.js +1 -0
- package/dist/src/analyzer.js +685 -0
- package/dist/src/contracts.js +82 -0
- package/dist/src/digest.js +26 -0
- package/dist/src/secret-paths.js +38 -0
- package/dist/src/source-intake.js +125 -0
- 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
|
+
}
|