@isomorph.ai/cli 0.4.2 → 0.5.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.
@@ -1,29 +1,26 @@
1
- import { homedir } from "node:os";
2
- import { basename, dirname, parse, resolve } from "node:path";
1
+ import { basename, dirname, resolve } from "node:path";
3
2
  import { readFile, mkdir, rename, writeFile } from "node:fs/promises";
4
- import { scanWorkspace } from "../../../src/analyzer.js";
5
3
  import { createSourceManifest } from "../../../src/source-intake.js";
6
- import { structured } from "./remote-mcp-client.js";
7
4
  import { archiveForManifest, putMultipart } from "./upload.js";
8
5
  import { CliError } from "./output.js";
9
- import { confirmAudience, confirmProfile, follow, pickSourceFailure, fetchStatus, outcomeFor } from "./operations.js";
6
+ import { continueCommand, follow, outcomeFor, pickSourceFailure, recordedRefusal, runningLine, statusFetch, summarize } from "./operations.js";
10
7
  import { CLI_VERSION } from "./version.js";
11
- import { isProhibitedSecretPath } from "../../../src/secret-paths.js";
12
- import { assertDeployReady, ensureLinkedApp } from "./integrations.js";
13
- import { describeSourceChange, readAppProfile, readKitLock, recordKitAppId, sourceDigest } from "./kit.js";
8
+ import { assertSafeAppRoot, readAppTree } from "./package.js";
9
+ import { deployBlocked, ensureLinkedApp } from "./integrations.js";
10
+ import { describeSourceChange, kitPaths, readAppProfile, readDeclaration, readKitLock, sourceDigest } from "./kit.js";
14
11
  import { CHECKS_FAILED_HINT, checksFailedMessage, FLOW_CHECK, readReport, runChecks } from "./check.js";
15
12
  import { runCommand } from "./local-runtime.js";
16
13
  import { EMBEDDED_KIT_BUNDLE } from "./kit-bundle.js";
17
14
  /**
18
- * The save attestation governance records (`isomorph.stage-approval/1.0`). The
19
- * CLI is run by an agent, so no terminal prompt stands between the command and
20
- * this reply: the person's confirmation is the `.isomorph/app.json` review the
21
- * agent shows them before running `deploy`, and the wire schema is unchanged.
15
+ * The manifest built here is local: the server sees the package's digest and
16
+ * its file list, and opens (or resumes) the operation from those. The operation
17
+ * id in this manifest is therefore a fixed label, never sent.
22
18
  */
23
- export const SAVE_APPROVAL = "Approved.";
24
- export async function deploy(rootArg, client, output, tenantId, options = {}) {
25
- const deployment = await runDeploy(rootArg, client, output, tenantId, options);
26
- await reportSession(rootArg, deployment.operationRef, options);
19
+ const LOCAL_MANIFEST_OPERATION = "local";
20
+ /** Deploys the private preview: one REST route to open the operation, one to hand over the package, then the status route until it settles. */
21
+ export async function deploy(rootArg, governance, output, tenantId, options = {}) {
22
+ const deployment = await runDeploy(rootArg, governance, output, tenantId, options);
23
+ await reportSession(rootArg, governance, deployment.operationRef, options);
27
24
  return deployment;
28
25
  }
29
26
  /**
@@ -34,25 +31,23 @@ export async function deploy(rootArg, client, output, tenantId, options = {}) {
34
31
  * outage, a missing Atlas token, or a runtime too old to have the feedback route
35
32
  * must never fail, delay or alter a deployment the builder already asked for.
36
33
  */
37
- async function reportSession(rootArg, operationRef, options) {
34
+ async function reportSession(rootArg, governance, operationRef, options) {
38
35
  const notes = options.sessionNotes?.trim();
39
- if (!notes || !options.integrations)
36
+ if (!notes)
40
37
  return;
41
38
  try {
42
- const appId = options.appId ?? (await readKitLock(resolve(rootArg)))?.appId;
39
+ const appId = (await readKitLock(resolve(rootArg)))?.appId;
43
40
  if (!appId)
44
41
  return;
45
- await options.integrations.governance.submitFeedback(appId, {
46
- operationRef, cliVersion: CLI_VERSION, kitBundleVersion: options.integrations.bundle.kitVersion, notes
47
- });
42
+ await governance.submitFeedback(appId, { operationRef, cliVersion: CLI_VERSION, kitBundleVersion: (options.bundle ?? EMBEDDED_KIT_BUNDLE).kitVersion, notes });
48
43
  }
49
44
  catch { /* never surfaced: the deployment is what the builder asked for. */ }
50
45
  }
51
- async function runDeploy(rootArg, client, output, tenantId, options = {}) {
46
+ async function runDeploy(rootArg, governance, output, tenantId, options) {
52
47
  const root = resolve(rootArg);
48
+ const bundle = options.bundle ?? EMBEDDED_KIT_BUNDLE;
53
49
  output(`Isomorph is checking ${basename(root)}.`);
54
- if (root === parse(root).root || root === resolve(homedir()))
55
- throw new CliError("PREFLIGHT_APP_ROOT", "The selected app boundary is unsafe.");
50
+ assertSafeAppRoot(root);
56
51
  // Only a kit app deploys from here: the checks, the gate and the profile all
57
52
  // live in `.isomorph/`. Anything else deploys from the console.
58
53
  if (!(await readKitLock(root)))
@@ -63,63 +58,18 @@ async function runDeploy(rootArg, client, output, tenantId, options = {}) {
63
58
  for (const line of describeProfile(profile))
64
59
  output(line);
65
60
  // Refused here, in the first second: a deploy with no description used to run
66
- // the whole gate and open an operation before `confirmProfile` refused it
67
- // (live, 2026-09-16), and the operation it left behind then blocked the
68
- // corrected tree as "changed before its upload finished".
61
+ // the whole gate and open an operation before the profile was refused (live,
62
+ // 2026-09-16). The deploy route records all three values, so it refuses one
63
+ // that is blank too; this only says so before the checks spend a minute.
69
64
  if (!profile.name.trim() || !profile.description.trim())
70
65
  throw new CliError("PROFILE_INCOMPLETE", "The app needs a name and a description in .isomorph/app.json before it can deploy.", undefined, "Edit `name` and `description` in .isomorph/app.json (show them to the person first), then run this again.");
71
- let pending = await readPending(root, tenantId, client.endpoint);
72
- let prior;
73
- if (pending) {
74
- try {
75
- prior = await fetchStatus(client, pending.operationRef);
76
- }
77
- catch (error) {
78
- if (error instanceof CliError)
79
- throw withOperation(error, pending.operationRef);
80
- throw new CliError("OPERATION_STATUS_UNAVAILABLE", "Isomorph could not read the existing operation. Continue it when the connection recovers; do not start another deployment.", pending.operationRef);
81
- }
82
- if (prior.operation?.stage === "failed" && pending.sourceDigest === (await sourceDigest(root)).digest)
83
- throw intakeRejection(prior, pending.operationRef);
84
- if (operationFinished(prior) && pending.sourceDigest !== (await sourceDigest(root)).digest)
85
- pending = undefined;
86
- }
87
- else {
88
- const appId = options.appId ?? (await readKitLock(root))?.appId;
89
- if (appId) {
90
- await client.initialize();
91
- const recovered = structured(await client.call("isomorph_get_operation_status", { appId }));
92
- if (!recovered || !Object.hasOwn(recovered, "operation"))
93
- throw new CliError("RECOVERY_UNAVAILABLE", "This company needs Isomorph's deployment recovery update before shipping. No new deployment was started.");
94
- if (recovered.operation?.operationId && !operationFinished(recovered)) {
95
- prior = recovered;
96
- pending = { tenantId, endpoint: client.endpoint, operationRef: recovered.operation.operationId, sourceDigest: (await sourceDigest(root)).digest, uploaded: Boolean(recovered.sourceSave || recovered.deployment || recovered.waiting || recovered.operation.stage === "awaiting-approval"), graph: await scanWorkspace(root, { sourceBoundary: "root" }), appId };
97
- await writePending(root, pending);
98
- }
99
- }
100
- }
101
- if (pending && (prior?.deployment || prior?.production || prior?.waiting || operationFinished(prior))) {
102
- output("Isomorph is continuing the existing operation; no new deployment was started.");
103
- return finishDeployment(client, pending.operationRef, output, options);
104
- }
105
- if (pending && prior?.sourceSave)
106
- pending.uploaded = true;
107
- // A record whose upload never finished pins nothing: nothing of the old tree
108
- // reached Isomorph, so the changed tree is simply what this deploy carries.
109
- // Only an upload already in progress (parts sent, completion pending) must
110
- // see the bytes it started with.
111
- if (pending && !pending.uploaded && pending.sourceDigest !== (await sourceDigest(root)).digest) {
112
- if (pending.uploadCompletion)
113
- throw new CliError("UPLOAD_SOURCE_CHANGED", "The app changed while its upload was in progress. Restore the submitted files to finish it, or wait for it to expire and deploy again.", pending.operationRef);
114
- output("The app changed since the previous attempt started; deploying the current code on the same operation.");
115
- pending = { ...pending, sourceDigest: (await sourceDigest(root)).digest, graph: await scanWorkspace(root, { sourceBoundary: "root" }) };
116
- await writePending(root, pending);
117
- }
66
+ const { declaration, errors } = await readDeclaration(root);
67
+ if (errors.length && !errors[0].startsWith(".isomorph/integrations.json is missing"))
68
+ throw new CliError("DECLARATION_INVALID", `.isomorph/integrations.json is invalid: ${errors[0]}`);
118
69
  // Link first: the app's identity lands in `.isomorph/kit.lock.json`, which is
119
70
  // one of the deployable files, so a report pinned after the link stays fresh
120
- // through the pre-flight and a refusal there does not cost a second gate run.
121
- if (!pending?.uploaded && options.integrations)
122
- await ensureLinkedApp(root, options.integrations.governance, tenantId, options.integrations.bundle);
71
+ // through the checks and the package is built from the linked tree.
72
+ const appId = await ensureLinkedApp(root, governance, tenantId, bundle);
123
73
  // The app's own checks are the cheapest gate Isomorph has and the most
124
74
  // expensive one to discover in the cloud. Measured: a builder shipped without
125
75
  // running `isomorph check`, the retained checks no longer matched the code,
@@ -127,133 +77,95 @@ async function runDeploy(rootArg, client, output, tenantId, options = {}) {
127
77
  // discovery — for a mismatch `isomorph check` names locally in seconds. A
128
78
  // report that passed for these exact bytes is trusted as it stands; anything
129
79
  // else runs the checks here, once, before the tree is read into memory and
130
- // long before any grant lookup, operation or upload. The gate writes into
131
- // `.isomorph/` as it runs, so the tree is scanned only after it.
132
- if (!pending?.uploaded)
133
- await ensureChecksPassed(root, output, options);
134
- const graph = pending?.graph ?? await scanWorkspace(root, { sourceBoundary: "root" });
135
- if (!graph.deploymentScope.includedFiles.length)
136
- throw new CliError("PREFLIGHT_EMPTY", "The selected app boundary contains no eligible files.");
137
- if (graph.deploymentScope.includedFiles.some(isProhibitedSecretPath))
138
- throw new CliError("PREFLIGHT_SECRET_PATH", "The selected app boundary contains a prohibited secret file.");
139
- const files = pending?.uploaded ? [] : await Promise.all(graph.deploymentScope.includedFiles.map(async (path) => ({ path, content: new Uint8Array(await readFile(resolve(root, path))) })));
140
- output(`Isomorph found ${graph.deploymentScope.includedFiles.length} app files.`);
141
- // A declared connection IT has not approved, a company whose AI setup is not
142
- // ready for an app that calls it, or a CLI the platform no longer accepts would
143
- // only park the deployment after the save: ask governance once, here, before
144
- // any operation exists, and refuse with every blocker at once.
145
- const kitAppId = pending?.appId ?? (options.integrations ? await assertDeployReady(root, options.integrations.governance, tenantId, options.integrations.bundle, "preview", output, await appCallsAi(root)) : options.appId);
146
- // A `CliError` is Isomorph's own answer (`AUTH_REQUIRED` after two 401s, a
147
- // JSON-RPC refusal) and reaches the builder as it is; only a plain Error from
148
- // the transport — Isomorph out of reach — is "not started". Reporting a
149
- // refused sign-in as unreachable sent an agent down a worse path (2026-09-14).
150
- try {
151
- await client.initialize();
152
- }
153
- catch (error) {
154
- if (error instanceof CliError)
155
- throw error;
156
- throw new CliError("NOT_STARTED", "Isomorph could not be reached before the operation started.");
157
- }
158
- let start;
159
- const linked = kitAppId ? { appId: kitAppId } : {};
160
- // TODO(kit-band-a): every call below sends `surface: "codex"`. `src/mcp.ts` validates `surface`
161
- // against its SURFACES enum (codex, claude_code, lovable, replit, v0, other), which has no "cli"
162
- // member yet; send "cli" once the server accepts it.
80
+ // long before any operation or upload. The gate writes into `.isomorph/` as
81
+ // it runs, so the tree is scanned only after it.
82
+ await ensureChecksPassed(root, output, options);
83
+ const { graph, files } = await readAppTree(root);
84
+ output(`Isomorph found ${files.length} app files.`);
85
+ // The package: built once, from the bytes read above; `archiveForManifest`
86
+ // re-reads every file and compares its sha256, so a tree that moved under us
87
+ // is refused here rather than uploaded. Its digest is the operation's
88
+ // identity on the server — the same package resumes the same operation.
89
+ const manifest = await createSourceManifest({ tenantId, appId, operationId: LOCAL_MANIFEST_OPERATION, graphDigest: graph.graphDigest, files });
90
+ const archive = await archiveForManifest(root, manifest);
91
+ const note = await readDeployNote(root, tenantId);
92
+ if (note?.sha256 === archive.digest)
93
+ output(`Isomorph is continuing operation ${note.operationRef}; no new deployment was started.`);
94
+ // One call opens the operation — or resumes it — and runs the deploy
95
+ // pre-flight first: a declared connection IT has not approved, a company
96
+ // whose AI setup is not ready for an app that calls it, or a CLI the platform
97
+ // no longer accepts would only park the deployment after the save, so
98
+ // governance refuses here, with every blocker at once, before any operation
99
+ // exists. The reviewed profile rides on the same call.
100
+ const body = {
101
+ environment: "preview",
102
+ app: { name: profile.name, description: profile.description, audience: profile.audience },
103
+ package: { sha256: archive.digest, bytes: archive.body.byteLength, manifest: { schema: "isomorph.source-package-manifest/1.0", files: manifest.files.map(file => ({ path: file.path, size: file.bytes, sha256: file.sha256 })) } },
104
+ graph, cliVersion: CLI_VERSION, kitBundleVersion: bundle.kitVersion, declaration, callsAi: await appCallsAi(root)
105
+ };
106
+ let opened;
163
107
  try {
164
- start = pending ? { operation: { operationId: pending.operationRef } } : structured(await client.call("isomorph_start_productionization", { appPath: basename(root), appName: basename(root), surface: "codex", ...linked }));
108
+ opened = await governance.startDeployment(appId, body);
165
109
  }
166
110
  catch (error) {
167
- if (error instanceof CliError)
168
- throw error;
169
- throw new CliError("START_UNCONFIRMED", "Isomorph did not confirm the start response. Run deploy again to recover the app operation before starting another deployment.");
111
+ if (error instanceof CliError && error.code === "DEPLOY_BLOCKED")
112
+ throw deployBlocked(error.details?.blockers, "preview", output);
113
+ // An older platform has no deployment route: say so rather than "HTTP_404", and that nothing was started.
114
+ if (error instanceof CliError && error.code === "HTTP_404")
115
+ throw new CliError("DEPLOY_ROUTE_UNAVAILABLE", "This company's Isomorph does not have the kit deployment route yet. No deployment was started.", undefined, "Ask your Isomorph administrator to update the platform.", undefined, { layer: "governance" });
116
+ if (error instanceof CliError && error.code === "DEPLOY_IN_FLIGHT") {
117
+ const inFlight = typeof error.details?.operationRef === "string" ? error.details.operationRef : undefined;
118
+ throw new CliError(error.code, error.message, inFlight, inFlight ? `Follow it with \`${continueCommand(inFlight)}\`; deploy again once it has settled.` : undefined, undefined, { layer: "governance" });
119
+ }
120
+ throw error;
170
121
  }
171
- const operationRef = start.operation?.operationId;
172
- if (!operationRef)
173
- throw new CliError("START_UNCONFIRMED", "Isomorph did not return the operation reference. Recover the app operation before starting another deployment.");
174
- pending ??= { tenantId, endpoint: client.endpoint, operationRef, sourceDigest: (await sourceDigest(root)).digest, uploaded: false, graph, ...(kitAppId ? { appId: kitAppId } : {}) };
175
- await writePending(root, pending);
176
- output(`Isomorph operation saved. Continue with isomorph status --operation ${operationRef} --wait --json.`);
122
+ const { operationRef } = opened;
123
+ await writeDeployNote(root, { schema: "isomorph.deploy-note/1.0", tenantId, operationRef, sha256: archive.digest });
124
+ if (opened.resumed && note?.sha256 !== archive.digest)
125
+ output(`Isomorph is continuing operation ${operationRef}; no new deployment was started.`);
126
+ output(`Isomorph operation saved. Continue with ${continueCommand(operationRef)}.`);
127
+ const fetch = statusFetch(governance, appId, operationRef);
177
128
  try {
178
- let appId = pending.appId;
179
- if (!pending.uploaded && !pending.uploadCompletion) {
180
- const submitted = structured(await client.call("isomorph_submit_application_graph", { operationId: operationRef, surface: "codex", graph, ...linked }));
181
- if (submitted.nextTool && !matchesTool(submitted.nextTool, "isomorph_prepare_source_upload"))
182
- throw new CliError("UNSUPPORTED_NEXT_STEP", "Isomorph returned an unsupported next step.", operationRef);
183
- appId = typeof submitted.appId === "string" ? submitted.appId.trim() : "";
184
- if (!appId)
185
- throw new CliError("APP_IDENTITY_MISSING", "Isomorph did not return the app identity for this operation.", operationRef);
186
- if (kitAppId && appId !== kitAppId)
187
- throw new CliError("APP_IDENTITY_MISMATCH", "Isomorph returned a different app identity than the one linked in .isomorph/kit.lock.json.", operationRef);
188
- pending = { ...pending, appId };
189
- await writePending(root, pending);
190
- if (!pending.appSetupConfirmed) {
191
- // An empty description defers to Isomorph's suggestion; a blank name never reaches here (readAppProfile).
192
- await confirmProfile(client, operationRef, { displayName: profile.name, ...(profile.description ? { description: profile.description } : {}) }, output);
193
- await confirmAudience(client, operationRef, profile.audience, output);
194
- pending = { ...pending, appSetupConfirmed: true };
195
- await writePending(root, pending);
196
- }
197
- const manifest = await createSourceManifest({ tenantId, appId, operationId: operationRef, graphDigest: graph.graphDigest, files });
198
- const preliminaryArchive = await archiveForManifest(root, manifest);
199
- const prepared = structured(await client.call("isomorph_prepare_source_upload", { operationId: operationRef, appId, surface: "codex", graph, format: "zip", filename: `${appId}.zip`, compressedBytes: preliminaryArchive.body.byteLength, manifest: { schema: "isomorph.source-package-manifest/1.0", files: manifest.files.map(file => ({ path: file.path, size: file.bytes, sha256: file.sha256 })) } }));
200
- if (!prepared.acceptedManifest || !prepared.sourceUpload)
201
- throw new CliError("UPLOAD_CONTRACT_MISSING", "Isomorph did not return a safe upload contract.", operationRef);
202
- if (prepared.nextAction?.tool && !matchesTool(prepared.nextAction.tool, "isomorph_complete_source_upload"))
203
- throw new CliError("UNSUPPORTED_NEXT_STEP", "Isomorph returned an unsupported next step.", operationRef);
204
- const archive = await archiveForManifest(root, prepared.acceptedManifest);
205
- // The canonical identity lands in kit.lock so a later `integrations request` reuses it instead
206
- // of linking a second app — written here, at the first moment it is safe to write it, and never
207
- // earlier. `.isomorph/kit.lock.json` is one of the app's own deployable files; the package is
208
- // built from bytes read at the top of this run, and `archiveForManifest` re-reads every file and
209
- // compares its sha256 before uploading. Recording the id before that read made the CLI refuse
210
- // its own edit — "The app changed while it was being packaged (.isomorph/kit.lock.json)" —
211
- // deterministically on the first deployment of every new app, because `recordKitAppId` is a
212
- // no-op once the id matches, so only a lock that has no id yet changes. Re-running succeeded and
213
- // hid the cause (2026-09-15). Both reads are done by this line and the archive is in memory, so
214
- // the app is still linked before anything can fail on the network.
215
- await recordKitAppId(root, appId, tenantId);
216
- const parts = await putMultipart(prepared.sourceUpload, archive.body, (sent, total) => output(`Isomorph is uploading the app package (${Math.floor(sent * 100 / total)}%).`));
217
- pending = { ...pending, uploadCompletion: { intentId: prepared.sourceUpload.intentId, parts, clientSha256: archive.digest } };
218
- await writePending(root, pending);
219
- }
220
- if (!pending.uploaded && pending.uploadCompletion) {
221
- await client.call("isomorph_complete_source_upload", { operationId: operationRef, ...pending.uploadCompletion });
222
- pending = { ...pending, uploaded: true };
223
- await writePending(root, pending);
129
+ let status = opened.status;
130
+ if (opened.upload) {
131
+ const parts = await putMultipart(opened.upload, archive.body, (sent, total) => output(`Isomorph is uploading the app package (${Math.floor(sent * 100 / total)}%).`));
132
+ status = (await governance.completePackage(appId, operationRef, { intentId: opened.upload.intentId, parts, clientSha256: archive.digest })).status;
133
+ // From here the server carries the deployment on by itself: it inspects the
134
+ // package, copies the app to the approved company code system (nothing is
135
+ // deployed or released by that), verifies the saved copy, and deploys.
224
136
  output("Isomorph uploaded the secure app package and is inspecting it.");
225
137
  }
226
- if (!appId)
227
- throw new CliError("APP_IDENTITY_MISSING", "The saved operation has no app identity.", operationRef);
228
- // The lock now carries the app id, so the tree differs from the one this operation was opened
229
- // against; re-pin the digest a resumed run compares with.
230
- if (pending.sourceDigest !== (await sourceDigest(root)).digest) {
231
- pending = { ...pending, sourceDigest: (await sourceDigest(root)).digest };
232
- await writePending(root, pending);
138
+ else
139
+ output("Isomorph already has this app package and is continuing from where it stood.");
140
+ let summary;
141
+ if (options.waitForDeployment === false) {
142
+ // Not followed: what the server has, and the one command that follows it. Neither a success nor a failure yet.
143
+ summary = { ...outcomeIfSettled(status, operationRef), outcome: "running", nextStep: runningLine(status, 0, operationRef) };
233
144
  }
234
- if (!prior?.sourceSave)
235
- await waitForIntake(client, operationRef);
236
- let execution = prior?.sourceSave?.status && prior.sourceSave.status !== "AWAITING_APPROVAL" ? { status: prior.sourceSave.status } : await execute(client, operationRef, appId, graph);
237
- if (execution.status === "APPROVAL_REQUIRED" || execution.approvalRequiredBeforeExternalAction === true) {
238
- output(`Isomorph is copying ${graph.deploymentScope.includedFiles.length} app files to the approved company code system. Nothing is deployed or released by that.`);
239
- execution = await execute(client, operationRef, appId, graph, { schema: "isomorph.stage-approval/1.0", step: "save_source_baseline", approved: true, approvedByUser: true, userApprovalText: SAVE_APPROVAL, userVisibleProgress: SAVE_APPROVAL, nextStepSummary: "Isomorph can safely save the app.", dataOrActions: ["Save the app in the company code system."] });
145
+ else {
146
+ try {
147
+ summary = await follow(fetch, operationRef, output, options.waitOptions);
148
+ }
149
+ catch (error) {
150
+ if (error instanceof CliError)
151
+ throw error;
152
+ throw new CliError("DEPLOYMENT_STATUS_UNAVAILABLE", `Isomorph could not follow the operation (${error instanceof Error ? error.message.replace(/https?:\/\/\S+|Bearer\s+\S+/gi, "").slice(0, 160) : "unknown error"}). Check again with \`isomorph status\`.`, operationRef);
153
+ }
154
+ output(outcomeLine(summary));
240
155
  }
241
- if (execution.status === "ADMIN_SETUP_REQUIRED")
242
- throw new CliError("ADMIN_SETUP_REQUIRED", "The company code connection needs one-time administrator setup.", operationRef);
243
- if (execution.status === "FAILED")
244
- throw new CliError("FAILED", "Isomorph could not save the app.", operationRef);
245
- if (execution.status === "RETRYABLE_FAILURE")
246
- throw new CliError("RETRYABLE_FAILURE", "Isomorph could not complete the save yet. Resume this operation safely.", operationRef);
247
- const evidence = await waitForSave(client, operationRef, graph, execution, output);
248
- const report = structured(await client.call("isomorph_build_verification_report", { operationId: operationRef, appId, surface: "codex", graph, sourceControlEvidence: evidence.sourceControlEvidence, runnerEvidence: evidence.runnerEvidence }));
249
- output("Isomorph verified the saved app.");
250
- const verification = safeVerificationResult(report, evidence);
251
- if (options.waitForDeployment === false)
252
- return { cliVersion: CLI_VERSION, operationRef, result: verification };
253
- return finishDeployment(client, operationRef, output, options, verification);
156
+ return { cliVersion: CLI_VERSION, operationRef, result: summary };
254
157
  }
255
158
  catch (error) {
256
- throw await operationFailure(client, operationRef, error);
159
+ throw await operationFailure(fetch, operationRef, error);
160
+ }
161
+ }
162
+ /** The status as `--no-wait` hands it back: a settled outcome is named, an in-flight one is summarised as it stands. */
163
+ function outcomeIfSettled(status, operationRef) {
164
+ try {
165
+ return outcomeFor(status, operationRef);
166
+ }
167
+ catch {
168
+ return summarize(status);
257
169
  }
258
170
  }
259
171
  /** What a builder is told to do when the checks failed here: the detail is in the report; fix, then run again. */
@@ -330,39 +242,9 @@ async function ensureChecksPassed(root, output, options) {
330
242
  }
331
243
  /** The checks as `isomorph check` runs them, with the bundle this deploy carries. */
332
244
  function defaultRunChecks(options) {
333
- const bundle = options.integrations?.bundle ?? EMBEDDED_KIT_BUNDLE;
245
+ const bundle = options.bundle ?? EMBEDDED_KIT_BUNDLE;
334
246
  return (root, output) => runChecks(root, { run: options.checks?.run ?? runCommand, bundle, output });
335
247
  }
336
- async function waitForIntake(client, operationRef) {
337
- for (let attempt = 0; attempt < 60; attempt += 1) {
338
- const status = structured(await client.call("isomorph_get_operation_status", { operationId: operationRef, waitSeconds: 3 }));
339
- if (status.operation?.stage === "awaiting-approval" || status.sourceSave?.status === "QUEUED")
340
- return;
341
- if (status.operation?.stage === "failed")
342
- throw intakeRejection(status, operationRef);
343
- await new Promise(resolve => setTimeout(resolve, 1_000));
344
- }
345
- throw new CliError("INSPECTION_TIMEOUT", "Isomorph is still inspecting the app package. Resume this operation safely.", operationRef);
346
- }
347
- /**
348
- * Intake does not merely refuse a package; it records why — a stable code, one
349
- * plain-English sentence, and the commands that fix it — on the operation. The
350
- * stage alone was all this used to read, so every refusal reached the maker as
351
- * the same sentence and the reason stayed on the server. Say what the server
352
- * said, and keep the generic line only for an operation that failed without
353
- * any detail at all.
354
- */
355
- function intakeRejection(status, operationRef) {
356
- return refusal(pickSourceFailure(status), "PACKAGE_REJECTED", "Isomorph could not safely accept this app package.", operationRef);
357
- }
358
- /**
359
- * The one way this file turns "the server refused" into a failure the builder
360
- * can act on: the server's own code, sentence and fix where it gave them, the
361
- * generic pair only where it gave nothing, and the operation reference always.
362
- */
363
- function refusal(failure, fallbackCode, fallbackMessage, operationRef) {
364
- return new CliError(failure?.code ?? fallbackCode, failure?.message ?? fallbackMessage, operationRef, failure?.remediationHint, undefined, { layer: "governance" });
365
- }
366
248
  /**
367
249
  * Why the whole operation stopped. The outer catch used to answer this with one
368
250
  * fixed sentence for everything that was not already a `CliError`, which is
@@ -372,14 +254,14 @@ function refusal(failure, fallbackCode, fallbackMessage, operationRef) {
372
254
  *
373
255
  * 1. A `CliError` already carries a reason; keep it, and only make sure it
374
256
  * names the operation, since by here one exists.
375
- * 2. Otherwise the thrown value's own detail, then the refusal intake recorded
376
- * on the operation, and only then the generic line.
257
+ * 2. Otherwise the thrown value's own detail, then the refusal the server
258
+ * recorded on the operation, and only then the generic line.
377
259
  */
378
- async function operationFailure(client, operationRef, error) {
260
+ async function operationFailure(fetch, operationRef, error) {
379
261
  if (error instanceof CliError)
380
262
  return error.operationRef ? error : withOperation(error, operationRef);
381
263
  const detail = errorDetail(error);
382
- return refusal(detail ?? await recordedFailure(client, operationRef), "OPERATION_FAILED", "Isomorph could not complete the started operation.", operationRef);
264
+ return recordedRefusal(detail ?? await recordedFailure(fetch), "OPERATION_FAILED", "Isomorph could not complete the started operation.", operationRef);
383
265
  }
384
266
  /**
385
267
  * What the thrown value itself says. A refusal reaches us as a bare `Error`
@@ -404,75 +286,18 @@ function text(value) {
404
286
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
405
287
  }
406
288
  /**
407
- * The refusal intake recorded on the operation, if any. Best-effort: the reason
408
- * the builder gets must never depend on a second call succeeding, so a probe
409
- * that fails simply contributes nothing.
289
+ * The refusal the server recorded on the operation, if any. Best-effort: the
290
+ * reason the builder gets must never depend on a second call succeeding, so a
291
+ * probe that fails simply contributes nothing.
410
292
  */
411
- async function recordedFailure(client, operationRef) {
293
+ async function recordedFailure(fetch) {
412
294
  try {
413
- return pickSourceFailure(structured(await client.call("isomorph_get_operation_status", { operationId: operationRef, waitSeconds: 0 })));
295
+ return pickSourceFailure(await fetch(0));
414
296
  }
415
297
  catch {
416
298
  return undefined;
417
299
  }
418
300
  }
419
- async function execute(client, operationRef, appId, graph, approval) {
420
- const args = { operationId: operationRef, appId, surface: "codex", graph, action: "save_baseline", mode: "EXECUTE" };
421
- if (approval)
422
- args.approval = approval;
423
- return structured(await client.call("isomorph_execute_source_control", args)).sourceControlExecution ?? {};
424
- }
425
- async function waitForSave(client, operationRef, graph, first, output) {
426
- let waitSeconds = boundedWait(first.nextAction?.waitSeconds);
427
- for (let attempt = 0; attempt < 40; attempt += 1) {
428
- const status = structured(await client.call("isomorph_get_operation_status", { operationId: operationRef, waitSeconds }));
429
- const terminal = status.sourceSave?.status;
430
- if (terminal === "SUCCEEDED" || terminal === "DUPLICATE") {
431
- const sourceControlEvidence = status.sourceControlEvidence ?? status.sourceSave?.sourceControlEvidence;
432
- const runnerEvidence = status.runnerEvidence ?? status.sourceSave?.runnerEvidence;
433
- if (!sourceControlEvidence || !runnerEvidence)
434
- throw new CliError("EVIDENCE_MISSING", "Isomorph did not return managed save evidence.", operationRef);
435
- return { sourceControlEvidence, runnerEvidence };
436
- }
437
- if (terminal === "FAILED")
438
- throw new CliError("FAILED", "Isomorph could not save the app.", operationRef);
439
- if (terminal === "RETRYABLE_FAILURE")
440
- throw new CliError("RETRYABLE_FAILURE", "Isomorph could not complete the save yet. Resume this operation safely.", operationRef);
441
- waitSeconds = boundedWait(status.nextAction?.waitSeconds ?? status.waitSeconds);
442
- output("Isomorph is still saving the app.");
443
- }
444
- throw new CliError("RETRYABLE_FAILURE", "Isomorph could not finish the save within the safe retry window.", operationRef);
445
- }
446
- function boundedWait(value) {
447
- return Math.min(15, Math.max(0, Number.isFinite(value) ? Math.floor(value) : 15));
448
- }
449
- /** Isomorph publishes and accepts exactly one tool spelling, `isomorph_<verb>`; a next step is compared as it is. */
450
- function matchesTool(actual, expected) {
451
- return actual === expected;
452
- }
453
- function safeVerificationResult(value, statusEvidence) {
454
- if (!value || typeof value !== "object" || Array.isArray(value))
455
- return { verification: "completed" };
456
- const record = value;
457
- const decision = record.decision && typeof record.decision === "object" && !Array.isArray(record.decision)
458
- ? record.decision
459
- : undefined;
460
- return {
461
- verification: typeof decision?.status === "string"
462
- ? decision.status
463
- : typeof record.status === "string"
464
- ? record.status
465
- : "completed",
466
- ...(typeof decision?.assuranceLevel === "string" ? { assuranceLevel: decision.assuranceLevel } : {}),
467
- ...(typeof record.protectedUrl === "string" ? { protectedUrl: record.protectedUrl } : {}),
468
- ...((record.sourceControlEvidence ?? statusEvidence?.sourceControlEvidence) && typeof (record.sourceControlEvidence ?? statusEvidence?.sourceControlEvidence) === "object" && !Array.isArray(record.sourceControlEvidence ?? statusEvidence?.sourceControlEvidence)
469
- ? { sourceControlEvidence: record.sourceControlEvidence ?? statusEvidence?.sourceControlEvidence }
470
- : {}),
471
- ...((record.runnerEvidence ?? statusEvidence?.runnerEvidence) && typeof (record.runnerEvidence ?? statusEvidence?.runnerEvidence) === "object" && !Array.isArray(record.runnerEvidence ?? statusEvidence?.runnerEvidence)
472
- ? { runnerEvidence: record.runnerEvidence ?? statusEvidence?.runnerEvidence }
473
- : {})
474
- };
475
- }
476
301
  function outcomeLine(summary) {
477
302
  switch (summary.outcome) {
478
303
  case "live": return `Isomorph deployed the app. Protected preview: ${summary.deployment?.protectedUrl ?? ""}`.trim();
@@ -495,57 +320,30 @@ export async function appCallsAi(root) {
495
320
  const callsites = report?.gate?.inventory?.aiCallsites;
496
321
  return Array.isArray(callsites) && callsites.length > 0;
497
322
  }
498
- async function finishDeployment(client, operationRef, output, options, verification = {}) {
499
- let summary;
500
- try {
501
- summary = options.waitForDeployment === false ? outcomeFor(await fetchStatus(client, operationRef), operationRef) : await follow(client, operationRef, output, options.waitOptions);
502
- }
503
- catch (error) {
504
- if (error instanceof CliError)
505
- throw withOperation(error, operationRef);
506
- throw new CliError("DEPLOYMENT_STATUS_UNAVAILABLE", `Isomorph could not follow the existing operation (${error instanceof Error ? error.message.replace(/https?:\/\/\S+|Bearer\s+\S+/gi, "").slice(0, 160) : "unknown error"}). Check again with \`isomorph status\`.`, operationRef);
507
- }
508
- output(outcomeLine(summary));
509
- return { cliVersion: CLI_VERSION, operationRef, result: { ...verification, ...summary } };
510
- }
511
323
  /** The three values as the command prints them before it starts. */
512
324
  export function describeProfile(profile) {
513
- return [`App name: ${profile.name}`, `Description: ${profile.description || "(none yet; Isomorph suggests one)"}`, `Audience: ${profile.audience.length ? profile.audience.join(", ") : "only you"}`];
514
- }
515
- function operationFinished(status) {
516
- if (status.production)
517
- return ["LIVE", "FAILED", "MANUAL_RECOVERY"].includes(status.production.state);
518
- if (status.deployment)
519
- return ["LIVE", "FAILED", "STALE", "RETRYABLE_FAILURE"].includes(status.deployment.state);
520
- return status.operation?.stage === "failed";
325
+ return [`App name: ${profile.name}`, `Description: ${profile.description || "(none yet)"}`, `Audience: ${profile.audience.length ? profile.audience.join(", ") : "only you"}`];
521
326
  }
522
- async function readPending(root, tenantId, endpoint) {
523
- let raw;
327
+ /** The last deployment this folder opened for this company, or nothing: a note that cannot be read is no note. */
328
+ export async function readDeployNote(root, tenantId) {
524
329
  try {
525
- raw = await readFile(resolve(root, ".isomorph/local/productionise.json"), "utf8");
330
+ const record = JSON.parse(await readFile(kitPaths(root).deployNote, "utf8"));
331
+ return record.schema === "isomorph.deploy-note/1.0" && record.tenantId === tenantId && typeof record.operationRef === "string" && typeof record.sha256 === "string" ? record : undefined;
526
332
  }
527
- catch (error) {
528
- if (error.code === "ENOENT")
529
- return undefined;
530
- throw error;
333
+ catch {
334
+ return undefined;
531
335
  }
532
- const record = JSON.parse(raw);
533
- if (!record.operationRef || !record.graph || typeof record.uploaded !== "boolean" || !record.sourceDigest)
534
- throw new CliError("OPERATION_RECORD_INVALID", "The saved deployment reference cannot be read. Recover the operation from Isomorph before starting another.");
535
- return record.tenantId === tenantId && record.endpoint === endpoint ? record : undefined;
536
336
  }
537
- async function writePending(root, record) {
538
- const path = resolve(root, ".isomorph/local/productionise.json");
337
+ async function writeDeployNote(root, note) {
338
+ const path = kitPaths(root).deployNote;
539
339
  try {
540
340
  await mkdir(dirname(path), { recursive: true });
541
341
  const temporary = `${path}.${process.pid}.tmp`;
542
- await writeFile(temporary, JSON.stringify(record), { mode: 0o600 });
342
+ await writeFile(temporary, `${JSON.stringify(note, null, 2)}\n`, { mode: 0o600 });
543
343
  await rename(temporary, path);
544
344
  }
545
- catch {
546
- throw new CliError("OPERATION_RECORD_WRITE_FAILED", "Isomorph started the operation but could not save its local reference. Continue with isomorph status using this operation reference.", record.operationRef);
547
- }
345
+ catch { /* The server owns resume; the note only saves the builder typing the reference. */ }
548
346
  }
549
347
  function withOperation(error, operationRef) {
550
- return new CliError(error.code, error.message, operationRef, error.remediationHint, error.result, { layer: error.layer, ...(error.paths ? { paths: error.paths } : {}) });
348
+ return new CliError(error.code, error.message, operationRef, error.remediationHint, error.result, { layer: error.layer, ...(error.paths ? { paths: error.paths } : {}), ...(error.details ? { details: error.details } : {}) });
551
349
  }
@@ -32,7 +32,7 @@ CREATE POLICY notes_owner ON notes
32
32
 
33
33
  Shared-read, owner-write: the same, plus \`CREATE POLICY posts_read ON posts FOR SELECT USING (true);\`.
34
34
 
35
- \`.isomorph/checks/\` is generated by \`isomorph check\`, which adds and deletes files as the app changes; never write or edit it by hand.
35
+ \`.isomorph/checks/\` is generated by every check and not committed; your own checks go in \`tests/*.mjs\`. A refusal for uncovered surface names the file to write.
36
36
 
37
37
  ## Files and realtime
38
38
 
@@ -99,7 +99,7 @@ AI goes through \`isomorph.ai\` only: one \`isomorph.ai.chat({ messages, maxToke
99
99
  const reply = await isomorph.ai.chat({ messages: [{ role: "user", content: \`Summarise these notes in three lines:\\n\${notes.map(n => n.title).join("\\n")}\` }], maxTokens: 200 });
100
100
  \`\`\`
101
101
 
102
- The starter calls no AI; add the one call when the person asks. \`isomorph check\` writes \`.isomorph/checks/ai-journey.mjs\` for it (and deletes it when the call goes): locally the journey makes a real governed call through the development route with your \`isomorph login\`; in the pipeline it is answered by a canned completion and reported as not tested.`,
102
+ The starter calls no AI; add the one call when the person asks. The check's generated ai journey makes a real governed call locally through the development route with your \`isomorph login\`; in the pipeline it is answered by a canned completion and reported as not tested.`,
103
103
  jobs: `# Scheduled jobs
104
104
 
105
105
  Scheduled work lives only in \`jobs/<name>.ts\`: export one literal UTC cron as \`schedule\` and one default async handler. Never use \`setInterval\`, an effect or a browser timer as a scheduler; Isomorph runs the same declaration automatically after deployment, and the deployed Kubernetes schedule owns the real clock. Test it immediately: start \`isomorph dev\`, then \`isomorph jobs run <name> --app-root . --scheduled-at <matching-UTC-time> --json\` — local data and safe fixtures, nothing sent. Use \`--real\` only when the person explicitly asks to test the company action now and the exact action has development approval.