@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,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
|
+
}
|