@isomorph.ai/cli 0.3.4 → 0.4.0-rc.1
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 +12 -14
- package/dist/packages/harbour-cli/src/agent-setup.js +25 -125
- package/dist/packages/harbour-cli/src/auth.js +1 -1
- package/dist/packages/harbour-cli/src/check.js +7 -34
- package/dist/packages/harbour-cli/src/cli.js +46 -100
- package/dist/packages/harbour-cli/src/config.js +51 -3
- package/dist/packages/harbour-cli/src/{productionise.js → deploy.js} +130 -183
- package/dist/packages/harbour-cli/src/dev.js +81 -2
- package/dist/packages/harbour-cli/src/guide.js +31 -45
- package/dist/packages/harbour-cli/src/integrations.js +47 -74
- package/dist/packages/harbour-cli/src/kit-bundle.js +0 -3
- package/dist/packages/harbour-cli/src/kit-bundle.manifest.js +9 -9
- package/dist/packages/harbour-cli/src/kit.js +43 -16
- package/dist/packages/harbour-cli/src/local-runtime.js +23 -1
- package/dist/packages/harbour-cli/src/operations.js +11 -90
- package/dist/packages/harbour-cli/src/output.js +24 -41
- package/dist/packages/harbour-cli/src/remote-mcp-client.js +4 -4
- package/dist/packages/harbour-cli/src/starter.js +21 -201
- package/dist/packages/harbour-cli/src/upload.js +1 -1
- package/dist/packages/harbour-cli/src/version.js +1 -1
- package/dist/src/analyzer.js +1 -1
- package/dist/src/secret-paths.js +1 -1
- package/package.json +2 -2
|
@@ -9,82 +9,70 @@
|
|
|
9
9
|
* no-duplication rule are enforced by tests/cli-guide-budget.test.ts.
|
|
10
10
|
*/
|
|
11
11
|
export const GUIDE_MODULES = {
|
|
12
|
-
core: `# Isomorph kit
|
|
12
|
+
core: `# Isomorph kit rules
|
|
13
13
|
|
|
14
14
|
## The SDK is the only door
|
|
15
15
|
|
|
16
|
-
\`@isomorph.ai/app-sdk
|
|
16
|
+
\`@isomorph.ai/app-sdk\` (exported by \`src/isomorph.client.ts\`) is the whole path: \`isomorph.identity.current()\`, \`isomorph.data.from(table)\`, \`isomorph.files.*\`, \`isomorph.realtime.*\`, \`isomorph.integrations.execute\` (integrations.md), \`isomorph.ai.chat\` (ai.md). No database, bucket or company URL from browser code; no backend, auth library, deployment config, provider key or other SDK; no secrets, tokens, \`.env\` values or fetched company content in source. Commit \`.isomorph/\`, never \`.isomorph/local/\`.
|
|
17
17
|
|
|
18
18
|
## Identity
|
|
19
19
|
|
|
20
|
-
Isomorph SSO signs everyone in: no login forms,
|
|
20
|
+
Isomorph SSO signs everyone in: no login forms, browser-trusted roles or public routes. \`isomorph.identity.current()\` answers \`{ id, email }\` only.
|
|
21
21
|
|
|
22
|
-
## Data
|
|
22
|
+
## Data
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
SQL files in \`migrations/\`; applied migrations are frozen after the first deploy (add the next file). Every table: RLS on, one policy; ownership decided in SQL (the gate names any privilege a table still lacks):
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
## Files
|
|
34
|
-
|
|
35
|
-
\`isomorph.files.upload(path, file)\`, \`list(prefix)\` and \`remove([path])\`.
|
|
26
|
+
\`\`\`sql
|
|
27
|
+
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
|
|
28
|
+
CREATE POLICY notes_owner ON notes
|
|
29
|
+
USING (owner_subject = current_setting('harbour.user_id', true))
|
|
30
|
+
WITH CHECK (owner_subject = current_setting('harbour.user_id', true));
|
|
31
|
+
\`\`\`
|
|
36
32
|
|
|
37
|
-
|
|
33
|
+
Shared-read, owner-write: the same, plus \`CREATE POLICY posts_read ON posts FOR SELECT USING (true);\`.
|
|
38
34
|
|
|
39
|
-
|
|
35
|
+
\`.isomorph/checks/\` is generated by \`isomorph check\`, which adds and deletes files as the app changes; never write or edit it by hand.
|
|
40
36
|
|
|
41
|
-
##
|
|
37
|
+
## Files and realtime
|
|
42
38
|
|
|
43
|
-
|
|
39
|
+
\`isomorph.files.upload(path, file)\`, \`list(prefix)\`, \`remove([path])\`.
|
|
44
40
|
|
|
45
|
-
|
|
41
|
+
\`isomorph.realtime.channel(name).on("postgres_changes", { event: "*" | "INSERT" | "UPDATE" | "DELETE", schema: "public", table }, payload => …).subscribe()\` (payload: \`eventType\`, \`new\`, \`old\`).
|
|
46
42
|
|
|
47
43
|
## Commands
|
|
48
44
|
|
|
49
|
-
|
|
50
|
-
- \`isomorph check --app-root . --json\` — types, build, then the pipeline's kit gate locally (declaration, database, write probe, journeys, coverage).
|
|
51
|
-
- \`isomorph productionise --app-root . …\` — private preview; \`isomorph promote\` makes it live.
|
|
45
|
+
\`isomorph dev --app-root . --detach --json\` (run), \`isomorph check --app-root . --json\` (check), \`isomorph deploy --app-root . --json\` (private preview; \`isomorph promote\` makes it live).
|
|
52
46
|
|
|
53
47
|
## Error codes
|
|
54
48
|
|
|
55
49
|
- \`CONFIG_REQUIRED\`: \`isomorph connect <work-email>\`; \`AUTH_REQUIRED\`: \`isomorph login\`.
|
|
56
|
-
- \`
|
|
57
|
-
- \`
|
|
58
|
-
- \`
|
|
59
|
-
- \`
|
|
50
|
+
- \`CHECKS_FAILED\`: fix what \`isomorph check\` reports, then rerun.
|
|
51
|
+
- \`DEPLOY_BLOCKED\`, \`INTEGRATIONS_NOT_READY\`, \`AI_NOT_READY\`: IT's step is in the refusal; the app works meanwhile.
|
|
52
|
+
- \`CLI_UPGRADE_REQUIRED\`: \`npm i -g @isomorph.ai/cli\`.
|
|
53
|
+
- \`APP_NOT_FOUND\`: clear \`appId\` and \`tenantId\` in \`.isomorph/kit.lock.json\` to relink.`,
|
|
60
54
|
integrations: `# Company systems (Slack, Gmail, warehouse)
|
|
61
55
|
|
|
62
56
|
## Declare
|
|
63
57
|
|
|
64
|
-
|
|
58
|
+
Company systems are reached through \`isomorph.integrations.execute(connection, { operation, resource, input })\`, declared in \`.isomorph/integrations.json\` by catalog identifier, with only the operations called. Closed set: \`slack.channel.history\` (user), \`slack.message.post\` (app or user), \`gmail.thread.list\`, \`gmail.message.read\`, \`gmail.message.send\` (user, \`inbox\`), \`warehouse.view.read\` (app). Resources are logical names, never IDs. Strict JSON.
|
|
65
59
|
|
|
66
60
|
\`\`\`json
|
|
67
61
|
"company-slack": { "kind": "saas", "operations": { "slack.message.post": { "identity": "app", "resources": ["team-updates"] } } },
|
|
68
62
|
"sales-warehouse": { "kind": "database", "operations": { "warehouse.view.read": { "identity": "app", "resources": { "weekly_sales": { "columns": ["week", "total"] } } } } }
|
|
69
63
|
\`\`\`
|
|
70
64
|
|
|
71
|
-
A connection that was READY reporting PENDING again: widening an approved declaration (another column on a declared resource, or another operation) re-files the request, and a database connection's development lane re-enters provisioning while access is re-minted, so the widened read fails until it republishes. Nothing is revoked: the record stays GRANTED with the resources it had, and the window closes on its own — re-check the status rather than treat it as a blocker.
|
|
72
|
-
|
|
73
65
|
## Catalog
|
|
74
66
|
|
|
75
|
-
Never guess a connection, channel, table, view, mailbox or
|
|
67
|
+
Never guess a connection, channel, table, view, mailbox or column: a guess is refused before IT sees it. Before a warehouse query or field mapping, run \`isomorph integrations catalog --app-root . --json\`, pick a listed table or view and copy its column names exactly; report a gap rather than invent a name. Columns \`["*"]\`: read \`["*"]\` and inspect the row keys.
|
|
76
68
|
|
|
77
69
|
## Request
|
|
78
70
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
## Status
|
|
82
|
-
|
|
83
|
-
READY: use it now. PENDING: say "IT has to approve this; the app works without it until then" and check later with \`isomorph integrations status --app-root . --json\`. Before a real integration test read that status: show pending approval and missing consent separately. Retained checks calling \`isomorph.integrations.execute\` get the gate's fixture, locally and in the pipeline (nothing sent or read); only \`isomorph check --integrations\` (reads) exercises real access.
|
|
71
|
+
\`isomorph integrations request <connection> --reason "<why>" --app-root . --json\` files the one request IT approves once for every environment (\`isomorph dev\` and \`isomorph deploy\` file it too); submit it yourself, in the same turn. \`isomorph integrations status --app-root . --json\`: READY, use it; PENDING, say "IT has to approve this; the app works meanwhile". \`RESOURCE_NOT_APPROVED\`: IT adds the channel, view or mailbox in the console (Controls & integrations); then run the same request again.
|
|
84
72
|
|
|
85
73
|
## Call shape
|
|
86
74
|
|
|
87
|
-
|
|
75
|
+
Always this exact shape, never a wrapper function: the kit gate reads the direct call.
|
|
88
76
|
|
|
89
77
|
\`\`\`ts
|
|
90
78
|
const report = await isomorph.integrations.execute("sales-warehouse", {
|
|
@@ -92,19 +80,17 @@ const report = await isomorph.integrations.execute("sales-warehouse", {
|
|
|
92
80
|
});
|
|
93
81
|
\`\`\`
|
|
94
82
|
|
|
95
|
-
Input bounds: \`slack.channel.history\` \`input.limit\` 1..15, \`gmail.thread.list\` \`input.limit\` 1..15, \`warehouse.view.read\` \`input.limit\` 1..1000 (\`VIEW_READ_MAX_LIMIT\`); larger is refused with \`INPUT_INVALID\`, so page instead.
|
|
96
|
-
|
|
97
|
-
## Slack modes
|
|
98
|
-
|
|
99
|
-
\`"identity": "app"\` posts as the company's Slack bot, Isomorph AI, under the connection's IT-approved \`presentation.displayName\` (optional \`iconEmoji\`); omit it to post as Isomorph AI itself. \`"identity": "user"\` posts as the person after their consent; an older consent answers \`USER_RECONNECT_REQUIRED\`: offer Connect again. Never pretend one is the other. The call names the approved mode it runs under (\`mode: "app"\` or \`"user"\`): optional with one approved mode, required with both (\`MODE_REQUIRED\`); an unapproved mode is \`MODE_NOT_GRANTED\`, never swapped. \`RESOURCE_NOT_APPROVED\` on a post: the bot is not in the channel — say "IT (or anyone in the channel) runs \`/invite @Isomorph AI\` in #<channel>". On a request: the resource is not on the connection yet — IT adds it in the console under Controls & integrations, then run the same request again.
|
|
100
|
-
|
|
101
83
|
## Consent
|
|
102
84
|
|
|
103
|
-
Only user-identity operations
|
|
85
|
+
Only user-identity operations need it, built in unasked: the control calls \`isomorph.integrations.connect(connection)\`, follows \`authorizationUrl\` on \`consent_required\`, and returns to a connected state. Never on page load; app-identity operations never call connect.
|
|
104
86
|
|
|
105
87
|
## Sends
|
|
106
88
|
|
|
107
|
-
A send (\`slack.message.post\`, \`gmail.message.send\`) runs only from an explicit Send control
|
|
89
|
+
A send (\`slack.message.post\`, \`gmail.message.send\`) runs only from an explicit Send control the person presses, with a fresh UUID \`idempotencyKey\` per press (reused only to retry); never from an effect, a timer or a check. Scheduled sends: jobs.md.
|
|
90
|
+
|
|
91
|
+
## Slack modes
|
|
92
|
+
|
|
93
|
+
\`"identity": "app"\` posts as the company's bot under IT's approved \`presentation.displayName\`; \`"identity": "user"\` posts as the person after consent (\`USER_RECONNECT_REQUIRED\`: offer Connect again). Name the \`mode\` when both are approved (\`MODE_REQUIRED\`); an unapproved mode is \`MODE_NOT_GRANTED\`, never swapped. \`RESOURCE_NOT_APPROVED\` on a post: the bot is not in the channel; anyone there runs \`/invite @Isomorph AI\`.`,
|
|
108
94
|
ai: `# Governed AI
|
|
109
95
|
|
|
110
96
|
AI goes through \`isomorph.ai\` only: one \`isomorph.ai.chat({ messages, maxTokens })\` call on the app's one client (\`src/isomorph.client.ts\`), never through a wrapper function (the gate reads only the direct call), behind a control the person presses — never on load, in an effect or a timer. Never add an OpenAI/Anthropic/Gemini key, SDK or URL: the governed AI gateway holds the key and IT sees every call. Send \`messages\` and \`maxTokens\` and nothing else; a refusal with \`unsupported_request_capability\` names a field the company's AI route does not accept — remove that field. A refusal with \`AI_NOT_ENABLED\` means IT has not enabled an AI provider yet: say so in one line and keep the app working without AI.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
2
|
import { linkIdempotencyKey, OPERATIONS, readDeclaration, readKitLock, requestResourceName, requestResources, writeKitLock, newKitLock } from "./kit.js";
|
|
3
3
|
import { CliError } from "./output.js";
|
|
4
|
+
import { CLI_VERSION } from "./version.js";
|
|
4
5
|
export class GovernanceClient {
|
|
5
6
|
apiUrl;
|
|
6
7
|
token;
|
|
@@ -29,9 +30,14 @@ export class GovernanceClient {
|
|
|
29
30
|
execute(appId, body) {
|
|
30
31
|
return this.call("POST", `/v1/development/apps/${encodeURIComponent(appId)}/integrations/execute`, body);
|
|
31
32
|
}
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Every blocker of a deploy at once: the grants the declaration needs on the
|
|
35
|
+
* environment's lane (governance files the requests itself), the company's AI
|
|
36
|
+
* setup when the app calls governed AI, and the CLI version. One call, one
|
|
37
|
+
* refusal shape, instead of the sequential integrations → AI → pipeline cycles.
|
|
38
|
+
*/
|
|
39
|
+
deployPreflight(appId, body) {
|
|
40
|
+
return this.call("POST", `/v1/development/apps/${encodeURIComponent(appId)}/deploy-preflight`, body);
|
|
35
41
|
}
|
|
36
42
|
/**
|
|
37
43
|
* What the building agent says cost it time in Isomorph itself. Never blocks a
|
|
@@ -46,9 +52,9 @@ export class GovernanceClient {
|
|
|
46
52
|
.catch((error) => { throw new CliError("GOVERNANCE_UNREACHABLE", `Isomorph governance at ${new URL(url).host} could not be reached: ${transportFailure(error)}.`); });
|
|
47
53
|
const parsed = await response.json().catch(() => ({}));
|
|
48
54
|
if (response.status === 401)
|
|
49
|
-
throw new CliError("AUTH_REQUIRED", "Please sign in to Isomorph with `isomorph login`.");
|
|
55
|
+
throw new CliError("AUTH_REQUIRED", "Please sign in to Isomorph with `isomorph login`.", undefined, undefined, undefined, { layer: "governance" });
|
|
50
56
|
if (!response.ok)
|
|
51
|
-
throw new CliError(parsed.error?.details?.code ?? parsed.error?.category ?? `HTTP_${response.status}`, parsed.error?.message ?? "Isomorph governance rejected the request.");
|
|
57
|
+
throw new CliError(parsed.error?.details?.code ?? parsed.error?.category ?? `HTTP_${response.status}`, parsed.error?.message ?? "Isomorph governance rejected the request.", undefined, undefined, undefined, { layer: "governance" });
|
|
52
58
|
return (parsed.data ?? parsed);
|
|
53
59
|
}
|
|
54
60
|
}
|
|
@@ -105,7 +111,7 @@ function settled(requests) {
|
|
|
105
111
|
}
|
|
106
112
|
/**
|
|
107
113
|
* Files the app-wide request for every connection the app declares, one per
|
|
108
|
-
* identity mode — what `isomorph dev` (signed in) and `
|
|
114
|
+
* identity mode — what `isomorph dev` (signed in) and `deploy` do before
|
|
109
115
|
* anything else, so IT's queue holds the ask from the first run and no command
|
|
110
116
|
* stands between a builder and an approval. Idempotent: governance answers
|
|
111
117
|
* READY for a scope IT has approved and PENDING with the existing request for
|
|
@@ -189,7 +195,7 @@ export function unregisteredResourceGuidance(error, declaration, connection, roo
|
|
|
189
195
|
const place = declared?.kind === "database"
|
|
190
196
|
? `Controls & integrations → Databases → ${connection} → Data resources`
|
|
191
197
|
: `Controls & integrations → API integrations → ${provider} → Configure → ${provider === "Gmail" ? "the mailbox" : "Channels"}`;
|
|
192
|
-
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
|
|
198
|
+
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 deploy\` file the request too).`, error.operationRef, undefined, undefined, { layer: "governance" });
|
|
193
199
|
}
|
|
194
200
|
export function groupGrants(grants) {
|
|
195
201
|
const groups = new Map();
|
|
@@ -222,17 +228,9 @@ const expired = (grant, nowMs) => grant.status === "GRANTED" && Boolean(grant.ex
|
|
|
222
228
|
* reading that as IT's sent a builder off to report a permanently broken development lane.
|
|
223
229
|
*/
|
|
224
230
|
const minting = (grant) => grant.readiness === "pending" && grant.provisioning === "PENDING";
|
|
225
|
-
/**
|
|
226
|
-
* Which readiness values can carry a deploy, as a total map so `npm run check` refuses a new
|
|
227
|
-
* readiness value until this question has been answered for it. A person's consent is asked
|
|
228
|
-
* inside the running app, so a consent lane deploys.
|
|
229
|
-
*/
|
|
230
|
-
const DEPLOYABLE = { ready: true, consent_required: true, reconnect_required: true, pending: false, failed: false };
|
|
231
|
-
/** A lane a deploy can run on: granted, unexpired, on a readiness that can carry one, and not still being minted — implied by `pending` today, and asked anyway so the gate fails closed if that map ever changes. */
|
|
232
|
-
const laneReady = (grant, nowMs) => grant.status === "GRANTED" && !expired(grant, nowMs) && DEPLOYABLE[grant.readiness] && !minting(grant);
|
|
233
231
|
/** Builder-safe failure guidance as one sentence: what failed, then what to do. */
|
|
234
232
|
export const failureSentence = (failure) => `${failure.message} ${failure.remediation}`;
|
|
235
|
-
const failureError = (failure) => new CliError(failure.code, failureSentence(failure));
|
|
233
|
+
const failureError = (failure) => new CliError(failure.code, failureSentence(failure), undefined, undefined, undefined, { layer: "governance" });
|
|
236
234
|
/**
|
|
237
235
|
* One sentence for the three lanes: `ready in development, preview, production`
|
|
238
236
|
* when they agree, otherwise the lanes that differ — `development ready
|
|
@@ -295,67 +293,42 @@ export function renderGrantGroup(group, nowMs = Date.now()) {
|
|
|
295
293
|
return lines;
|
|
296
294
|
}
|
|
297
295
|
/**
|
|
298
|
-
* `
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
// saying "Waiting for IT" over it is the same false blocker the minting predicate exists to end.
|
|
313
|
-
const waiting = [];
|
|
314
|
-
for (const item of settled(filed.requests)) {
|
|
315
|
-
const group = findGroup(filed.groups, item.connection, item.identityMode);
|
|
316
|
-
const preview = group.lanes.preview;
|
|
317
|
-
// Guidance also rides on a lane that is still being retried; only a failed readiness is a refusal in its own words.
|
|
318
|
-
if (preview?.failure && preview.readiness === "failed")
|
|
319
|
-
throw failureError(preview.failure);
|
|
320
|
-
if (preview && laneReady(preview, nowMs))
|
|
321
|
-
continue;
|
|
322
|
-
if (preview && minting(preview)) {
|
|
323
|
-
waiting.push({ awaitsDecision: false, line: `${item.connection} ${IDENTITY_WORDS[item.identityMode]} is being set up for the preview; nobody has to approve anything and it finishes on its own.` });
|
|
324
|
-
continue;
|
|
325
|
-
}
|
|
326
|
-
const requestId = item.outcome.requestId ?? openRequestId(group.lanes);
|
|
327
|
-
waiting.push({ awaitsDecision: true, line: requestId
|
|
328
|
-
? `Waiting for IT: ${item.connection} ${IDENTITY_WORDS[item.identityMode]} (request ${requestId}).`
|
|
329
|
-
: `${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.` });
|
|
330
|
-
}
|
|
331
|
-
if (!waiting.length)
|
|
332
|
-
return filed.appId;
|
|
333
|
-
output("Isomorph cannot deploy the preview yet:");
|
|
334
|
-
for (const { line } of waiting)
|
|
335
|
-
output(` ${line}`);
|
|
336
|
-
const next = waiting.some(item => item.awaitsDecision)
|
|
337
|
-
? "IT approves a request once, for every environment; rerun productionise when it is approved."
|
|
338
|
-
: "Run productionise again in a minute.";
|
|
339
|
-
throw new CliError("INTEGRATIONS_NOT_READY", `${waiting.map(item => item.line).join(" ")} ${next}`);
|
|
340
|
-
}
|
|
341
|
-
/**
|
|
342
|
-
* `productionise` / `promote` pre-check for an app that calls governed AI
|
|
343
|
-
* (the local gate's inventory lists `ai` among its capabilities): the
|
|
344
|
-
* pipeline will ask governance for a PLAN over the inventory the kit shipped,
|
|
345
|
-
* and that PLAN blocks on the company's AI setup — no provider, no route, no
|
|
346
|
-
* budget, observe mode before production. Ask the same question here, before
|
|
347
|
-
* any build, and refuse with the one IT step, the way a missing grant is
|
|
348
|
-
* refused. Observed without it (fourier app-f578bfc2, 2026-09-12): a green
|
|
349
|
-
* check, a live preview, and a button that answered AI_UNAVAILABLE.
|
|
296
|
+
* `deploy` / `promote` pre-flight, one call. Links the app when needed (the
|
|
297
|
+
* same key as `integrations request`) so both share one appId, then asks
|
|
298
|
+
* governance whether this app can deploy to the environment: it files the
|
|
299
|
+
* declared access requests itself and answers with every blocker at once —
|
|
300
|
+
* the lanes IT still has to approve, the company's AI setup when the app calls
|
|
301
|
+
* governed AI, and a CLI older than the platform's minimum. Prints each
|
|
302
|
+
* blocker's sentence and refuses once, before any operation exists.
|
|
303
|
+
*
|
|
304
|
+
* The code is `DEPLOY_BLOCKED` when the blockers are of more than one kind;
|
|
305
|
+
* when they are all of one kind it is the code readers already know for that
|
|
306
|
+
* kind (`INTEGRATIONS_NOT_READY`, `AI_NOT_READY`, `CLI_UPGRADE_REQUIRED`).
|
|
307
|
+
* Observed before this: a builder was refused `INTEGRATIONS_NOT_READY`, fixed
|
|
308
|
+
* that, then `AI_NOT_READY`, then the pipeline refused `kit_bundle_incompatible`,
|
|
309
|
+
* each a separate cycle.
|
|
350
310
|
*/
|
|
351
|
-
export async function
|
|
352
|
-
const
|
|
353
|
-
if (
|
|
354
|
-
|
|
355
|
-
|
|
311
|
+
export async function assertDeployReady(root, client, tenantId, bundle, environment, output, callsAi) {
|
|
312
|
+
const { declaration, errors } = await readDeclaration(root);
|
|
313
|
+
if (errors.length && !errors[0].startsWith(".isomorph/integrations.json is missing"))
|
|
314
|
+
throw new CliError("DECLARATION_INVALID", `.isomorph/integrations.json is invalid: ${errors[0]}`);
|
|
315
|
+
const appId = await ensureLinkedApp(root, client, tenantId, bundle);
|
|
316
|
+
const preflight = await client.deployPreflight(appId, { environment, callsAi, declaration, cliVersion: CLI_VERSION });
|
|
317
|
+
const blockers = Array.isArray(preflight.blockers) ? preflight.blockers.filter(blocker => blocker && typeof blocker.sentence === "string") : [];
|
|
318
|
+
if (preflight.ready && !blockers.length) {
|
|
319
|
+
output(`Isomorph confirmed the app can deploy to ${environment}.`);
|
|
320
|
+
return appId;
|
|
356
321
|
}
|
|
357
|
-
|
|
322
|
+
output(`Isomorph cannot deploy to ${environment} yet:`);
|
|
323
|
+
for (const blocker of blockers)
|
|
324
|
+
output(` ${blocker.sentence}`);
|
|
325
|
+
const kinds = new Set(blockers.map(blocker => blocker.kind));
|
|
326
|
+
const code = kinds.size === 1 ? BLOCKER_CODES[[...kinds][0]] ?? "DEPLOY_BLOCKED" : "DEPLOY_BLOCKED";
|
|
327
|
+
const fixes = [...new Set(blockers.map(blocker => blocker.fix).filter(Boolean))].join(" ");
|
|
328
|
+
throw new CliError(code, blockers.map(blocker => blocker.sentence).join(" ") || `Isomorph cannot deploy this app to ${environment} yet.`, undefined, fixes || undefined, undefined, { layer: "governance" });
|
|
358
329
|
}
|
|
330
|
+
/** The code readers already know for a refusal that is all of one kind. */
|
|
331
|
+
const BLOCKER_CODES = { integration: "INTEGRATIONS_NOT_READY", ai: "AI_NOT_READY", cli: "CLI_UPGRADE_REQUIRED" };
|
|
359
332
|
/**
|
|
360
333
|
* Splits the declared operations of one connection by identity mode — the
|
|
361
334
|
* declaration's own `identity` (the gate has checked it against the closed
|
|
@@ -25,7 +25,6 @@ export function isKitBundle(value) {
|
|
|
25
25
|
const record = value;
|
|
26
26
|
return Boolean(record && typeof record === "object" && record.schema === "isomorph.kit-bundle/1.0" && typeof record.kitVersion === "string"
|
|
27
27
|
&& record.sdk && typeof record.sdk.package === "string" && typeof record.sdk.tarballSha256 === "string"
|
|
28
|
-
&& record.images && typeof record.images.appGateway === "string" && typeof record.images.sessionFixture === "string"
|
|
29
28
|
&& record.brief && typeof record.brief.fingerprint === "string" && record.declarationSchema === "isomorph.app-integrations/2.0");
|
|
30
29
|
}
|
|
31
30
|
/** Lines naming every digest that differs between two manifests, for `init --upgrade`. */
|
|
@@ -33,8 +32,6 @@ export function bundleDiff(before, after) {
|
|
|
33
32
|
const fields = [
|
|
34
33
|
["kitVersion", before.kitVersion, after.kitVersion],
|
|
35
34
|
["sdk.tarballSha256", before.sdk.tarballSha256, after.sdk.tarballSha256],
|
|
36
|
-
["images.appGateway", before.images.appGateway, after.images.appGateway],
|
|
37
|
-
["images.sessionFixture", before.images.sessionFixture, after.images.sessionFixture],
|
|
38
35
|
["brief.fingerprint", before.brief.fingerprint, after.brief.fingerprint]
|
|
39
36
|
];
|
|
40
37
|
return fields.filter(([, a, b]) => a !== b).map(([name, a, b]) => `${name}: ${a ?? "(none)"} -> ${b ?? "(none)"}`);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const PUBLISHED_KIT_BUNDLE = {
|
|
2
2
|
"schema": "isomorph.kit-bundle/1.0",
|
|
3
|
-
"kitVersion": "0.
|
|
3
|
+
"kitVersion": "0.4.0",
|
|
4
4
|
"sdk": {
|
|
5
5
|
"package": "@isomorph.ai/app-sdk",
|
|
6
6
|
"version": "1.2.0",
|
|
@@ -8,21 +8,21 @@ export const PUBLISHED_KIT_BUNDLE = {
|
|
|
8
8
|
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:6b41494883215527a73e05a1e07d022552dfe3402915e23950e357e5e9381c49"
|
|
9
9
|
},
|
|
10
10
|
"images": {
|
|
11
|
-
"appGateway": "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:
|
|
12
|
-
"sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:
|
|
11
|
+
"appGateway": "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:9c7750897be5ed44af332069f463f0cfe9ca94694450b49ab469c019c97d7757",
|
|
12
|
+
"sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:52bfe8ca4e40fd38e662ab13c32cc842721f0d48b494561e99ac1607e49e8c74"
|
|
13
13
|
},
|
|
14
14
|
"nativeRuntime": {
|
|
15
15
|
"darwinArm64": {
|
|
16
|
-
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:
|
|
17
|
-
"sha256": "
|
|
16
|
+
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:7f6377c465b46cfb45402ae12c3f05063bae7c780c1c2be3aab5bd75614e809a",
|
|
17
|
+
"sha256": "7f6377c465b46cfb45402ae12c3f05063bae7c780c1c2be3aab5bd75614e809a"
|
|
18
18
|
},
|
|
19
19
|
"linuxX64": {
|
|
20
|
-
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:
|
|
21
|
-
"sha256": "
|
|
20
|
+
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:ea9fc7cab8b2ebf27fb39a59699cd3d76117d0f8fc00346f7d85cc1bab84778d",
|
|
21
|
+
"sha256": "ea9fc7cab8b2ebf27fb39a59699cd3d76117d0f8fc00346f7d85cc1bab84778d"
|
|
22
22
|
},
|
|
23
23
|
"windowsX64": {
|
|
24
|
-
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:
|
|
25
|
-
"sha256": "
|
|
24
|
+
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:0e1ef627daae405f1043b4b434a446e3cee937c370cb64ea49d84dc07c484d21",
|
|
25
|
+
"sha256": "0e1ef627daae405f1043b4b434a446e3cee937c370cb64ea49d84dc07c484d21"
|
|
26
26
|
}
|
|
27
27
|
},
|
|
28
28
|
"brief": {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { mkdir, readFile,
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { dirname, join, parse, resolve } from "node:path";
|
|
4
|
+
import { basename, dirname, join, parse, resolve } from "node:path";
|
|
5
5
|
import { scanWorkspace } from "../../../src/analyzer.js";
|
|
6
6
|
import { isKitBundle } from "./kit-bundle.js";
|
|
7
7
|
import { CliError } from "./output.js";
|
|
@@ -111,23 +111,50 @@ export function appRoot(rootArg) {
|
|
|
111
111
|
export function kitPaths(root) {
|
|
112
112
|
const kit = join(root, KIT_DIRECTORY);
|
|
113
113
|
const local = join(kit, "local");
|
|
114
|
-
return { kit, local, declaration: join(kit, "integrations.json"), lock: join(kit, "kit.lock.json"), checks: join(kit, "checks"), devLock: join(local, "dev.lock"), state: join(local, "state"), report: join(local, "check-report.json") };
|
|
114
|
+
return { kit, local, declaration: join(kit, "integrations.json"), app: join(kit, "app.json"), lock: join(kit, "kit.lock.json"), checks: join(kit, "checks"), devLock: join(local, "dev.lock"), state: join(local, "state"), report: join(local, "check-report.json") };
|
|
115
115
|
}
|
|
116
|
-
|
|
117
|
-
|
|
116
|
+
export function defaultAppProfile(root) {
|
|
117
|
+
return { schema: "isomorph.app/1.0", name: basename(resolve(root)), description: "", audience: [] };
|
|
118
|
+
}
|
|
119
|
+
/** `.isomorph/app.json` as `init` writes it. */
|
|
120
|
+
export function renderAppProfile(profile) {
|
|
121
|
+
return `${JSON.stringify(profile, null, 2)}\n`;
|
|
122
|
+
}
|
|
123
|
+
const EMAIL_SHAPE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
118
124
|
/**
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
* than discovered as a missing declaration, a missing lock and an app deployed
|
|
123
|
-
* without its kit.
|
|
125
|
+
* Reads the profile, refusing what `deploy` could not record: a missing file
|
|
126
|
+
* (an app set up before the file existed — `init` adds it), invalid JSON, a
|
|
127
|
+
* blank name, or an audience entry that is not an email address.
|
|
124
128
|
*/
|
|
125
|
-
export async function
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
+
export async function readAppProfile(root) {
|
|
130
|
+
let raw;
|
|
131
|
+
try {
|
|
132
|
+
raw = await readFile(kitPaths(root).app, "utf8");
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
throw new CliError("APP_PROFILE_REQUIRED", "This app has no .isomorph/app.json (its name, description and audience).", undefined, "Run `isomorph init --app-root .` to add it, then edit the three values and run this again.");
|
|
136
|
+
}
|
|
137
|
+
let parsed;
|
|
138
|
+
try {
|
|
139
|
+
parsed = JSON.parse(raw);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
throw new CliError("APP_PROFILE_INVALID", ".isomorph/app.json is not valid JSON.", undefined, "Fix the file, then run this again.");
|
|
143
|
+
}
|
|
144
|
+
const record = (parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {});
|
|
145
|
+
const problems = [];
|
|
146
|
+
if (record.schema !== "isomorph.app/1.0")
|
|
147
|
+
problems.push("schema must be \"isomorph.app/1.0\"");
|
|
148
|
+
if (typeof record.name !== "string" || !record.name.trim())
|
|
149
|
+
problems.push("name must be a non-empty string");
|
|
150
|
+
if (record.description !== undefined && typeof record.description !== "string")
|
|
151
|
+
problems.push("description must be a string");
|
|
152
|
+
if (!Array.isArray(record.audience) || record.audience.some(entry => typeof entry !== "string" || !EMAIL_SHAPE.test(entry.trim())))
|
|
153
|
+
problems.push("audience must be a list of email addresses (an empty list means only you)");
|
|
154
|
+
if (problems.length)
|
|
155
|
+
throw new CliError("APP_PROFILE_INVALID", `.isomorph/app.json is invalid: ${problems.join("; ")}.`, undefined, "Fix the file, then run this again.");
|
|
156
|
+
return { schema: "isomorph.app/1.0", name: record.name.trim(), description: (record.description ?? "").trim(), audience: record.audience.map(entry => entry.trim()) };
|
|
129
157
|
}
|
|
130
|
-
const isDirectory = (path) => stat(path).then(info => info.isDirectory(), () => false);
|
|
131
158
|
/** Stable per-project identity for the kit-managed local state. */
|
|
132
159
|
export function projectName(root, suffix = "") {
|
|
133
160
|
return `isomorph-${createHash("sha256").update(resolve(root)).digest("hex").slice(0, 12)}${suffix}`;
|
|
@@ -137,7 +164,7 @@ export function linkIdempotencyKey(tenantId, root) {
|
|
|
137
164
|
return createHash("sha256").update(`${tenantId}${resolve(root)}`).digest("hex");
|
|
138
165
|
}
|
|
139
166
|
/**
|
|
140
|
-
* sha256 over the tracked app files (same boundary as
|
|
167
|
+
* sha256 over the tracked app files (same boundary as deploy), so any
|
|
141
168
|
* edit changes it — and, per file, the hash of its contents, so a stale check
|
|
142
169
|
* can say WHICH path moved rather than only that something did.
|
|
143
170
|
*/
|
|
@@ -43,6 +43,11 @@ export const LOCAL = {
|
|
|
43
43
|
bucket: "harbour-local",
|
|
44
44
|
gatewayRole: "harbour_app_gateway"
|
|
45
45
|
};
|
|
46
|
+
/** What the gateway role may do with every table and sequence a migration creates, set once per session (see `migrate`). */
|
|
47
|
+
export const DEFAULT_PRIVILEGES = [
|
|
48
|
+
`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${LOCAL.gatewayRole};`,
|
|
49
|
+
`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO ${LOCAL.gatewayRole};`
|
|
50
|
+
];
|
|
46
51
|
/**
|
|
47
52
|
* App Gateway configuration in the shape cmd/appgateway/main.go decodes (unknown fields are rejected there).
|
|
48
53
|
* `localSession` is what makes the gateway the whole local runtime: issuer = its own published origin (the
|
|
@@ -279,7 +284,19 @@ export class LocalRuntime {
|
|
|
279
284
|
await this.down();
|
|
280
285
|
await rm(kitPaths(this.root).state, { recursive: true, force: true });
|
|
281
286
|
}
|
|
282
|
-
/**
|
|
287
|
+
/**
|
|
288
|
+
* Applies `migrations/*.sql` in name order as the session superuser, after
|
|
289
|
+
* ensuring the runtime role and its default privileges.
|
|
290
|
+
*
|
|
291
|
+
* The gateway logs in as `harbour_app_gateway`; before data plane 0.79.2.0
|
|
292
|
+
* every migration had to `GRANT` it every verb on every table and sequence
|
|
293
|
+
* by hand — two lines per table that said nothing about the app. The gate's
|
|
294
|
+
* replay and the hosted path now set default privileges once (#365), and this
|
|
295
|
+
* is the one remaining site: the same statements, in this session, so a table
|
|
296
|
+
* a migration creates is readable by the gateway with no GRANT of its own.
|
|
297
|
+
* Idempotent (default privileges are a set), no `FOR ROLE`: the replay and
|
|
298
|
+
* this step run as the same role, so the default applies to what they create.
|
|
299
|
+
*/
|
|
283
300
|
async migrate() {
|
|
284
301
|
const dir = join(this.root, "migrations");
|
|
285
302
|
const names = await migrationNames(this.root);
|
|
@@ -287,6 +304,11 @@ export class LocalRuntime {
|
|
|
287
304
|
const role = await psql(`DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${LOCAL.gatewayRole}') THEN CREATE ROLE ${LOCAL.gatewayRole} LOGIN PASSWORD '${LOCAL.dbPassword}'; END IF; END $$;`);
|
|
288
305
|
if (role.code !== 0)
|
|
289
306
|
throw new CliError("MIGRATION_FAILED", "Could not prepare the local database role.");
|
|
307
|
+
for (const statement of DEFAULT_PRIVILEGES) {
|
|
308
|
+
const granted = await psql(statement);
|
|
309
|
+
if (granted.code !== 0)
|
|
310
|
+
throw new CliError("MIGRATION_FAILED", `Could not set the local database's default privileges: ${granted.stderr.trim().split("\n").at(-1) ?? "psql error"}`);
|
|
311
|
+
}
|
|
290
312
|
for (const name of names) {
|
|
291
313
|
const result = await psql(await readFile(join(dir, name), "utf8"));
|
|
292
314
|
if (result.code !== 0)
|