@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,168 @@
1
+ import { createServer, request as httpRequest } from "node:http";
2
+ import { connect } from "node:net";
3
+ const INTEGRATION_ROUTES = { "/_harbour/integrations/execute": ["POST"], "/_harbour/integrations/connect": ["POST", "DELETE"], "/_harbour/ai/chat": ["POST"], "/_harbour/ai/embed": ["POST"] };
4
+ /** The governance development route family a local path forwards to, relative to /v1/development/apps/{appId}/. */
5
+ function developmentTarget(path) { return path.startsWith("/_harbour/ai/") ? `ai/${path.slice("/_harbour/ai/".length)}` : `integrations/${path.slice("/_harbour/integrations/".length)}`; }
6
+ const HOP_HEADERS = new Set(["connection", "keep-alive", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", "host", "content-length"]);
7
+ const IDENTITY_HEADER = "x-harbour-identity-context";
8
+ /** The session identity for a gateway leg, unless the request already names one. */
9
+ function identityHeaders(request, token) {
10
+ return request.headers[IDENTITY_HEADER] ? {} : { [IDENTITY_HEADER]: token };
11
+ }
12
+ export function createForwarder(options) {
13
+ const origin = `http://127.0.0.1:${options.port}`;
14
+ const host = `127.0.0.1:${options.port}`;
15
+ const server = createServer((request, response) => {
16
+ const path = (request.url ?? "/").split("?")[0];
17
+ if (!sameOrigin(request, host, origin) || path.startsWith("/_harbour/integrations/") || path.startsWith("/_harbour/ai/")) {
18
+ void answerLocally(request, response, path, host, origin, options);
19
+ return;
20
+ }
21
+ if (path.startsWith("/_harbour/")) {
22
+ pipe(request, response, options.gatewayPort, identityHeaders(request, options.identityToken));
23
+ return;
24
+ }
25
+ pipe(request, response, options.vitePort);
26
+ });
27
+ server.on("upgrade", (request, socket, head) => {
28
+ if (!sameOrigin(request, host, origin)) {
29
+ socket.destroy();
30
+ return;
31
+ }
32
+ tunnel(request, socket, head, (request.url ?? "/").startsWith("/_harbour/") ? options.gatewayPort : options.vitePort, (request.url ?? "/").startsWith("/_harbour/") ? identityHeaders(request, options.identityToken) : {});
33
+ });
34
+ return server;
35
+ }
36
+ export function sameOrigin(request, host, origin) {
37
+ if (request.headers.host !== host)
38
+ return false;
39
+ const requestOrigin = request.headers.origin;
40
+ return requestOrigin === undefined || requestOrigin === origin;
41
+ }
42
+ /** Locally answered routes read the whole request first so a keep-alive socket is left clean. */
43
+ async function answerLocally(request, response, path, host, origin, options) {
44
+ const body = await readBody(request, 16 * 1024);
45
+ if (!sameOrigin(request, host, origin)) {
46
+ reject(response, 403, "FORBIDDEN", "Requests must come from the local app origin.");
47
+ return;
48
+ }
49
+ if (path === "/_harbour/integrations/oauth/complete") {
50
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
51
+ response.end(COMPLETE_PAGE);
52
+ return;
53
+ }
54
+ const methods = INTEGRATION_ROUTES[path];
55
+ if (!methods) {
56
+ reject(response, 404, "NOT_FOUND", path.startsWith("/_harbour/ai/") ? "Unknown AI route." : "Unknown integration route.");
57
+ return;
58
+ }
59
+ const method = request.method ?? "GET";
60
+ if (!methods.includes(method)) {
61
+ reject(response, 405, "VALIDATION_FAILED", "Method not allowed.");
62
+ return;
63
+ }
64
+ const ai = path.startsWith("/_harbour/ai/");
65
+ const token = await options.accessToken().catch(() => undefined);
66
+ if (!token) {
67
+ options.onAuthRequired?.();
68
+ reject(response, 401, "AUTH_REQUIRED", ai ? "Sign in to Isomorph with `isomorph login` to use governed AI locally." : "Sign in to Isomorph with `isomorph login` to use company integrations locally.", "CLI_LOGIN_REQUIRED");
69
+ return;
70
+ }
71
+ // Governed AI needs the app's identity for its trace; a signed-in builder's app is linked on first use.
72
+ const appId = options.appId() ?? (ai && options.linkApp ? await options.linkApp().catch(() => undefined) : undefined);
73
+ if (!appId) {
74
+ reject(response, 409, "CONFLICT", ai ? "This app could not be linked with Isomorph yet; check `isomorph login` and try again." : "This app is not linked yet: restart `isomorph dev` now that you are signed in, or run `isomorph integrations request <connection> --app-root .` once.", "APP_NOT_LINKED");
75
+ return;
76
+ }
77
+ if (body === undefined) {
78
+ reject(response, 413, "VALIDATION_FAILED", "Request too large.", "REQUEST_TOO_LARGE");
79
+ return;
80
+ }
81
+ const target = `${options.apiUrl.replace(/\/$/, "")}/v1/development/apps/${encodeURIComponent(appId)}/${developmentTarget(path)}`;
82
+ const payload = path.endsWith("/connect") && method === "POST" ? withReturnUrl(body, `${origin}/_harbour/integrations/oauth/complete`) : body.length ? Buffer.from(body).toString("utf8") : undefined;
83
+ let upstream;
84
+ try {
85
+ upstream = await (options.fetch ?? fetch)(target, { method, headers: { authorization: `Bearer ${token}`, "x-harbour-tenant": options.tenantId, "content-type": "application/json", accept: "application/json" }, ...(payload === undefined ? {} : { body: payload }), redirect: "error", signal: AbortSignal.timeout(ai ? 45_000 : 12_000) });
86
+ }
87
+ catch {
88
+ reject(response, 503, "UNAVAILABLE", "Isomorph governance could not be reached.", "PROVIDER_UNAVAILABLE");
89
+ return;
90
+ }
91
+ if (upstream.status === 401)
92
+ options.onAuthRequired?.();
93
+ const headers = { "content-type": upstream.headers.get("content-type") ?? "application/json", "cache-control": "no-store" };
94
+ const retryAfter = upstream.headers.get("retry-after");
95
+ if (retryAfter)
96
+ headers["retry-after"] = retryAfter;
97
+ response.writeHead(upstream.status, headers);
98
+ response.end(new Uint8Array(await upstream.arrayBuffer()));
99
+ }
100
+ function pipe(request, response, port, extraHeaders = {}) {
101
+ const headers = {};
102
+ for (const [name, value] of Object.entries(request.headers))
103
+ if (value !== undefined && !HOP_HEADERS.has(name))
104
+ headers[name] = value;
105
+ if (request.headers["content-length"])
106
+ headers["content-length"] = request.headers["content-length"];
107
+ Object.assign(headers, extraHeaders);
108
+ const upstream = httpRequest({ host: "127.0.0.1", port, method: request.method, path: request.url, headers: { ...headers, host: `127.0.0.1:${port}` } }, upstreamResponse => {
109
+ response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers);
110
+ upstreamResponse.pipe(response);
111
+ });
112
+ upstream.on("error", () => reject(response, 502, "UNAVAILABLE", "The local service is not responding."));
113
+ request.pipe(upstream);
114
+ }
115
+ /**
116
+ * An upgrade keeps the browser's own `Host` — this origin's — instead of the
117
+ * internal target port. A WebSocket server's same-origin defence compares
118
+ * `Origin` against `Host`: the App Gateway's `websocket.Accept` answers
119
+ * `403 request Origin "…" is not authorized for Host "…"` when they differ, so
120
+ * rewriting `Host` to the gateway's published port broke `/_harbour/realtime`
121
+ * (live updates fell back to polling) while every plain `/_harbour/*` call, which
122
+ * has no such check, kept working. Both targets listen on loopback and route by
123
+ * path, never by `Host`, and `sameOrigin` above has already rejected anything
124
+ * whose `Host`/`Origin` is not exactly this origin — forwarding the real pair is
125
+ * what lets the gateway's check do its job rather than defeating it.
126
+ */
127
+ function tunnel(request, socket, head, port, extraHeaders) {
128
+ const upstream = connect(port, "127.0.0.1", () => {
129
+ const lines = [`${request.method} ${request.url} HTTP/1.1`];
130
+ for (const [name, value] of Object.entries({ ...request.headers, ...extraHeaders }))
131
+ if (value !== undefined)
132
+ for (const item of Array.isArray(value) ? value : [value])
133
+ lines.push(`${name}: ${item}`);
134
+ upstream.write(`${lines.join("\r\n")}\r\n\r\n`);
135
+ if (head.length)
136
+ upstream.write(head);
137
+ upstream.pipe(socket);
138
+ socket.pipe(upstream);
139
+ });
140
+ upstream.on("error", () => socket.destroy());
141
+ socket.on("error", () => upstream.destroy());
142
+ }
143
+ /** Consent flows return the browser to this origin's completion page; governance allowlists exactly that loopback URL. */
144
+ function withReturnUrl(body, returnUrl) {
145
+ let parsed;
146
+ try {
147
+ parsed = JSON.parse(Buffer.from(body).toString("utf8") || "{}");
148
+ }
149
+ catch {
150
+ parsed = {};
151
+ }
152
+ return JSON.stringify({ ...(parsed && typeof parsed === "object" ? parsed : {}), returnUrl });
153
+ }
154
+ function readBody(request, limit) {
155
+ return new Promise(resolve => {
156
+ const chunks = [];
157
+ let size = 0;
158
+ request.on("data", (chunk) => { size += chunk.length; if (size <= limit)
159
+ chunks.push(chunk); });
160
+ request.on("end", () => resolve(size > limit ? undefined : new Uint8Array(Buffer.concat(chunks))));
161
+ request.on("error", () => resolve(undefined));
162
+ });
163
+ }
164
+ function reject(response, status, category, message, code = category) {
165
+ response.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
166
+ response.end(JSON.stringify({ error: { category, message, details: { code } }, requestId: "local" }));
167
+ }
168
+ const COMPLETE_PAGE = "<!doctype html><meta charset=\"utf-8\"><title>Isomorph</title><body style=\"font-family:system-ui;margin:3rem\"><h1>Connected</h1><p>Your account is linked for this app. You can close this tab and return to the app.</p></body>";
@@ -0,0 +1,385 @@
1
+ import { basename } from "node:path";
2
+ import { linkIdempotencyKey, OPERATIONS, readDeclaration, readKitLock, requestResourceName, requestResources, writeKitLock, newKitLock } from "./kit.js";
3
+ import { CliError } from "./output.js";
4
+ export class GovernanceClient {
5
+ apiUrl;
6
+ token;
7
+ tenantId;
8
+ fetchImpl;
9
+ constructor(apiUrl, token, tenantId, fetchImpl = fetch) {
10
+ this.apiUrl = apiUrl;
11
+ this.token = token;
12
+ this.tenantId = tenantId;
13
+ this.fetchImpl = fetchImpl;
14
+ }
15
+ link(input) {
16
+ return this.call("POST", "/v1/development/apps/link", { tenantId: this.tenantId, ...input });
17
+ }
18
+ /** One app-wide request per (connection, identity): no environment and no expiry — IT approves once for every lane and sets any expiry itself. */
19
+ request(appId, input) {
20
+ return this.call("POST", `/v1/development/apps/${encodeURIComponent(appId)}/integration-requests`, { tenantId: this.tenantId, ...input });
21
+ }
22
+ list(appId) {
23
+ return this.call("GET", `/v1/development/apps/${encodeURIComponent(appId)}/integrations?tenantId=${encodeURIComponent(this.tenantId)}`);
24
+ }
25
+ /** Tenant-scoped: needs no linked app, so it answers on a builder's first turn. */
26
+ catalog() {
27
+ return this.call("GET", `/v1/development/integrations/catalog?tenantId=${encodeURIComponent(this.tenantId)}`);
28
+ }
29
+ execute(appId, body) {
30
+ return this.call("POST", `/v1/development/apps/${encodeURIComponent(appId)}/integrations/execute`, body);
31
+ }
32
+ /** Would this app's deployment PLAN pass for the environment? Read-only; governance answers from the company's AI setup. */
33
+ aiReadiness(appId, environment) {
34
+ return this.call("POST", `/v1/development/apps/${encodeURIComponent(appId)}/ai/readiness`, { environment });
35
+ }
36
+ async call(method, path, body) {
37
+ const url = `${this.apiUrl.replace(/\/$/, "")}${path}`;
38
+ const response = await this.fetchImpl(url, { method, headers: { authorization: `Bearer ${this.token}`, "x-harbour-tenant": this.tenantId, accept: "application/json", ...(body ? { "content-type": "application/json" } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}), redirect: "error" })
39
+ .catch((error) => { throw new CliError("GOVERNANCE_UNREACHABLE", `Isomorph governance at ${new URL(url).host} could not be reached: ${transportFailure(error)}.`); });
40
+ const parsed = await response.json().catch(() => ({}));
41
+ if (response.status === 401)
42
+ throw new CliError("AUTH_REQUIRED", "Please sign in to Isomorph with `isomorph login`.");
43
+ if (!response.ok)
44
+ throw new CliError(parsed.error?.details?.code ?? parsed.error?.category ?? `HTTP_${response.status}`, parsed.error?.message ?? "Isomorph governance rejected the request.");
45
+ return (parsed.data ?? parsed);
46
+ }
47
+ }
48
+ /**
49
+ * What actually failed under a `fetch` rejection. undici reports every transport
50
+ * failure as `TypeError: fetch failed` and keeps the reason in `cause`: `getaddrinfo
51
+ * ENOTFOUND <host>`, `connect ECONNREFUSED <ip>:<port>`, a ConnectTimeoutError
52
+ * (UND_ERR_CONNECT_TIMEOUT, after 10 s), `unexpected redirect` under `redirect:
53
+ * "error"`, or an AggregateError with an empty message over one error per address
54
+ * tried. Observed 2026-09-12 (CLI 0.1.29): `isomorph check --integrations` printed
55
+ * `fetch failed` and nothing else, so what was wrong — and that nothing about the
56
+ * app was — could not be told from the output.
57
+ */
58
+ function transportFailure(error) {
59
+ const cause = (error instanceof Error ? error.cause ?? error : error);
60
+ const words = (failure) => { const { message } = (failure ?? {}); return typeof message === "string" ? message.trim() : ""; };
61
+ const text = words(cause) || (Array.isArray(cause?.errors) ? cause.errors.map(words).filter(Boolean).join("; ") : "") || String(cause);
62
+ return typeof cause?.code === "string" && !text.includes(cause.code) ? `${text} (${cause.code})` : text;
63
+ }
64
+ /** Links the app once (stable idempotency key) and records the appId in kit.lock. */
65
+ export async function ensureLinkedApp(root, client, tenantId, bundle) {
66
+ const lock = (await readKitLock(root)) ?? newKitLock(bundle, tenantId);
67
+ if (lock.appId)
68
+ return lock.appId;
69
+ const linked = await client.link({ displayName: basename(root), idempotencyKey: linkIdempotencyKey(tenantId, root) });
70
+ if (!linked.appId)
71
+ throw new CliError("LINK_FAILED", "Isomorph did not return an app identity for this app.");
72
+ await writeKitLock(root, { ...lock, appId: linked.appId, tenantId });
73
+ return linked.appId;
74
+ }
75
+ export const ENVIRONMENTS = ["development", "preview", "production"];
76
+ export const IDENTITY_WORDS = { app: "as the company bot", user: "as the signed-in person" };
77
+ /** A refusal about the leg rather than the ask — signed out, unreachable, an answer with no code — is nobody's ask and stops the run. */
78
+ const legFailure = (code) => code === "AUTH_REQUIRED" || code === "GOVERNANCE_UNREACHABLE" || code.startsWith("HTTP_");
79
+ /** One request per identity mode of the connection. A refusal of an ask is returned beside the others, not thrown, so a caller can report every connection at once. */
80
+ async function submitRequests(client, appId, declaration, root, connection, reason, only) {
81
+ return Promise.all(requestScope(declaration, connection, only).map(async (part) => {
82
+ const ask = { connection, ...part };
83
+ try {
84
+ return { ...ask, outcome: await client.request(appId, { connection, identityMode: part.identityMode, operations: part.operations, resources: part.resources, ...(part.presentation ? { presentation: part.presentation } : {}), ...(reason?.trim() ? { reason: reason.trim() } : {}) }) };
85
+ }
86
+ catch (error) {
87
+ const refusal = unregisteredResourceGuidance(error, declaration, connection, root);
88
+ if (!(refusal instanceof CliError) || legFailure(refusal.code))
89
+ throw refusal;
90
+ return { ...ask, error: refusal };
91
+ }
92
+ }));
93
+ }
94
+ /** The filed asks with every refusal thrown, for the commands that cannot go on past one. */
95
+ function settled(requests) {
96
+ return requests.map(item => { if (item.error)
97
+ throw item.error; return item; });
98
+ }
99
+ /**
100
+ * Files the app-wide request for every connection the app declares, one per
101
+ * identity mode — what `isomorph dev` (signed in) and `productionise` do before
102
+ * anything else, so IT's queue holds the ask from the first run and no command
103
+ * stands between a builder and an approval. Idempotent: governance answers
104
+ * READY for a scope IT has approved and PENDING with the existing request for
105
+ * one it is still deciding, so a repeat opens nothing. Links the app first
106
+ * (the same key as `integrations request`) and reads the lanes back, grouped.
107
+ * An app with no declaration or no connections is left alone: nothing to ask,
108
+ * so governance is not called.
109
+ */
110
+ export async function fileDeclaredRequests(root, client, tenantId, bundle) {
111
+ const { declaration, errors } = await readDeclaration(root);
112
+ if (errors.length) {
113
+ if (errors[0].startsWith(".isomorph/integrations.json is missing"))
114
+ return undefined;
115
+ throw new CliError("DECLARATION_INVALID", `.isomorph/integrations.json is invalid: ${errors[0]}`);
116
+ }
117
+ const connections = Object.keys(declaration.connections);
118
+ if (!connections.length)
119
+ return undefined;
120
+ const appId = await ensureLinkedApp(root, client, tenantId, bundle);
121
+ const requests = (await Promise.all(connections.map(connection => submitRequests(client, appId, declaration, root, connection)))).flat();
122
+ return { appId, requests, groups: groupGrants((await client.list(appId)).grants) };
123
+ }
124
+ /**
125
+ * `isomorph integrations request`: the request for one connection (or the named
126
+ * subset of its operations) now, one per identity mode. Polls the grant list
127
+ * for up to 30 s; PENDING is reported as pending, never as ready.
128
+ */
129
+ export async function requestIntegrations(root, client, tenantId, bundle, options) {
130
+ const { declaration, errors } = await readDeclaration(root);
131
+ if (errors.length)
132
+ throw new CliError("DECLARATION_INVALID", `.isomorph/integrations.json is invalid: ${errors[0]}`);
133
+ const appId = await ensureLinkedApp(root, client, tenantId, bundle);
134
+ const submitted = settled(await submitRequests(client, appId, declaration, root, options.connection, options.reason, options.operations)).map(item => ({ ...item, state: item.outcome.state }));
135
+ const sleep = options.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
136
+ const pollMs = options.pollMs ?? 3_000;
137
+ let grants = [];
138
+ for (let waited = 0;; waited += pollMs) {
139
+ grants = (await client.list(appId)).grants;
140
+ // A grant that already serves an older scope reads "ready" while this request (an expansion, a renewal,
141
+ // a changed per-app name) is still attached to it as pending: READY only once the grant no longer waits on it.
142
+ for (const item of submitted) {
143
+ const grant = grants.find(candidate => candidate.grantId === item.outcome.grantId);
144
+ // Guidance also rides on a grant that is still being retried ("attempt 1 of 3 …"); only a failed readiness ends the wait.
145
+ if (grant?.failure && grant.readiness === "failed")
146
+ throw failureError(grant.failure);
147
+ if (grant?.readiness === "ready" && grant.pendingRequestId !== item.outcome.requestId)
148
+ item.state = "READY";
149
+ }
150
+ if (!submitted.some(item => item.state === "PENDING") || waited >= 30_000)
151
+ break;
152
+ await sleep(pollMs);
153
+ }
154
+ const groups = groupGrants(grants);
155
+ return { appId, connection: options.connection, requests: submitted.map(item => ({
156
+ identityMode: item.identityMode, operations: item.operations, resources: item.resources, ...(item.presentation ? { presentation: item.presentation } : {}),
157
+ ...(item.outcome.requestId ? { requestId: item.outcome.requestId } : {}), grantId: item.outcome.grantId, state: item.state,
158
+ readiness: item.state === "PENDING" ? "pending" : grants.find(grant => grant.grantId === item.outcome.grantId)?.readiness ?? (item.state === "READY" ? "ready" : "denied"),
159
+ // The three lanes in one sentence, as `integrations status` prints them (the request itself names the anchor lane's grant).
160
+ summary: laneSummary(findGroup(groups, options.connection, item.identityMode), item.outcome.requestId)
161
+ })) };
162
+ }
163
+ const UNREGISTERED_RESOURCE = /^resource "([^"]+)" is not registered for \S+ on "([^"]+)"$/;
164
+ /**
165
+ * Governance refuses a request that names a channel, table, view or mailbox IT has not
166
+ * registered on the connection (404 RESOURCE_NOT_APPROVED) before the Review
167
+ * queue ever sees it, so a builder cannot self-serve it. Observed: an agent
168
+ * read the raw refusal and told the person to email an administrator, who gave
169
+ * up. The code is kept for `--json`; the sentence names the resource, the one
170
+ * console place where IT adds it, and the command to run again afterwards.
171
+ */
172
+ export function unregisteredResourceGuidance(error, declaration, connection, root) {
173
+ if (!(error instanceof CliError) || error.code !== "RESOURCE_NOT_APPROVED")
174
+ return error;
175
+ const match = UNREGISTERED_RESOURCE.exec(error.message);
176
+ if (!match)
177
+ return error;
178
+ const [, resource] = match;
179
+ const declared = declaration.connections[connection];
180
+ const operations = Object.keys(declared?.operations ?? {});
181
+ const provider = operations.some(name => name.startsWith("gmail.")) ? "Gmail" : operations.some(name => name.startsWith("slack.")) ? "Slack" : connection;
182
+ const place = declared?.kind === "database"
183
+ ? `Controls & integrations → Databases → ${connection} → Data resources`
184
+ : `Controls & integrations → API integrations → ${provider} → Configure → ${provider === "Gmail" ? "the mailbox" : "Channels"}`;
185
+ return new CliError(error.code, `IT has to add ${resource} to the ${connection} connection first (in the Isomorph console: ${place}); the app works without it until then, and once it is added run \`isomorph integrations request ${connection} --app-root ${root}\` again (\`isomorph dev\` and \`isomorph productionise\` file the request too).`, error.operationRef);
186
+ }
187
+ export function groupGrants(grants) {
188
+ const groups = new Map();
189
+ for (const grant of grants) {
190
+ const key = `${grant.identityMode}:${grant.connection}`;
191
+ const group = groups.get(key) ?? { connection: grant.connection, identityMode: grant.identityMode, lanes: {} };
192
+ if (ENVIRONMENTS.includes(grant.environment))
193
+ group.lanes[grant.environment] = grant;
194
+ groups.set(key, group);
195
+ }
196
+ return [...groups.values()].sort((a, b) => a.connection.localeCompare(b.connection) || a.identityMode.localeCompare(b.identityMode));
197
+ }
198
+ /** The group of one ask, or an empty one (no lane on file yet). */
199
+ export function findGroup(groups, connection, identityMode) {
200
+ return groups.find(group => group.connection === connection && group.identityMode === identityMode) ?? { connection, identityMode, lanes: {} };
201
+ }
202
+ /** The open request of a group: it sits on the production lane (the anchor), so whichever lane record carries one names it. */
203
+ export function openRequestId(lanes) {
204
+ return ENVIRONMENTS.map(lane => lanes[lane]?.pendingRequestId).find(Boolean);
205
+ }
206
+ /** A granted lane past IT's expiry (the server reads it as failed; the sentence says why). */
207
+ const expired = (grant, nowMs) => grant.status === "GRANTED" && Boolean(grant.expiresAt && Date.parse(grant.expiresAt) <= nowMs);
208
+ /** A lane a deploy can run on: granted, unexpired, not still provisioning or failed (a person's consent is asked in the app, at runtime). */
209
+ const laneReady = (grant, nowMs) => grant.status === "GRANTED" && !expired(grant, nowMs) && grant.readiness !== "pending" && grant.readiness !== "failed";
210
+ /** Builder-safe failure guidance as one sentence: what failed, then what to do. */
211
+ export const failureSentence = (failure) => `${failure.message} ${failure.remediation}`;
212
+ const failureError = (failure) => new CliError(failure.code, failureSentence(failure));
213
+ /**
214
+ * One sentence for the three lanes: `ready in development, preview, production`
215
+ * when they agree, otherwise the lanes that differ — `development ready
216
+ * (preapproved until <ts>); preview, production waiting for IT (request <id>)`.
217
+ * A lane with no record yet is waiting on the group's open request (the list
218
+ * read right after filing may not carry the anchor yet, so the filing's own
219
+ * request id is the fallback). Development ready while another lane waits is
220
+ * the connection's development preapproval: the server writes that lane at
221
+ * filing time and keeps the request open for IT.
222
+ */
223
+ export function laneSummary(group, requestHint, nowMs = Date.now()) {
224
+ const { lanes } = group;
225
+ const open = openRequestId(lanes) ?? requestHint;
226
+ const waitingOn = (lane) => { const grant = lanes[lane]; return grant ? (grant.readiness === "pending" ? grant.pendingRequestId ?? requestHint : undefined) : open; };
227
+ const word = (lane) => {
228
+ const grant = lanes[lane];
229
+ const request = waitingOn(lane);
230
+ if (!grant)
231
+ return { base: request ? `waiting for IT (request ${request})` : "not requested", suffix: "" };
232
+ if (expired(grant, nowMs))
233
+ return { base: `expired ${grant.expiresAt}`, suffix: "" };
234
+ const until = grant.expiresAt ? ` until ${grant.expiresAt}` : "";
235
+ switch (grant.readiness) {
236
+ case "ready": return { base: "ready", suffix: lane === "development" && ENVIRONMENTS.some(other => other !== lane && waitingOn(other)) ? ` (preapproved${until})` : until };
237
+ case "consent_required": return { base: "approved", suffix: " (connect your account in the app)" };
238
+ case "reconnect_required": return { base: "approved", suffix: " (reconnect your account in the app)" };
239
+ case "pending": return { base: request ? `waiting for IT (request ${request})` : "being set up", suffix: "" };
240
+ case "failed": return { base: grant.status === "DENIED" ? "denied" : grant.status === "REVOKED" ? "revoked" : "failed", suffix: "" };
241
+ }
242
+ };
243
+ const segments = [];
244
+ for (const lane of ENVIRONMENTS) {
245
+ const { base, suffix } = word(lane);
246
+ const segment = segments.find(candidate => candidate.base === base && candidate.suffix === suffix);
247
+ if (segment)
248
+ segment.lanes.push(lane);
249
+ else
250
+ segments.push({ base, suffix, lanes: [lane] });
251
+ }
252
+ return segments.length === 1 ? `${segments[0].base} in ${ENVIRONMENTS.join(", ")}${segments[0].suffix}` : segments.map(segment => `${segment.lanes.join(", ")} ${segment.base}${segment.suffix}`).join("; ");
253
+ }
254
+ /** The group's line — `company-slack [as the company bot] ready in development, preview, production` — plus the failure guidance of any lane, once per distinct failure. */
255
+ export function renderGrantGroup(group, nowMs = Date.now()) {
256
+ const lines = [`${group.connection} [${IDENTITY_WORDS[group.identityMode]}] ${laneSummary(group, undefined, nowMs)}`];
257
+ const seen = new Set();
258
+ for (const lane of ENVIRONMENTS) {
259
+ const failure = group.lanes[lane]?.failure;
260
+ if (failure && !seen.has(failure.message)) {
261
+ seen.add(failure.message);
262
+ lines.push(` ${failureSentence(failure)}`);
263
+ }
264
+ }
265
+ return lines;
266
+ }
267
+ /**
268
+ * `productionise` pre-check. Files the app-wide request for every declared
269
+ * connection (idempotent: READY comes straight back once IT has approved), then
270
+ * reads the preview lane — the one this deploy runs in — and refuses before any
271
+ * operation exists, naming exactly what is still waiting. Links the app when
272
+ * needed (the same key as `integrations request`) so both share one appId.
273
+ * Returns the kit appId, or undefined for a non-kit app.
274
+ */
275
+ export async function assertPreviewIntegrationsReady(root, client, tenantId, bundle, output) {
276
+ const filed = await fileDeclaredRequests(root, client, tenantId, bundle);
277
+ if (!filed)
278
+ return (await readKitLock(root))?.appId || undefined;
279
+ const nowMs = Date.now();
280
+ const waiting = [];
281
+ for (const item of settled(filed.requests)) {
282
+ const group = findGroup(filed.groups, item.connection, item.identityMode);
283
+ const preview = group.lanes.preview;
284
+ // Guidance also rides on a lane that is still being retried; only a failed readiness is a refusal in its own words.
285
+ if (preview?.failure && preview.readiness === "failed")
286
+ throw failureError(preview.failure);
287
+ if (preview && laneReady(preview, nowMs))
288
+ continue;
289
+ const requestId = item.outcome.requestId ?? openRequestId(group.lanes);
290
+ waiting.push(requestId
291
+ ? `Waiting for IT: ${item.connection} ${IDENTITY_WORDS[item.identityMode]} (request ${requestId}).`
292
+ : `${item.connection} ${IDENTITY_WORDS[item.identityMode]} is not ready for the preview (${laneSummary(group, undefined, nowMs)}); ask IT to review this app's access in the Isomorph console.`);
293
+ }
294
+ if (!waiting.length)
295
+ return filed.appId;
296
+ output("Isomorph cannot deploy the preview yet:");
297
+ for (const line of waiting)
298
+ output(` ${line}`);
299
+ throw new CliError("INTEGRATIONS_NOT_READY", `${waiting.join(" ")} IT approves a request once, for every environment; rerun productionise when it is approved.`);
300
+ }
301
+ /**
302
+ * `productionise` / `promote` pre-check for an app that calls governed AI
303
+ * (the local gate's inventory lists `ai` among its capabilities): the
304
+ * pipeline will ask governance for a PLAN over the inventory the kit shipped,
305
+ * and that PLAN blocks on the company's AI setup — no provider, no route, no
306
+ * budget, observe mode before production. Ask the same question here, before
307
+ * any build, and refuse with the one IT step, the way a missing grant is
308
+ * refused. Observed without it (fourier app-f578bfc2, 2026-09-12): a green
309
+ * check, a live preview, and a button that answered AI_UNAVAILABLE.
310
+ */
311
+ export async function assertAiReady(appId, environment, client, output) {
312
+ const readiness = await client.aiReadiness(appId, environment);
313
+ if (readiness.ready) {
314
+ output(`Isomorph confirmed the company's AI setup for ${environment}.`);
315
+ return;
316
+ }
317
+ throw new CliError("AI_NOT_READY", readiness.plainEnglish);
318
+ }
319
+ /**
320
+ * Splits the declared operations of one connection by identity mode — the
321
+ * declaration's own `identity` (the gate has checked it against the closed
322
+ * set; an operation with one mode is read as that mode). The app-mode part
323
+ * that carries `slack.message.post` also carries the connection's declared
324
+ * presentation, so IT sees "posts as" on the request.
325
+ */
326
+ export function requestScope(declaration, connection, only) {
327
+ const declared = declaration.connections[connection];
328
+ if (!declared)
329
+ throw new CliError("CONNECTION_NOT_DECLARED", `${connection} is not declared in .isomorph/integrations.json.`);
330
+ const names = (only?.length ? only : Object.keys(declared.operations));
331
+ const unknown = names.filter(name => !declared.operations[name]);
332
+ if (unknown.length)
333
+ throw new CliError("OPERATION_NOT_DECLARED", `${unknown.join(", ")} not declared for ${connection}.`);
334
+ const byMode = new Map();
335
+ for (const name of names) {
336
+ const allowed = OPERATIONS[name]?.identities ?? [];
337
+ const declaredMode = declared.operations[name].identity;
338
+ const mode = allowed.includes(declaredMode) ? declaredMode : allowed[0];
339
+ const entry = byMode.get(mode) ?? { operations: [], resources: new Map() };
340
+ entry.operations.push(name);
341
+ for (const resource of requestResources(declared.operations[name]))
342
+ entry.resources.set(requestResourceName(resource), resource);
343
+ byMode.set(mode, entry);
344
+ }
345
+ return [...byMode.entries()].map(([identityMode, entry]) => ({
346
+ identityMode, operations: entry.operations, resources: [...entry.resources.keys()].sort().map(key => entry.resources.get(key)),
347
+ ...(identityMode === "app" && entry.operations.includes("slack.message.post") && declared.presentation ? { presentation: declared.presentation } : {})
348
+ }));
349
+ }
350
+ /** Where connection, channel, table, view and mailbox names come from, so an agent never guesses one (2026-09-12: six of nineteen turns spent on refused guesses). */
351
+ export async function integrationsCatalog(client) {
352
+ const listed = await client.catalog();
353
+ return { connections: listed.connections.map(entry => ({
354
+ connection: entry.connection, kind: entry.kind, ...(entry.provider ? { provider: entry.provider } : {}), ...(entry.connector ? { connector: entry.connector } : {}), ...(entry.installation ? { installation: entry.installation } : {}),
355
+ displayName: entry.displayName, status: entry.status,
356
+ operations: entry.operations.map(item => ({ operation: item.operation, identities: [...item.identities] })),
357
+ resources: Object.fromEntries(ENVIRONMENTS.map(environment => [environment, (entry.resources[environment] ?? []).map(resource => ({ name: resource.name, ...(resource.columns ? { columns: [...resource.columns] } : {}), ...(resource.description ? { description: resource.description } : {}) }))]))
358
+ })) };
359
+ }
360
+ const PROVIDER_WORDS = { slack: "Slack", gmail: "Gmail" };
361
+ const INSTALLATION_WORDS = { installed: "bot installed", not_installed: "bot not installed yet: IT installs it in the Isomorph console", reconnect_required: "bot needs reconnecting: IT does that in the Isomorph console" };
362
+ /** One block per connection: the identifier to declare, what it is, the allowed operations with their identity, and the approved names per environment. */
363
+ export function renderIntegrationsCatalog(result) {
364
+ if (!result.connections.length)
365
+ return "No connections are registered for your company yet; IT adds them in the Isomorph console under Controls & integrations. Do not declare one until it is listed here.";
366
+ const lines = [];
367
+ for (const entry of result.connections) {
368
+ const what = entry.kind === "saas" ? `${PROVIDER_WORDS[entry.provider ?? ""] ?? entry.provider} connection` : `${entry.connector ?? "database"} warehouse`;
369
+ const state = [entry.status, ...(entry.installation && INSTALLATION_WORDS[entry.installation] ? [INSTALLATION_WORDS[entry.installation]] : [])].join(", ");
370
+ lines.push(`\`${entry.connection}\` — ${what} "${entry.displayName}" (${state})`);
371
+ lines.push(` operations: ${entry.operations.map(item => `${item.operation} (${item.identities.join(" or ")})`).join(", ") || "none allowed"}`);
372
+ for (const environment of ENVIRONMENTS) {
373
+ const names = entry.resources[environment].map(resource => `${resource.name}${resource.description ? ` — ${resource.description}` : ""}${resource.columns?.length ? ` (${resource.columns.join(", ")})` : ""}`);
374
+ lines.push(` ${environment}: ${names.join(", ") || "nothing approved yet"}`);
375
+ }
376
+ }
377
+ return lines.join("\n");
378
+ }
379
+ export async function integrationsStatus(root, client) {
380
+ const lock = await readKitLock(root);
381
+ if (!lock?.appId)
382
+ return { linked: false, grants: [], requests: [] };
383
+ const listed = await client.list(lock.appId);
384
+ return { appId: lock.appId, linked: true, grants: listed.grants.map(grant => ({ connection: grant.connection, environment: grant.environment, identityMode: grant.identityMode, status: grant.status, operations: grant.operations, resources: grant.resources, expiresAt: grant.expiresAt, readiness: grant.readiness, pendingRequestId: grant.pendingRequestId, failure: grant.failure })), requests: listed.requests };
385
+ }
@@ -0,0 +1,53 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { CliError } from "./output.js";
5
+ import { parseSessionEnv, readDevLock, runCommand } from "./local-runtime.js";
6
+ import { kitPaths } from "./kit.js";
7
+ const jobName = /^[a-z0-9][a-z0-9_-]{0,63}$/;
8
+ const scheduleDeclaration = /^export\s+const\s+schedule\s*=\s*["']([^"']+)["']\s*;?\s*$/m;
9
+ export async function discoverJobs(root) {
10
+ const directory = join(root, "jobs");
11
+ const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
12
+ const jobs = [];
13
+ for (const entry of entries) {
14
+ if (!entry.isFile() || !entry.name.endsWith(".ts"))
15
+ continue;
16
+ const name = entry.name.slice(0, -3);
17
+ if (!jobName.test(name))
18
+ throw new CliError("JOB_INVALID", `jobs/${entry.name}: the file name must be a lowercase logical name.`);
19
+ const path = join(directory, entry.name);
20
+ const source = await readFile(path, "utf8");
21
+ const schedule = source.match(scheduleDeclaration)?.[1];
22
+ if (!schedule)
23
+ throw new CliError("JOB_INVALID", `jobs/${entry.name}: export one literal schedule, for example export const schedule = \"0 9 * * 1-5\".`);
24
+ if (!/^export\s+default\s+/m.test(source))
25
+ throw new CliError("JOB_INVALID", `jobs/${entry.name}: export one default job handler.`);
26
+ jobs.push({ name, schedule, path });
27
+ }
28
+ return jobs.sort((a, b) => a.name.localeCompare(b.name));
29
+ }
30
+ export async function runJob(root, name, scheduledAt, run = runCommand, real = false) {
31
+ if (!jobName.test(name))
32
+ throw new CliError("JOB_INVALID", "A valid job name is required.");
33
+ const job = (await discoverJobs(root)).find(candidate => candidate.name === name);
34
+ if (!job)
35
+ throw new CliError("JOB_NOT_FOUND", `No job named ${name} exists under jobs/.`);
36
+ const timestamp = scheduledAt ?? new Date().toISOString();
37
+ if (Number.isNaN(Date.parse(timestamp)))
38
+ throw new CliError("JOB_INVALID", "--scheduled-at must be an ISO 8601 timestamp.");
39
+ const session = await readFile(join(kitPaths(root).state, "session.env"), "utf8").catch(() => "");
40
+ const lock = await readDevLock(root);
41
+ if (!session || !lock)
42
+ throw new CliError("DEV_NOT_RUNNING", "Start `isomorph dev` before running a job so it uses the same local data and identity boundary.");
43
+ const env = parseSessionEnv(session);
44
+ const runner = `const module = await import(${JSON.stringify(pathToFileURL(job.path).href)}); if (typeof module.default !== "function") throw new Error("job has no default handler"); await module.default({scheduledAt: process.env.ISOMORPH_SCHEDULED_AT});`;
45
+ // Ordinary runs stay fixture-only. An explicit real run uses the same
46
+ // loopback origin as the browser, where the existing forwarder applies the
47
+ // builder's approved company/AI access.
48
+ const gatewayUrl = real ? lock.origin : `http://127.0.0.1:${lock.ports.gateway}`;
49
+ const result = await run(process.execPath, ["--experimental-strip-types", "--input-type=module", "--eval", runner], { cwd: root, env: { ...env, ISOMORPH_GATEWAY_URL: gatewayUrl, ISOMORPH_SCHEDULED_AT: timestamp } });
50
+ if (result.code !== 0)
51
+ throw new CliError("JOB_FAILED", `Job ${name} failed: ${result.stderr.trim().split("\n").at(-1) ?? "process exited non-zero"}`);
52
+ return { name, schedule: job.schedule, scheduledAt: timestamp, mode: real ? "real" : "local" };
53
+ }