@isomorph.ai/cli 0.3.0 → 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/README.md +4 -2
- package/dist/packages/harbour-cli/src/agent-setup.js +2 -2
- 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/cli.js +22 -18
- 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/local-runtime.js +1 -1
- package/dist/packages/harbour-cli/src/operations.js +6 -2
- package/dist/packages/harbour-cli/src/productionise.js +6 -4
- package/dist/packages/harbour-cli/src/retained-checks.js +11 -3
- package/dist/packages/harbour-cli/src/starter.js +5 -2
- package/dist/packages/harbour-cli/src/version.js +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -36,12 +36,14 @@ isomorph integrations catalog --app-root <path> [--json] the company's connect
|
|
|
36
36
|
isomorph integrations request <connection> --app-root <path> [--reason <text>] one request per connection; IT approves it once for every environment (`isomorph dev` and `isomorph productionise` file it for you)
|
|
37
37
|
isomorph integrations status --app-root <path> [--json]
|
|
38
38
|
isomorph connect <work-email | company-start-url> once per company; then isomorph login | logout
|
|
39
|
-
isomorph productionise --app-root <path> [--
|
|
40
|
-
isomorph status | retry | promote | setup | profile | audience | secrets … --operation <reference>
|
|
39
|
+
isomorph productionise --app-root <path> [--confirm-save] [--json] save + preview deployment, prints the protected link
|
|
40
|
+
isomorph status | retry | promote | setup | profile | audience | secrets … --operation <reference> (promote: [--confirm-tested])
|
|
41
41
|
```
|
|
42
42
|
|
|
43
43
|
`productionise`, `status --wait`, `retry` and `promote` follow the deployment for up to `--max-wait <seconds>` (default 30 minutes). Past that they exit 0 with `status: "RUNNING"` and the `isomorph status --operation <reference> --wait --max-wait 120 --json` that continues the same deployment — a bounded wait is not a failure, and never a reason to start another deploy.
|
|
44
44
|
|
|
45
|
+
`productionise` and `promote` each stop for a typed reply — `Approved.` before the app files are copied to the company code system, `Tested.` before the preview is released. `--confirm-save` and `--confirm-tested` give that same reply on the command line when nobody can type it.
|
|
46
|
+
|
|
45
47
|
`isomorph --help` prints the full usage. Local commands need no company sign-in; integrations and shipping do.
|
|
46
48
|
|
|
47
49
|
## Apps set up by the old `harbour` CLI
|
|
@@ -220,8 +220,8 @@ The kit's rules are the files beside this one: \`core.md\` before the first edit
|
|
|
220
220
|
- "does it work?", and before you report anything as working → open the dev link in your own browser, press the control you built or changed, and read what the app shows. A green \`isomorph check\` is not that proof: fixtures answer AI and company systems, so their refusals appear only when the control is really pressed. Test rendering, navigation, fixtures and approved reads automatically; a Send in development is real: reuse explicit authorization for that bounded test, or ask once if none exists (IT approval alone is not permission to send). If you have no browser, say that the button itself is untested.
|
|
221
221
|
- "I need Slack / Gmail / the warehouse / company data" → read \`integrations.md\` beside this skill first; then, in the same turn, read the catalog, declare only what the app calls, submit the access request yourself, and say READY or PENDING in one line.
|
|
222
222
|
- "summarise", "draft", "explain", "AI" → read \`ai.md\` beside this skill first; then one call behind a control they press.
|
|
223
|
-
- "ship it", "put it online", "let my team try it" → \`isomorph check --app-root . --json\` first, unasked
|
|
224
|
-
- "make it live for everyone", "go to production" → only after they have tried the preview. Before \`isomorph promote --operation <ref> --json\`, show the exact app name, description and production audience again
|
|
223
|
+
- "ship it", "put it online", "let my team try it" → \`isomorph check --app-root . --json\` first, unasked; fix everything it finds: the same gates rerun in the cloud, minutes per attempt. Before sharing or deploying the private preview, show the exact proposed app name, description and audience that will be passed to Isomorph (say "only you" when the audience is empty); ask for one confirmation or correction covering all three; do not run \`productionise\` until they confirm. \`isomorph productionise --app-root . --name "<app name>" --description "<description>" --emails "<comma-separated audience>" --confirm-save --json\` (omit \`--emails\` for "only you"; \`--confirm-save\` is the approval they just gave). It records the confirmed setup before uploading or deploying the app, files the access request for each declared connection itself and refuses with \`INTEGRATIONS_NOT_READY\` naming what IT still has to approve, or \`AI_NOT_READY\` and the one IT step: relay that line only; the app works without them until then. \`result.deployment.protectedUrl\` is the private preview, for them and the confirmed audience after company sign-in. If your tool cuts it off, \`${continueCommand("<ref>")}\` continues the same deployment; never start another one to find out what happened; \`TRANSFORMING\` means building and checking.
|
|
224
|
+
- "make it live for everyone", "go to production" → only after they have tried the preview, which \`--confirm-tested\` states. Before \`isomorph promote --operation <ref> --confirm-tested --json\`, show the exact app name, description and production audience again; ask for one confirmation or correction covering all three; never promote a profile or audience the person has not just seen and confirmed. Report the production link, or that operator approval is pending.
|
|
225
225
|
- "stop it" → \`isomorph stop --app-root .\` (data kept); \`isomorph dev --reset --app-root .\` deletes local data, only when they ask to start over.
|
|
226
226
|
- "every day at 2pm", "run this on a schedule", "send this automatically" → read \`jobs.md\` beside this skill first; then write the job, run it once with \`isomorph jobs run\`, and say the sentence it gives you.
|
|
227
227
|
|
|
@@ -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
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { RemoteMcpClient } from "./remote-mcp-client.js";
|
|
3
|
-
import { appCallsAi, productionise } from "./productionise.js";
|
|
4
|
-
import { confirmAudience, confirmProfile, continueCommand, dismissSecret, fetchStatus, follow, getAppSetup, listSecrets, promoteToProduction, readSecretFromStdin, readSecretFromTerminal, retryDeployment, setSecret, summarize } from "./operations.js";
|
|
3
|
+
import { appCallsAi, productionise, SAVE_APPROVAL } from "./productionise.js";
|
|
4
|
+
import { confirmAudience, confirmProfile, continueCommand, dismissSecret, fetchStatus, follow, getAppSetup, listSecrets, promoteToProduction, PROMOTION_CONFIRMATION, readSecretFromStdin, readSecretFromTerminal, retryDeployment, setSecret, summarize } from "./operations.js";
|
|
5
5
|
import { CliError, failureEnvelope, operationEnvelope, renderFailure, renderSummary } from "./output.js";
|
|
6
6
|
import { CLI_VERSION } from "./version.js";
|
|
7
7
|
import { connectedAccount, login, logout, refreshStoredToken } from "./auth.js";
|
|
@@ -28,6 +28,9 @@ const profileDescription = optionValue("--description");
|
|
|
28
28
|
const audienceEmails = optionValue("--emails");
|
|
29
29
|
const personal = args.includes("--personal");
|
|
30
30
|
const valueStdin = args.includes("--value-stdin");
|
|
31
|
+
/** The reply the save and promote prompts would otherwise wait to have typed, for a caller with no terminal. */
|
|
32
|
+
const confirmSave = args.includes("--confirm-save");
|
|
33
|
+
const confirmTested = args.includes("--confirm-tested");
|
|
31
34
|
const subcommand = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
|
|
32
35
|
const wait = args.includes("--wait");
|
|
33
36
|
const reset = args.includes("--reset");
|
|
@@ -62,10 +65,10 @@ const usage = [
|
|
|
62
65
|
"Usage:",
|
|
63
66
|
" isomorph connect <work-email | company-start-url>",
|
|
64
67
|
" isomorph login | logout",
|
|
65
|
-
" isomorph productionise --app-root <path> [--include <relative-path>]... [--name <text> --description <text> [--emails a@co,b@co]] [--no-wait] [--max-wait <seconds>] [--json]",
|
|
68
|
+
" isomorph productionise --app-root <path> [--include <relative-path>]... [--name <text> --description <text> [--emails a@co,b@co]] [--confirm-save] [--no-wait] [--max-wait <seconds>] [--json]",
|
|
66
69
|
" isomorph status --operation <reference> [--wait] [--max-wait <seconds>] [--json]",
|
|
67
70
|
" isomorph retry --operation <reference> [--no-wait] [--max-wait <seconds>] [--json]",
|
|
68
|
-
" isomorph promote --operation <reference> [--app-root <path>] [--no-wait] [--max-wait <seconds>] [--json] (--app-root: also confirm the company's AI setup for production when the app calls governed AI)",
|
|
71
|
+
" isomorph promote --operation <reference> [--app-root <path>] [--confirm-tested] [--no-wait] [--max-wait <seconds>] [--json] (--app-root: also confirm the company's AI setup for production when the app calls governed AI)",
|
|
69
72
|
" isomorph setup --operation <reference> [--json] what the app still needs (name, audience, secrets)",
|
|
70
73
|
" isomorph profile --operation <reference> [--name <text>] [--description <text>] [--json]",
|
|
71
74
|
" isomorph audience --operation <reference> [--emails a@co,b@co] [--json] (no --emails = only you)",
|
|
@@ -83,6 +86,7 @@ const usage = [
|
|
|
83
86
|
" isomorph integrations catalog --app-root <path> [--json] the company's connections as .isomorph/integrations.json names them: identifiers, allowed operations, approved channels/tables/views/mailboxes per environment (no app needed)",
|
|
84
87
|
"Run `isomorph connect <work-email>` once; sign in with `isomorph login` when Isomorph asks.",
|
|
85
88
|
"productionise saves the app, follows its deployment, and prints the protected preview link; promote sends a tested preview to production.",
|
|
89
|
+
"productionise stops for a typed `Approved.` before it copies the app files to the company code system, and promote for a typed `Tested.` before it releases the preview. --confirm-save and --confirm-tested give that same reply on the command line when nobody can type it; the approval recorded is the one made by whoever ran the command.",
|
|
86
90
|
`--max-wait bounds how long productionise, status --wait, retry and promote follow the deployment (default 30 minutes). When it passes the command exits 0 with status RUNNING, the last known deployment state, and the \`${continueCommand("<reference>")}\` that continues the same deployment: a bounded wait is not a failure and never means start another one.`,
|
|
87
91
|
"For kit apps, productionise first files the access request for every connection in .isomorph/integrations.json (one per connection; IT approves it once for every environment) and exits 2 (INTEGRATIONS_NOT_READY) naming what IT still has to approve, asks governance whether the company's AI setup is ready when the app calls governed AI (exit 2, AI_NOT_READY, with the IT step), then runs the pipeline's kit gate in the local session and refuses (KIT_GATE_FAILED) what CodeBuild would refuse. promote asks the same AI question for production when --app-root names the app.",
|
|
88
92
|
"secrets set reads the value from your terminal with echo off (or from stdin with --value-stdin); it is never printed or passed to any other program.",
|
|
@@ -95,6 +99,14 @@ const progress = (message) => { process.stderr.write(`${message}\n`); };
|
|
|
95
99
|
const summaryEnvelope = (result) => ({ schema: "isomorph.cli-result/1.0", cliVersion: CLI_VERSION, status: "SUCCEEDED", operationStarted: false, result });
|
|
96
100
|
/** `line` is the human form for a command whose result is a sentence, not a summary. */
|
|
97
101
|
const emit = (value, line) => { process.stdout.write(json ? `${JSON.stringify(value)}\n` : line ?? renderSummary(value)); process.exitCode = 0; };
|
|
102
|
+
/**
|
|
103
|
+
* The command's last bytes, then its exit code. Every non-zero exit goes through
|
|
104
|
+
* here: `process.exitCode` is not ours alone — a dependency's `beforeExit` hook
|
|
105
|
+
* overwrites it (see local-runtime.ts) — and `process.exit` on its own truncates
|
|
106
|
+
* a write still in flight when the stream is a pipe rather than a terminal,
|
|
107
|
+
* which is how an agent or a CI job reads this CLI. Both writes flush first.
|
|
108
|
+
*/
|
|
109
|
+
const exitAfterWriting = (code, out, err = "") => { process.stderr.write(err, () => process.stdout.write(out, () => process.exit(code))); };
|
|
98
110
|
/** Exit 2, like a usage error: nothing started and the fix is a command the maker runs. */
|
|
99
111
|
const USAGE_REFUSALS = ["INTEGRATIONS_NOT_READY", "AI_NOT_READY", "NOT_A_MEMBER", "TENANT_AMBIGUOUS", "NOT_A_START_LINK", "PLATFORM_UNREACHABLE"];
|
|
100
112
|
if (command === "--version" || command === "version") {
|
|
@@ -127,10 +139,7 @@ else if (!["connect", "login", "logout", "productionise", "integrations", ...LOC
|
|
|
127
139
|
|| (command === "integrations" && (!root || !subcommand || !["request", "status", "catalog"].includes(subcommand) || (subcommand === "request" && (!args[2] || args[2].startsWith("--")))))
|
|
128
140
|
|| (OPERATION_COMMANDS.includes(command) && !operationRef)
|
|
129
141
|
|| (command === "secrets" && (!subcommand || !["list", "set", "dismiss"].includes(subcommand) || (subcommand !== "list" && !secretName)))) {
|
|
130
|
-
|
|
131
|
-
process.stderr.write(`${optionError}\n`);
|
|
132
|
-
process.stderr.write(usage);
|
|
133
|
-
process.exit(2);
|
|
142
|
+
exitAfterWriting(2, "", `${optionError ? `${optionError}\n` : ""}${usage}`);
|
|
134
143
|
}
|
|
135
144
|
else {
|
|
136
145
|
try {
|
|
@@ -192,10 +201,8 @@ else {
|
|
|
192
201
|
governance = new GovernanceClient(config.apiUrl, token, config.tenantId);
|
|
193
202
|
}
|
|
194
203
|
const report = await runChecks(target, { run: runCommand, bundle, output: progress, governance });
|
|
195
|
-
if (!report.passed)
|
|
196
|
-
|
|
197
|
-
process.exitCode = 1;
|
|
198
|
-
}
|
|
204
|
+
if (!report.passed)
|
|
205
|
+
exitAfterWriting(1, json ? `${JSON.stringify({ ...summaryEnvelope(report), status: "FAILED", error: { code: "CHECKS_FAILED", message: "One or more checks failed; see .isomorph/local/check-report.json." } })}\n` : "Checks failed; see .isomorph/local/check-report.json.\n");
|
|
199
206
|
else
|
|
200
207
|
emit(summaryEnvelope(report));
|
|
201
208
|
}
|
|
@@ -257,7 +264,7 @@ else {
|
|
|
257
264
|
? { displayName: profileName, description: profileDescription, audienceEmails: audienceEmails ? audienceEmails.split(",").map(value => value.trim()).filter(Boolean) : [] }
|
|
258
265
|
: undefined;
|
|
259
266
|
await assertKitCurrent(appRoot(root));
|
|
260
|
-
const result = await productionise(root, client, progress, tenant, includePaths, { waitForDeployment: !noWait, waitOptions, integrations: { governance: new GovernanceClient(config.apiUrl, token, tenant), bundle: EMBEDDED_KIT_BUNDLE }, ...(confirmedAppSetup ? { confirmedAppSetup } : {}) });
|
|
267
|
+
const result = await productionise(root, client, progress, tenant, includePaths, { waitForDeployment: !noWait, waitOptions, integrations: { governance: new GovernanceClient(config.apiUrl, token, tenant), bundle: EMBEDDED_KIT_BUNDLE }, ...(confirmSave ? { readApproval: async () => SAVE_APPROVAL } : {}), ...(confirmedAppSetup ? { confirmedAppSetup } : {}) });
|
|
261
268
|
envelope = operationEnvelope(result.result, result.operationRef, CLI_VERSION);
|
|
262
269
|
}
|
|
263
270
|
else {
|
|
@@ -273,7 +280,7 @@ else {
|
|
|
273
280
|
if (lock?.appId)
|
|
274
281
|
await assertAiReady(lock.appId, "production", new GovernanceClient(config.apiUrl, token, tenant), progress);
|
|
275
282
|
}
|
|
276
|
-
summary = await promoteToProduction(client, operationRef, progress, { wait: !noWait, waitOptions });
|
|
283
|
+
summary = await promoteToProduction(client, operationRef, progress, { wait: !noWait, waitOptions, ...(confirmTested ? { readConfirmation: async () => PROMOTION_CONFIRMATION } : {}) });
|
|
277
284
|
}
|
|
278
285
|
else if (command === "setup")
|
|
279
286
|
summary = { setup: await getAppSetup(client, operationRef) };
|
|
@@ -295,10 +302,7 @@ else {
|
|
|
295
302
|
}
|
|
296
303
|
catch (error) {
|
|
297
304
|
const envelope = failureEnvelope(error, CLI_VERSION);
|
|
298
|
-
|
|
299
|
-
if (json || command === "productionise")
|
|
300
|
-
process.stdout.write(`${JSON.stringify(envelope)}\n`);
|
|
301
|
-
process.exit(USAGE_REFUSALS.includes(envelope.error?.code ?? "") ? 2 : 1);
|
|
305
|
+
exitAfterWriting(USAGE_REFUSALS.includes(envelope.error?.code ?? "") ? 2 : 1, json || command === "productionise" ? `${JSON.stringify(envelope)}\n` : "", renderFailure(envelope));
|
|
302
306
|
}
|
|
303
307
|
}
|
|
304
308
|
/** One line per (connection, identity): the three lanes are one approval, so they are read together. */
|
|
@@ -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": {
|
|
@@ -4,7 +4,6 @@ import { copyFile, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promi
|
|
|
4
4
|
import { createServer } from "node:net";
|
|
5
5
|
import { dirname, join, relative, win32 } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
-
import EmbeddedPostgres from "embedded-postgres";
|
|
8
7
|
import { kitPaths, projectName } from "./kit.js";
|
|
9
8
|
import { CliError } from "./output.js";
|
|
10
9
|
export function nodePackageCommand(tool, args, platform = process.platform, execPath = process.execPath) {
|
|
@@ -209,6 +208,7 @@ export class LocalRuntime {
|
|
|
209
208
|
const paths = kitPaths(this.root);
|
|
210
209
|
const postgresBinaries = await hydrateEmbeddedPostgres(this.run);
|
|
211
210
|
const postgresMessages = [];
|
|
211
|
+
const { default: EmbeddedPostgres } = await import("embedded-postgres");
|
|
212
212
|
this.postgres = new EmbeddedPostgres({ databaseDir: join(paths.state, "postgres"), port: this.ports.postgres, user: LOCAL.dbUser, password: LOCAL.dbPassword, persistent: true, onLog: message => postgresMessages.push(String(message).trim()), onError: error => postgresMessages.push(String(error)) });
|
|
213
213
|
try {
|
|
214
214
|
if (!(await readdir(join(paths.state, "postgres")).catch(() => [])).length)
|
|
@@ -250,6 +250,10 @@ export async function retryDeployment(client, operationRef, output, options = {}
|
|
|
250
250
|
return summarize(await fetchStatus(client, operationRef));
|
|
251
251
|
return follow(client, operationRef, output, options.waitOptions);
|
|
252
252
|
}
|
|
253
|
+
/** What the maker types at the promote prompt. `--confirm-tested` supplies this same literal through
|
|
254
|
+
* `readConfirmation`, so governance receives the attestation it receives today, byte for byte — the
|
|
255
|
+
* claim is unchanged, only reachable where no terminal can answer the prompt. */
|
|
256
|
+
export const PROMOTION_CONFIRMATION = "Tested.";
|
|
253
257
|
/**
|
|
254
258
|
* Promotion mirrors the console's "I tested this version" button: the maker
|
|
255
259
|
* must have opened the live preview and say so, and the version they tested
|
|
@@ -268,8 +272,8 @@ export async function promoteToProduction(client, operationRef, output, options
|
|
|
268
272
|
output(`Open the protected preview and test it: ${deployment.protectedUrl}`);
|
|
269
273
|
output("When it works as expected, type Tested. then press Enter to promote it to production.");
|
|
270
274
|
const reply = await (options.readConfirmation ?? readLine)();
|
|
271
|
-
if (reply !==
|
|
272
|
-
throw new CliError("PROMOTION_NOT_CONFIRMED", "The production promotion was not confirmed.", operationRef);
|
|
275
|
+
if (reply !== PROMOTION_CONFIRMATION)
|
|
276
|
+
throw new CliError("PROMOTION_NOT_CONFIRMED", "The production promotion was not confirmed. Confirm the tested preview with --confirm-tested instead.", operationRef);
|
|
273
277
|
const result = structured(await client.call("isomorph_promote_to_production", {
|
|
274
278
|
operationId: operationRef,
|
|
275
279
|
expectedPreviewVersionId: deployment.versionId,
|
|
@@ -12,6 +12,9 @@ import { isProhibitedSecretPath } from "../../../src/secret-paths.js";
|
|
|
12
12
|
import { assertAiReady, assertPreviewIntegrationsReady } from "./integrations.js";
|
|
13
13
|
import { kitPaths, readKitLock, recordKitAppId, sourceDigest } from "./kit.js";
|
|
14
14
|
import { FLOW_CHECK, preflightKitGate, readReport } from "./check.js";
|
|
15
|
+
/** What the maker types at the save prompt; `--confirm-save` supplies this same literal, so the
|
|
16
|
+
* `isomorph.stage-approval/1.0` attestation governance receives is unchanged, byte for byte. */
|
|
17
|
+
export const SAVE_APPROVAL = "Approved.";
|
|
15
18
|
export async function productionise(rootArg, client, output, tenantId, includePaths = [], options = {}) {
|
|
16
19
|
const root = resolve(rootArg);
|
|
17
20
|
output(`Isomorph is checking ${basename(root)}.`);
|
|
@@ -159,9 +162,9 @@ export async function productionise(rootArg, client, output, tenantId, includePa
|
|
|
159
162
|
let execution = prior?.sourceSave?.status && prior.sourceSave.status !== "AWAITING_APPROVAL" ? { status: prior.sourceSave.status } : await execute(client, operationRef, appId, graph);
|
|
160
163
|
if (execution.status === "APPROVAL_REQUIRED" || execution.approvalRequiredBeforeExternalAction === true) {
|
|
161
164
|
output(`Approval required: Isomorph can copy ${graph.deploymentScope.includedFiles.length} app files to the approved company code system. Nothing will be deployed or released. Type Approved. then press Enter.`);
|
|
162
|
-
const approval = await readApproval();
|
|
163
|
-
if (approval !==
|
|
164
|
-
throw new CliError("APPROVAL_NOT_GRANTED", "The Isomorph save was not approved.", operationRef);
|
|
165
|
+
const approval = await (options.readApproval ?? readLine)();
|
|
166
|
+
if (approval !== SAVE_APPROVAL)
|
|
167
|
+
throw new CliError("APPROVAL_NOT_GRANTED", "The Isomorph save was not approved. Approve copying the app files with --confirm-save instead.", operationRef);
|
|
165
168
|
execution = await execute(client, operationRef, appId, graph, { schema: "isomorph.stage-approval/1.0", step: "save_source_baseline", approved: true, approvedByUser: true, userApprovalText: approval, userVisibleProgress: "Approved.", nextStepSummary: "Isomorph can safely save the app.", dataOrActions: ["Save the app in the company code system."] });
|
|
166
169
|
}
|
|
167
170
|
if (execution.status === "ADMIN_SETUP_REQUIRED")
|
|
@@ -437,7 +440,6 @@ function outcomeLine(summary) {
|
|
|
437
440
|
default: return summary.nextStep ?? "Isomorph saved the app; deployment has not started.";
|
|
438
441
|
}
|
|
439
442
|
}
|
|
440
|
-
const readApproval = readLine;
|
|
441
443
|
/**
|
|
442
444
|
* Whether the app really calls governed AI: the gate's inventory of
|
|
443
445
|
* `isomorph.ai.*` call sites (`.isomorph/local/check-report.json`,
|
|
@@ -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);
|
|
@@ -421,8 +421,11 @@ export function App() {
|
|
|
421
421
|
{error && <p role="alert" style={{ color: "crimson" }}>{error}</p>}
|
|
422
422
|
|
|
423
423
|
{/* Notes — paired with .isomorph/checks/notes-journey.mjs and notes-cross-user.mjs, generated
|
|
424
|
-
from this section and migrations/0001_notes.sql;
|
|
425
|
-
|
|
424
|
+
from this section and migrations/0001_notes.sql; \`isomorph check\` rewrites both whenever
|
|
425
|
+
either one changes. Edit 0001_notes.sql in place only until this app's first deploy: an
|
|
426
|
+
applied migration is frozen, so after that the schema changes by adding the next
|
|
427
|
+
migrations/000N_*.sql — never by editing or deleting an applied file, and never by
|
|
428
|
+
dropping a deployed table. Rules: core.md in the isomorph skill (data). */}
|
|
426
429
|
<section>
|
|
427
430
|
<h2>Notes</h2>
|
|
428
431
|
<form onSubmit={event => { event.preventDefault(); void addNote(); }}>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = "0.3.
|
|
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.
|
|
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
|
}
|