@isomorph.ai/cli 0.3.1-rc.1 → 0.3.1-rc.2
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/dist/packages/harbour-cli/src/app-schema.js +4 -0
- package/dist/packages/harbour-cli/src/check.js +17 -1
- package/dist/packages/harbour-cli/src/guide.js +25 -21
- package/dist/packages/harbour-cli/src/integrations.js +43 -10
- package/dist/packages/harbour-cli/src/kit-bundle.manifest.js +9 -9
- package/dist/packages/harbour-cli/src/retained-checks.js +11 -3
- package/dist/packages/harbour-cli/src/version.js +1 -1
- package/package.json +2 -2
|
@@ -144,3 +144,7 @@ export function numericMinimum(checks, column) {
|
|
|
144
144
|
}
|
|
145
145
|
return minimum;
|
|
146
146
|
}
|
|
147
|
+
/** Whether `col <= n` / `col < n` caps the column: the mirror of `numericMinimum`, asked so no invented value is written into a range the app's own CHECK does not admit. */
|
|
148
|
+
export function hasNumericMaximum(checks, column) {
|
|
149
|
+
return checks.some(check => new RegExp(String.raw `\b${column}\b[^<>=]*(?:<=|<)\s*-?\d+(?:\.\d+)?`, "i").test(check));
|
|
150
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
3
4
|
import { readAppSchema } from "./app-schema.js";
|
|
4
5
|
import { describeRetainedChecks, syncRetainedChecks } from "./retained-checks.js";
|
|
5
6
|
import { CliError } from "./output.js";
|
|
@@ -158,7 +159,7 @@ export async function runKitGate(root, options) {
|
|
|
158
159
|
output("Running the retained journeys against the gate's App Gateway.");
|
|
159
160
|
}
|
|
160
161
|
const name = state.current;
|
|
161
|
-
const result = await run("node", [join(kitPaths(root).checks, name)], { cwd: root, quiet: true, env: { ...state.environment, ISOMORPH_SDK_MODULE:
|
|
162
|
+
const result = await run("node", [join(kitPaths(root).checks, name)], { cwd: root, quiet: true, env: { ...state.environment, ISOMORPH_SDK_MODULE: sdkModuleSpecifier(root, bundle), NODE_NO_WARNINGS: "1" } });
|
|
162
163
|
const posted = await post(`/_gate/journeys/${encodeURIComponent(name)}`, { code: result.code, output: `${result.stdout}${result.stderr}`.slice(-4000) });
|
|
163
164
|
if (posted?.status !== 200)
|
|
164
165
|
throw new CliError("LOCAL_RUNTIME_FAILED", `The kit gate did not accept the result of ${name}.`);
|
|
@@ -175,6 +176,21 @@ export async function runKitGate(root, options) {
|
|
|
175
176
|
await runtime.down();
|
|
176
177
|
}
|
|
177
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* The SDK a generated journey imports, as a `file://` URL rather than a path.
|
|
181
|
+
*
|
|
182
|
+
* The journey does `await import(process.env.ISOMORPH_SDK_MODULE)`, and on
|
|
183
|
+
* Windows a bare absolute path is not a specifier `import()` accepts: Node
|
|
184
|
+
* reads the drive letter in `D:\\app\\node_modules\\…` as a URL scheme and
|
|
185
|
+
* refuses it with ERR_UNSUPPORTED_ESM_URL_SCHEME. Every journey on Windows
|
|
186
|
+
* therefore failed before it reached its first assertion. It went unseen
|
|
187
|
+
* because `isomorph check` reported those failures and still exited 0 (a
|
|
188
|
+
* dependency's `beforeExit` hook overwrote `process.exitCode`, fixed in
|
|
189
|
+
* 0.3.1-rc.1), so the Windows lane read the run as a pass.
|
|
190
|
+
*/
|
|
191
|
+
export function sdkModuleSpecifier(root, bundle) {
|
|
192
|
+
return pathToFileURL(join(root, "node_modules", ...bundle.sdk.package.split("/"), "dist", "index.js")).href;
|
|
193
|
+
}
|
|
178
194
|
/**
|
|
179
195
|
* `.isomorph/checks/` is generated from what the app declares — the tables the
|
|
180
196
|
* gate's replayed database holds, the capabilities the gate derived from the
|
|
@@ -13,21 +13,23 @@ export const GUIDE_MODULES = {
|
|
|
13
13
|
|
|
14
14
|
## The SDK is the only door
|
|
15
15
|
|
|
16
|
-
\`@isomorph.ai/app-sdk\`, on the client
|
|
16
|
+
\`@isomorph.ai/app-sdk\`, on the client \`src/isomorph.client.ts\` exports, 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). Never open a database, bucket or company URL from browser code; never add a backend, auth library, deployment config, provider key or SDK. No secrets, tokens, \`.env\` values or fetched company content in source. Commit \`.isomorph/integrations.json\` and \`.isomorph/kit.lock.json\`, never \`.isomorph/local/\`.
|
|
17
17
|
|
|
18
18
|
## Identity
|
|
19
19
|
|
|
20
|
-
Isomorph SSO signs everyone in: no login forms, no roles or ids
|
|
20
|
+
Isomorph SSO signs everyone in: no login forms, no browser-trusted roles or ids, no public routes. \`isomorph.identity.current()\` answers \`{ id, email }\` only; derive a display name from the address.
|
|
21
21
|
|
|
22
22
|
## Data (\`core.rls-owner-scoped\`)
|
|
23
23
|
|
|
24
|
-
Schema changes are SQL files in \`migrations
|
|
24
|
+
Schema changes are SQL files in \`migrations/\`. Every table: \`ENABLE ROW LEVEL SECURITY\`, a policy, and \`GRANT\` of every verb it allows to \`harbour_app_gateway\` and no other role. Row ownership is decided in SQL through \`current_setting('harbour.user_id', true)\` (set by the gateway per request, with \`harbour.user_email\`), never in the browser. Two patterns:
|
|
25
25
|
|
|
26
|
-
- **Private table** — every policy scoped to
|
|
27
|
-
- **Shared-read, owner-write** — \`USING (true)\` for SELECT; INSERT, UPDATE and DELETE scoped to
|
|
26
|
+
- **Private table** — every policy scoped to that setting; the gate asserts another signed-in person cannot read, update or delete the row.
|
|
27
|
+
- **Shared-read, owner-write** — \`USING (true)\` for SELECT; INSERT, UPDATE and DELETE scoped to that setting.
|
|
28
28
|
|
|
29
29
|
The gate treats a table as private when any of its policies mentions \`harbour.user_id\` (or \`harbour.user_email\`), so a shared-read table must keep that setting out of its SELECT policy, or its cross-user read check fails.
|
|
30
30
|
|
|
31
|
+
Iterating on the schema needs no restart: every \`isomorph check\` replays \`migrations/*.sql\` into a fresh, empty scratch database. \`isomorph dev --reset\` only refreshes the running app's own data, so its UI shows the new schema; stopping, resetting and waiting before each check costs minutes and changes nothing.
|
|
32
|
+
|
|
31
33
|
## Files
|
|
32
34
|
|
|
33
35
|
\`isomorph.files.upload(path, file)\`, \`list(prefix)\` and \`remove([path])\`.
|
|
@@ -38,45 +40,47 @@ The gate treats a table as private when any of its policies mentions \`harbour.u
|
|
|
38
40
|
|
|
39
41
|
## Retained checks (\`core.journey-fk\`)
|
|
40
42
|
|
|
41
|
-
\`.isomorph/checks/\` holds one journey per capability the app
|
|
43
|
+
\`.isomorph/checks/\` holds one journey per capability the app uses, plus a cross-user denial per private table. \`isomorph check\` generates them from \`migrations/\` and \`src/\` and deletes what the app dropped, so run it in the edit that changes the app; never write or remove them by hand. A generated check starts with \`// isomorph:generated\` and a digest of its body; edit one and it is yours: kept, never updated or deleted, so delete it in the same edit that drops the feature. Two-way (\`flow.check-failed\`): the \`flow\` gate refuses a capability no check exercises, and a check exercising what the code dropped — the most common first-deploy refusal. What cannot be generated (\`actions\`, \`realtime\`, a table with no migration) is named for you to write. No journey is generated for a table whose row needs a parent (a foreign key): write that table's journey yourself.
|
|
44
|
+
|
|
45
|
+
A journey failing on a CHECK constraint: generation marks the row by the first free text or number column with no foreign key, no allowed-values CHECK and no ceiling on a number, so that column's other CHECKs must admit an arbitrary unique value. Only the generated file names the column.
|
|
42
46
|
|
|
43
47
|
## Commands
|
|
44
48
|
|
|
45
|
-
- \`isomorph dev --app-root .\` — local Postgres, storage, gateway and Vite
|
|
46
|
-
- \`isomorph check --app-root . --json\` — types, build, then the pipeline's
|
|
47
|
-
- \`isomorph productionise --app-root . …\` —
|
|
49
|
+
- \`isomorph dev --app-root .\` — local Postgres, storage, gateway and Vite on one origin; company calls use the \`isomorph login\` account.
|
|
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.
|
|
48
52
|
|
|
49
53
|
## Error codes
|
|
50
54
|
|
|
51
|
-
- \`CONFIG_REQUIRED\`:
|
|
52
|
-
- \`
|
|
53
|
-
- \`
|
|
54
|
-
- \`CHECKS_FAILED\` / \`CHECKS_STALE\`: fix what \`isomorph check\` reports and run it again on this exact code.
|
|
55
|
+
- \`CONFIG_REQUIRED\`: \`isomorph connect <work-email>\`; \`AUTH_REQUIRED\`: \`isomorph login\`.
|
|
56
|
+
- \`NOT_A_MEMBER\` / \`TENANT_AMBIGUOUS\`: use the command it printed or IT's link.
|
|
57
|
+
- \`CHECKS_FAILED\` / \`CHECKS_STALE\`: fix what \`isomorph check\` reports, then rerun on this code.
|
|
55
58
|
- \`APP_NOT_FOUND\`: the app is linked to another company — connect back to it with its start link, or clear \`appId\` and \`tenantId\` in \`.isomorph/kit.lock.json\` to relink.
|
|
56
|
-
- \`INTEGRATIONS_NOT_READY\`: IT has not approved
|
|
57
|
-
- \`AI_NOT_READY\`: IT has not enabled the company's AI setup yet (ai.md).`,
|
|
59
|
+
- \`INTEGRATIONS_NOT_READY\` / \`AI_NOT_READY\`: IT has not approved the connection (integrations.md) or enabled AI (ai.md).`,
|
|
58
60
|
integrations: `# Company systems (Slack, Gmail, warehouse)
|
|
59
61
|
|
|
60
62
|
## Declare
|
|
61
63
|
|
|
62
|
-
Reach company systems only through \`isomorph.integrations.execute(connection, { operation, resource, input })\`, declared in \`.isomorph/integrations.json\`. Operations, a closed set: \`slack.channel.history\` (user identity), \`slack.message.post\` (app or user identity, declared per app), \`gmail.thread.list\`, \`gmail.message.read\` and \`gmail.message.send\` (user identity, resource \`inbox\`, plain-text send), \`warehouse.view.read\` (app identity). Resources are logical names, never IDs, URLs or tokens. The file starts as \`"connections": {}\`: declare
|
|
64
|
+
Reach company systems only through \`isomorph.integrations.execute(connection, { operation, resource, input })\`, declared in \`.isomorph/integrations.json\`. Operations, a closed set: \`slack.channel.history\` (user identity), \`slack.message.post\` (app or user identity, declared per app), \`gmail.thread.list\`, \`gmail.message.read\` and \`gmail.message.send\` (user identity, resource \`inbox\`, plain-text send), \`warehouse.view.read\` (app identity). Resources are logical names, never IDs, URLs or tokens. The file starts as \`"connections": {}\`: declare one only when the app calls it, by the catalog's identifier, with only the operations it calls — each declared connection blocks the deploy until IT grants it; an undeclared one cannot be requested (\`CONNECTION_NOT_DECLARED\`). Strict JSON, no comments.
|
|
63
65
|
|
|
64
66
|
\`\`\`json
|
|
65
67
|
"company-slack": { "kind": "saas", "operations": { "slack.message.post": { "identity": "app", "resources": ["team-updates"] } } },
|
|
66
68
|
"sales-warehouse": { "kind": "database", "operations": { "warehouse.view.read": { "identity": "app", "resources": { "weekly_sales": { "columns": ["week", "total"] } } } } }
|
|
67
69
|
\`\`\`
|
|
68
70
|
|
|
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
|
+
|
|
69
73
|
## Catalog
|
|
70
74
|
|
|
71
|
-
Never guess a connection, channel, table, view, mailbox or warehouse column — a guessed name is refused before IT's queue ever sees it. Before writing a warehouse query or mapping response fields, run \`isomorph integrations catalog --app-root . --json\`, select a listed table or view, and copy its listed column names exactly; if
|
|
75
|
+
Never guess a connection, channel, table, view, mailbox or warehouse column — a guessed name is refused before IT's queue ever sees it. Before writing a warehouse query or mapping response fields, run \`isomorph integrations catalog --app-root . --json\`, select a listed table or view, and copy its listed column names exactly; if either is absent, report that catalog gap instead of substituting a plausible name. A resource whose catalog columns are \`["*"]\` is declared and read with \`["*"]\`: inspect the returned row keys; never invent a schema.
|
|
72
76
|
|
|
73
77
|
## Request
|
|
74
78
|
|
|
75
|
-
One request per connection; IT approves it once for every environment. \`isomorph dev\` and \`isomorph productionise\` file it for you; \`isomorph integrations request <connection> --reason "<
|
|
79
|
+
One request per connection; IT approves it once for every environment. \`isomorph dev\` and \`isomorph productionise\` file it for you; \`isomorph integrations request <connection> --reason "<why the app needs it>" --app-root . --json\` files it now — submit the request yourself in the same turn, and never tell the person to ask IT before you have submitted the request.
|
|
76
80
|
|
|
77
81
|
## Status
|
|
78
82
|
|
|
79
|
-
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
|
|
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.
|
|
80
84
|
|
|
81
85
|
## Call shape
|
|
82
86
|
|
|
@@ -92,11 +96,11 @@ Input bounds: \`slack.channel.history\` \`input.limit\` 1..15, \`gmail.thread.li
|
|
|
92
96
|
|
|
93
97
|
## Slack modes
|
|
94
98
|
|
|
95
|
-
\`"identity": "app"\` posts as the company's Slack bot, Isomorph AI, under the connection's \`presentation.displayName\` (optional \`iconEmoji\`)
|
|
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.
|
|
96
100
|
|
|
97
101
|
## Consent
|
|
98
102
|
|
|
99
|
-
Only user-identity operations (\`slack.channel.history\`, \`gmail.thread.list\`, \`gmail.message.read\`, \`gmail.message.send\`, and \`slack.message.post\` declared \`"identity": "user"\`) need it; build the flow in unasked, during the original implementation: the operation's control calls \`isomorph.integrations.connect(connection)\`, follows \`authorizationUrl\` when the result is \`consent_required\`, returns to a clear connected state and lets the person continue. Consent starts
|
|
103
|
+
Only user-identity operations (\`slack.channel.history\`, \`gmail.thread.list\`, \`gmail.message.read\`, \`gmail.message.send\`, and \`slack.message.post\` declared \`"identity": "user"\`) need it; build the flow in unasked, during the original implementation: the operation's control calls \`isomorph.integrations.connect(connection)\`, follows \`authorizationUrl\` when the result is \`consent_required\`, returns to a clear connected state and lets the person continue. Consent starts from that control, never on page load; missing consent never falls back to another account. App-identity operations (\`slack.message.post\` declared \`"identity": "app"\`, \`warehouse.view.read\`) never call connect — it is refused and puts nothing in IT's queue.
|
|
100
104
|
|
|
101
105
|
## Sends
|
|
102
106
|
|
|
@@ -205,8 +205,24 @@ export function openRequestId(lanes) {
|
|
|
205
205
|
}
|
|
206
206
|
/** A granted lane past IT's expiry (the server reads it as failed; the sentence says why). */
|
|
207
207
|
const expired = (grant, nowMs) => grant.status === "GRANTED" && Boolean(grant.expiresAt && Date.parse(grant.expiresAt) <= nowMs);
|
|
208
|
-
/**
|
|
209
|
-
|
|
208
|
+
/**
|
|
209
|
+
* The worker is minting this lane's credentials right now. `readiness` cannot say so on its own —
|
|
210
|
+
* the server folds both waits into `pending` — but the grant summary has always carried the
|
|
211
|
+
* provisioning status beside it, and the two together separate seconds from days: `REQUESTED` waits
|
|
212
|
+
* on a person at IT, while `PENDING` provisioning follows a decision already taken (IT's, the anchor
|
|
213
|
+
* rule's, or the connection's development preapproval) and clears itself. Observed 2026-09-14: one
|
|
214
|
+
* added column re-files an approved warehouse request, the still-GRANTED lanes are re-minted, and
|
|
215
|
+
* reading that as IT's sent a builder off to report a permanently broken development lane.
|
|
216
|
+
*/
|
|
217
|
+
const minting = (grant) => grant.readiness === "pending" && grant.provisioning === "PENDING";
|
|
218
|
+
/**
|
|
219
|
+
* Which readiness values can carry a deploy, as a total map so `npm run check` refuses a new
|
|
220
|
+
* readiness value until this question has been answered for it. A person's consent is asked
|
|
221
|
+
* inside the running app, so a consent lane deploys.
|
|
222
|
+
*/
|
|
223
|
+
const DEPLOYABLE = { ready: true, consent_required: true, reconnect_required: true, pending: false, failed: false };
|
|
224
|
+
/** 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. */
|
|
225
|
+
const laneReady = (grant, nowMs) => grant.status === "GRANTED" && !expired(grant, nowMs) && DEPLOYABLE[grant.readiness] && !minting(grant);
|
|
210
226
|
/** Builder-safe failure guidance as one sentence: what failed, then what to do. */
|
|
211
227
|
export const failureSentence = (failure) => `${failure.message} ${failure.remediation}`;
|
|
212
228
|
const failureError = (failure) => new CliError(failure.code, failureSentence(failure));
|
|
@@ -218,12 +234,17 @@ const failureError = (failure) => new CliError(failure.code, failureSentence(fai
|
|
|
218
234
|
* read right after filing may not carry the anchor yet, so the filing's own
|
|
219
235
|
* request id is the fallback). Development ready while another lane waits is
|
|
220
236
|
* the connection's development preapproval: the server writes that lane at
|
|
221
|
-
* filing time and keeps the request open for IT.
|
|
237
|
+
* filing time and keeps the request open for IT. A lane the worker is still
|
|
238
|
+
* minting reads `being set up`: it carries the request it was re-filed under,
|
|
239
|
+
* but nobody has to decide anything and it clears itself in seconds.
|
|
222
240
|
*/
|
|
223
241
|
export function laneSummary(group, requestHint, nowMs = Date.now()) {
|
|
224
242
|
const { lanes } = group;
|
|
225
243
|
const open = openRequestId(lanes) ?? requestHint;
|
|
226
|
-
|
|
244
|
+
// Only a lane a person still has to decide is named as IT's. A lane being minted is `pending` too and always
|
|
245
|
+
// carries the request id it was re-filed under, so naming it here was the whole bug — and it also turned a
|
|
246
|
+
// sibling `ready` development lane into a "(preapproved)" one, as if something were being held back from it.
|
|
247
|
+
const waitingOn = (lane) => { const grant = lanes[lane]; return grant ? (grant.readiness === "pending" && !minting(grant) ? grant.pendingRequestId ?? requestHint : undefined) : open; };
|
|
227
248
|
const word = (lane) => {
|
|
228
249
|
const grant = lanes[lane];
|
|
229
250
|
const request = waitingOn(lane);
|
|
@@ -236,6 +257,8 @@ export function laneSummary(group, requestHint, nowMs = Date.now()) {
|
|
|
236
257
|
case "ready": return { base: "ready", suffix: lane === "development" && ENVIRONMENTS.some(other => other !== lane && waitingOn(other)) ? ` (preapproved${until})` : until };
|
|
237
258
|
case "consent_required": return { base: "approved", suffix: " (connect your account in the app)" };
|
|
238
259
|
case "reconnect_required": return { base: "approved", suffix: " (reconnect your account in the app)" };
|
|
260
|
+
// `being set up` is reachable at last: a minting lane is `pending` here too, and `waitingOn` no longer hands
|
|
261
|
+
// it a request id, so it falls through to the words that were always the right ones for it.
|
|
239
262
|
case "pending": return { base: request ? `waiting for IT (request ${request})` : "being set up", suffix: "" };
|
|
240
263
|
case "failed": return { base: grant.status === "DENIED" ? "denied" : grant.status === "REVOKED" ? "revoked" : "failed", suffix: "" };
|
|
241
264
|
}
|
|
@@ -277,6 +300,9 @@ export async function assertPreviewIntegrationsReady(root, client, tenantId, bun
|
|
|
277
300
|
if (!filed)
|
|
278
301
|
return (await readKitLock(root))?.appId || undefined;
|
|
279
302
|
const nowMs = Date.now();
|
|
303
|
+
// `awaitsDecision` separates the refusals a person has to act on from a preview lane the worker is
|
|
304
|
+
// simply still minting: that one refuses too (there are no credentials to deploy against yet), but
|
|
305
|
+
// saying "Waiting for IT" over it is the same false blocker the minting predicate exists to end.
|
|
280
306
|
const waiting = [];
|
|
281
307
|
for (const item of settled(filed.requests)) {
|
|
282
308
|
const group = findGroup(filed.groups, item.connection, item.identityMode);
|
|
@@ -286,17 +312,24 @@ export async function assertPreviewIntegrationsReady(root, client, tenantId, bun
|
|
|
286
312
|
throw failureError(preview.failure);
|
|
287
313
|
if (preview && laneReady(preview, nowMs))
|
|
288
314
|
continue;
|
|
315
|
+
if (preview && minting(preview)) {
|
|
316
|
+
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.` });
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
289
319
|
const requestId = item.outcome.requestId ?? openRequestId(group.lanes);
|
|
290
|
-
waiting.push(requestId
|
|
291
|
-
|
|
292
|
-
|
|
320
|
+
waiting.push({ awaitsDecision: true, line: requestId
|
|
321
|
+
? `Waiting for IT: ${item.connection} ${IDENTITY_WORDS[item.identityMode]} (request ${requestId}).`
|
|
322
|
+
: `${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
323
|
}
|
|
294
324
|
if (!waiting.length)
|
|
295
325
|
return filed.appId;
|
|
296
326
|
output("Isomorph cannot deploy the preview yet:");
|
|
297
|
-
for (const line of waiting)
|
|
327
|
+
for (const { line } of waiting)
|
|
298
328
|
output(` ${line}`);
|
|
299
|
-
|
|
329
|
+
const next = waiting.some(item => item.awaitsDecision)
|
|
330
|
+
? "IT approves a request once, for every environment; rerun productionise when it is approved."
|
|
331
|
+
: "Run productionise again in a minute.";
|
|
332
|
+
throw new CliError("INTEGRATIONS_NOT_READY", `${waiting.map(item => item.line).join(" ")} ${next}`);
|
|
300
333
|
}
|
|
301
334
|
/**
|
|
302
335
|
* `productionise` / `promote` pre-check for an app that calls governed AI
|
|
@@ -386,5 +419,5 @@ export async function integrationsStatus(root, client) {
|
|
|
386
419
|
if (!lock?.appId)
|
|
387
420
|
return { linked: false, grants: [], requests: [] };
|
|
388
421
|
const listed = await client.list(lock.appId);
|
|
389
|
-
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 };
|
|
422
|
+
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, provisioning: grant.provisioning, pendingRequestId: grant.pendingRequestId, failure: grant.failure })), requests: listed.requests };
|
|
390
423
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const PUBLISHED_KIT_BUNDLE = {
|
|
2
2
|
"schema": "isomorph.kit-bundle/1.0",
|
|
3
|
-
"kitVersion": "0.3.
|
|
3
|
+
"kitVersion": "0.3.1",
|
|
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:af841461de984099c202b5d0c86321c5e5f51713f3bcd487f0fc904b17a30d3a",
|
|
12
|
+
"sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:fe94fb879357520bc9b34db590a64b6c82cb63e59b97ca76d1cde470e84f7df3"
|
|
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:56db6d0080e0f38401d8a4a6cba0dfd44fd66878d574e88df8b6ffe63551fa4e",
|
|
17
|
+
"sha256": "56db6d0080e0f38401d8a4a6cba0dfd44fd66878d574e88df8b6ffe63551fa4e"
|
|
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:082272dd1823d51e05d5f9497c5644dd62dd3996ffe83ae69f357471d7d492a0",
|
|
21
|
+
"sha256": "082272dd1823d51e05d5f9497c5644dd62dd3996ffe83ae69f357471d7d492a0"
|
|
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:5341eab77337713255fa3175f14038d702b07332bb0073b5bba333ba5adf5bf1",
|
|
25
|
+
"sha256": "5341eab77337713255fa3175f14038d702b07332bb0073b5bba333ba5adf5bf1"
|
|
26
26
|
}
|
|
27
27
|
},
|
|
28
28
|
"brief": {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { allowedLiterals, numericMinimum, textLengthBounds } from "./app-schema.js";
|
|
4
|
+
import { allowedLiterals, hasNumericMaximum, numericMinimum, textLengthBounds } from "./app-schema.js";
|
|
5
5
|
import { COVERED_CAPABILITIES, capabilitiesCalled, capabilityCallSurface, capabilityUsage, sourceTableUsage } from "./source-inventory.js";
|
|
6
6
|
import { kitPaths } from "./kit.js";
|
|
7
7
|
/**
|
|
@@ -334,7 +334,7 @@ function rowShape(schema, needsUpdate) {
|
|
|
334
334
|
return { blocked: `its \`${unwritable.name} ${unwritable.declaredType}\` column ${unwritable.references ? "references another table, so a row cannot be written without inventing its parent — rule core.journey-fk (core.md in the isomorph skill)" : "has a type Isomorph cannot invent a value for"}` };
|
|
335
335
|
const marker = writable.find(column => isMarker(column) && column.notNull && !column.hasDefault) ?? writable.find(isMarker);
|
|
336
336
|
if (!marker)
|
|
337
|
-
return { blocked:
|
|
337
|
+
return { blocked: markerless(writable) };
|
|
338
338
|
const markerExpression = marker.type === "text" ? textMarker(marker) : "Date.now() % 2000000000";
|
|
339
339
|
if (!markerExpression)
|
|
340
340
|
return { blocked: `\`${marker.name}\` is the only column a check could find a row by, and its CHECK constraint leaves no room for a unique value` };
|
|
@@ -345,6 +345,12 @@ function rowShape(schema, needsUpdate) {
|
|
|
345
345
|
return { blocked: "the app updates it, but every column Isomorph could change is the one the check finds the row by" };
|
|
346
346
|
return { values: [`${marker.name}: markerValue`, ...fill], marker, markerExpression, locator, uuid: fill.some(value => value.includes("randomUUID(")), update };
|
|
347
347
|
}
|
|
348
|
+
/** Why nothing on the table can carry the unique value a journey finds its row by — naming the capped number when that is what ruled the last candidate out, so the answer is a column to add rather than a CHECK to weaken. */
|
|
349
|
+
function markerless(writable) {
|
|
350
|
+
const none = "it has no free text or number column a check could write a unique value into and find the row by";
|
|
351
|
+
const capped = writable.find(cappedNumber);
|
|
352
|
+
return capped ? `${none}: \`${capped.name} ${capped.declaredType}\` is a number its own CHECK caps, and the unique value a journey writes is a timestamp` : none;
|
|
353
|
+
}
|
|
348
354
|
/** What a journey writes into a free-text column it updates, when the column's length bounds admit it. */
|
|
349
355
|
const UPDATED_TEXT = "isomorph check updated";
|
|
350
356
|
/** The column a journey changes, and what it changes it to: a boolean is flipped, a closed set moves to its other value, free text gets a fixed one. */
|
|
@@ -366,8 +372,10 @@ function updateStep(writable, marker, locator) {
|
|
|
366
372
|
}
|
|
367
373
|
return undefined;
|
|
368
374
|
}
|
|
375
|
+
/** A number the app's own CHECK caps from above: the unique value a journey marks a row with is a timestamp, which no ordinary ceiling admits, so such a column is never the marker. */
|
|
376
|
+
const cappedNumber = (column) => (column.type === "integer" || column.type === "number") && hasNumericMaximum(column.checks, column.name);
|
|
369
377
|
/** A column a check can write a unique value into and then filter on. */
|
|
370
|
-
const isMarker = (column) => (column.type === "text" || column.type === "integer" || column.type === "number") && !column.references && !allowedLiterals(column.checks, column.name).length;
|
|
378
|
+
const isMarker = (column) => (column.type === "text" || column.type === "integer" || column.type === "number") && !column.references && !cappedNumber(column) && !allowedLiterals(column.checks, column.name).length;
|
|
371
379
|
/** A unique marker satisfying the column's own length constraints, or `undefined` when they leave no room for one. */
|
|
372
380
|
function textMarker(column) {
|
|
373
381
|
const { min, max } = textLengthBounds(column.checks, column.name);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = "0.3.1-rc.
|
|
1
|
+
export const CLI_VERSION = "0.3.1-rc.2";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@isomorph.ai/cli",
|
|
3
|
-
"version": "0.3.1-rc.
|
|
3
|
+
"version": "0.3.1-rc.2",
|
|
4
4
|
"description": "Isomorph development kit CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"harbour": {
|
|
38
38
|
"kitBundle": {
|
|
39
39
|
"repository": "public.ecr.aws/y6t4p3i8/harbour-kit-bundle",
|
|
40
|
-
"version": "0.3.
|
|
40
|
+
"version": "0.3.1"
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
}
|