@isomorph.ai/cli 0.3.4 → 0.4.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,22 +1,28 @@
1
1
  import { homedir } from "node:os";
2
2
  import { basename, dirname, parse, resolve } from "node:path";
3
- import { readdir, readFile, mkdir, rename, writeFile } from "node:fs/promises";
3
+ import { readFile, mkdir, rename, writeFile } from "node:fs/promises";
4
4
  import { scanWorkspace } from "../../../src/analyzer.js";
5
5
  import { createSourceManifest } from "../../../src/source-intake.js";
6
6
  import { structured } from "./remote-mcp-client.js";
7
7
  import { archiveForManifest, putMultipart } from "./upload.js";
8
8
  import { CliError } from "./output.js";
9
- import { confirmAudience, confirmProfile, follow, getAppSetup, pickSourceFailure, readLine, fetchStatus, outcomeFor } from "./operations.js";
9
+ import { confirmAudience, confirmProfile, follow, pickSourceFailure, fetchStatus, outcomeFor } from "./operations.js";
10
10
  import { CLI_VERSION } from "./version.js";
11
11
  import { isProhibitedSecretPath } from "../../../src/secret-paths.js";
12
- import { assertAiReady, assertPreviewIntegrationsReady } from "./integrations.js";
13
- import { describeSourceChange, kitPaths, readKitLock, recordKitAppId, sourceDigest } from "./kit.js";
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. */
12
+ import { assertDeployReady, ensureLinkedApp } from "./integrations.js";
13
+ import { describeSourceChange, readAppProfile, readKitLock, recordKitAppId, sourceDigest } from "./kit.js";
14
+ import { CHECKS_FAILED_HINT, checksFailedMessage, FLOW_CHECK, readReport, runChecks } from "./check.js";
15
+ import { runCommand } from "./local-runtime.js";
16
+ import { EMBEDDED_KIT_BUNDLE } from "./kit-bundle.js";
17
+ /**
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.
22
+ */
17
23
  export const SAVE_APPROVAL = "Approved.";
18
- export async function productionise(rootArg, client, output, tenantId, includePaths = [], options = {}) {
19
- const deployment = await deploy(rootArg, client, output, tenantId, includePaths, options);
24
+ export async function deploy(rootArg, client, output, tenantId, options = {}) {
25
+ const deployment = await runDeploy(rootArg, client, output, tenantId, options);
20
26
  await reportSession(rootArg, deployment.operationRef, options);
21
27
  return deployment;
22
28
  }
@@ -42,11 +48,20 @@ async function reportSession(rootArg, operationRef, options) {
42
48
  }
43
49
  catch { /* never surfaced: the deployment is what the builder asked for. */ }
44
50
  }
45
- async function deploy(rootArg, client, output, tenantId, includePaths = [], options = {}) {
51
+ async function runDeploy(rootArg, client, output, tenantId, options = {}) {
46
52
  const root = resolve(rootArg);
47
53
  output(`Isomorph is checking ${basename(root)}.`);
48
54
  if (root === parse(root).root || root === resolve(homedir()))
49
55
  throw new CliError("PREFLIGHT_APP_ROOT", "The selected app boundary is unsafe.");
56
+ // Only a kit app deploys from here: the checks, the gate and the profile all
57
+ // live in `.isomorph/`. Anything else deploys from the console.
58
+ if (!(await readKitLock(root)))
59
+ throw new CliError("KIT_REQUIRED", "This folder is not an Isomorph app: it has no .isomorph/kit.lock.json.", undefined, "Run `isomorph init --app-root .` first.");
60
+ // The three values the person confirmed, printed so the transcript shows what
61
+ // was recorded; recorded on the operation before anything is uploaded.
62
+ const profile = await readAppProfile(root);
63
+ for (const line of describeProfile(profile))
64
+ output(line);
50
65
  let pending = await readPending(root, tenantId, client.endpoint);
51
66
  let prior;
52
67
  if (pending) {
@@ -72,7 +87,7 @@ async function deploy(rootArg, client, output, tenantId, includePaths = [], opti
72
87
  throw new CliError("RECOVERY_UNAVAILABLE", "This company needs Isomorph's deployment recovery update before shipping. No new deployment was started.");
73
88
  if (recovered.operation?.operationId && !operationFinished(recovered)) {
74
89
  prior = recovered;
75
- 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", ...(includePaths.length ? { includePaths } : {}) }), appId };
90
+ 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 };
76
91
  await writePending(root, pending);
77
92
  }
78
93
  }
@@ -86,55 +101,34 @@ async function deploy(rootArg, client, output, tenantId, includePaths = [], opti
86
101
  if (pending && !pending.uploaded && pending.sourceDigest !== (await sourceDigest(root)).digest) {
87
102
  throw new CliError("UPLOAD_SOURCE_CHANGED", "The app changed before its upload finished. Restore the submitted files before continuing this operation.", pending.operationRef);
88
103
  }
89
- const scan = () => scanWorkspace(root, { sourceBoundary: "root", ...(includePaths.length ? { includePaths } : {}) });
90
- let graph = pending?.graph ?? await scan();
104
+ // Link first: the app's identity lands in `.isomorph/kit.lock.json`, which is
105
+ // one of the deployable files, so a report pinned after the link stays fresh
106
+ // through the pre-flight and a refusal there does not cost a second gate run.
107
+ if (!pending?.uploaded && options.integrations)
108
+ await ensureLinkedApp(root, options.integrations.governance, tenantId, options.integrations.bundle);
109
+ // The app's own checks are the cheapest gate Isomorph has and the most
110
+ // expensive one to discover in the cloud. Measured: a builder shipped without
111
+ // running `isomorph check`, the retained checks no longer matched the code,
112
+ // and the pipeline's `flow` gate refused it twice — 2m23s and 2m12s per
113
+ // discovery — for a mismatch `isomorph check` names locally in seconds. A
114
+ // report that passed for these exact bytes is trusted as it stands; anything
115
+ // else runs the checks here, once, before the tree is read into memory and
116
+ // long before any grant lookup, operation or upload. The gate writes into
117
+ // `.isomorph/` as it runs, so the tree is scanned only after it.
118
+ if (!pending?.uploaded)
119
+ await ensureChecksPassed(root, output, options);
120
+ const graph = pending?.graph ?? await scanWorkspace(root, { sourceBoundary: "root" });
91
121
  if (!graph.deploymentScope.includedFiles.length)
92
122
  throw new CliError("PREFLIGHT_EMPTY", "The selected app boundary contains no eligible files.");
93
123
  if (graph.deploymentScope.includedFiles.some(isProhibitedSecretPath))
94
124
  throw new CliError("PREFLIGHT_SECRET_PATH", "The selected app boundary contains a prohibited secret file.");
95
- // The app's own retained checks are the cheapest gate Isomorph has and the
96
- // most expensive one to discover in the cloud. Measured: a builder shipped
97
- // without running `isomorph check`, the retained checks no longer matched the
98
- // code, and the pipeline's `flow` gate refused it twice — 2m23s and 2m12s per
99
- // discovery — for a mismatch `isomorph check` names locally in about five
100
- // seconds. Refuse here, before the tree is even read into memory, and long
101
- // before any grant lookup, database replay, operation or upload.
102
- if (!pending?.uploaded)
103
- await assertChecksPassedForTree(root, output);
104
- const readTree = async () => pending?.uploaded ? [] : await Promise.all(graph.deploymentScope.includedFiles.map(async (path) => ({ path, content: new Uint8Array(await readFile(resolve(root, path))) })));
105
- let files = await readTree();
125
+ const files = pending?.uploaded ? [] : await Promise.all(graph.deploymentScope.includedFiles.map(async (path) => ({ path, content: new Uint8Array(await readFile(resolve(root, path))) })));
106
126
  output(`Isomorph found ${graph.deploymentScope.includedFiles.length} app files.`);
107
- // A declared connection IT has not approved yet would only park the
108
- // deployment after the save; file the request and refuse here, before any operation exists.
109
- const kitAppId = pending?.appId ?? (options.integrations ? await assertPreviewIntegrationsReady(root, options.integrations.governance, tenantId, options.integrations.bundle, output) : options.appId);
110
- // An app that calls governed AI needs the company's AI setup to pass the
111
- // deployment PLAN; ask governance now rather than discover it on the deployed button.
112
- if (!pending?.uploaded && options.integrations && kitAppId && await appCallsAi(root))
113
- await assertAiReady(kitAppId, "preview", options.integrations.governance, output);
114
- // A kit app the pipeline's gate would refuse (a table without RLS, an
115
- // operation no journey exercises, a cross-user leak) is refused here,
116
- // before the save, by the same gate with the same wording.
117
- const gate = options.kitGate === false ? undefined : options.kitGate ?? (options.integrations ? { bundle: options.integrations.bundle } : undefined);
118
- if (gate && !pending?.uploaded) {
119
- await preflightKitGate(root, gate.bundle, output, gate.run);
120
- // The gate AUTHORS `.isomorph/checks/*.mjs` — the retained journeys the cloud replays. On an app
121
- // that has never run `isomorph check` on its own they did not exist when the tree was scanned
122
- // above, so the package shipped without them and the cloud gate, which has no journeys to run,
123
- // refused every write the source performs as unexercised. Locally the same gate had just
124
- // generated and passed them, so the app looked ready and the refusal named `src/App.tsx` rather
125
- // than the missing files (2026-09-15). Re-read the tree the gate leaves behind.
126
- if (!pending) {
127
- graph = await scan();
128
- files = await readTree();
129
- }
130
- }
131
- // The journeys are the app's proof for the cloud gate; a package without the ones on disk is
132
- // refused there, minutes later, in terms that point at the app instead of the package.
133
- const packaged = new Set(graph.deploymentScope.includedFiles);
134
- const unpackaged = (await retainedChecks(root)).filter(name => !packaged.has(`.isomorph/checks/${name}`));
135
- if (!pending?.uploaded && unpackaged.length) {
136
- throw new CliError("CHECKS_NOT_PACKAGED", `The app package does not carry its retained check${unpackaged.length === 1 ? "" : "s"} (${nameList(unpackaged)}), so the deployment would have nothing to replay.`, undefined, "Run `isomorph check --app-root .`, then run this again.");
137
- }
127
+ // A declared connection IT has not approved, a company whose AI setup is not
128
+ // ready for an app that calls it, or a CLI the platform no longer accepts would
129
+ // only park the deployment after the save: ask governance once, here, before
130
+ // any operation exists, and refuse with every blocker at once.
131
+ const kitAppId = pending?.appId ?? (options.integrations ? await assertDeployReady(root, options.integrations.governance, tenantId, options.integrations.bundle, "preview", output, await appCallsAi(root)) : options.appId);
138
132
  // A `CliError` is Isomorph's own answer (`AUTH_REQUIRED` after two 401s, a
139
133
  // JSON-RPC refusal) and reaches the builder as it is; only a plain Error from
140
134
  // the transport — Isomorph out of reach — is "not started". Reporting a
@@ -149,13 +143,16 @@ async function deploy(rootArg, client, output, tenantId, includePaths = [], opti
149
143
  }
150
144
  let start;
151
145
  const linked = kitAppId ? { appId: kitAppId } : {};
146
+ // TODO(kit-band-a): every call below sends `surface: "codex"`. `src/mcp.ts` validates `surface`
147
+ // against its SURFACES enum (codex, claude_code, lovable, replit, v0, other), which has no "cli"
148
+ // member yet; send "cli" once the server accepts it.
152
149
  try {
153
150
  start = pending ? { operation: { operationId: pending.operationRef } } : structured(await client.call("isomorph_start_productionization", { appPath: basename(root), appName: basename(root), surface: "codex", ...linked }));
154
151
  }
155
152
  catch (error) {
156
153
  if (error instanceof CliError)
157
154
  throw error;
158
- throw new CliError("START_UNCONFIRMED", "Isomorph did not confirm the start response. Run productionise again to recover the app operation before starting another deployment.");
155
+ throw new CliError("START_UNCONFIRMED", "Isomorph did not confirm the start response. Run deploy again to recover the app operation before starting another deployment.");
159
156
  }
160
157
  const operationRef = start.operation?.operationId;
161
158
  if (!operationRef)
@@ -176,9 +173,10 @@ async function deploy(rootArg, client, output, tenantId, includePaths = [], opti
176
173
  throw new CliError("APP_IDENTITY_MISMATCH", "Isomorph returned a different app identity than the one linked in .isomorph/kit.lock.json.", operationRef);
177
174
  pending = { ...pending, appId };
178
175
  await writePending(root, pending);
179
- if (options.confirmedAppSetup && !pending.appSetupConfirmed) {
180
- await confirmProfile(client, operationRef, options.confirmedAppSetup, output);
181
- await confirmAudience(client, operationRef, options.confirmedAppSetup.audienceEmails, output);
176
+ if (!pending.appSetupConfirmed) {
177
+ // An empty description defers to Isomorph's suggestion; a blank name never reaches here (readAppProfile).
178
+ await confirmProfile(client, operationRef, { displayName: profile.name, ...(profile.description ? { description: profile.description } : {}) }, output);
179
+ await confirmAudience(client, operationRef, profile.audience, output);
182
180
  pending = { ...pending, appSetupConfirmed: true };
183
181
  await writePending(root, pending);
184
182
  }
@@ -223,11 +221,8 @@ async function deploy(rootArg, client, output, tenantId, includePaths = [], opti
223
221
  await waitForIntake(client, operationRef);
224
222
  let execution = prior?.sourceSave?.status && prior.sourceSave.status !== "AWAITING_APPROVAL" ? { status: prior.sourceSave.status } : await execute(client, operationRef, appId, graph);
225
223
  if (execution.status === "APPROVAL_REQUIRED" || execution.approvalRequiredBeforeExternalAction === true) {
226
- 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.`);
227
- const approval = await (options.readApproval ?? readLine)();
228
- if (approval !== SAVE_APPROVAL)
229
- throw new CliError("APPROVAL_NOT_GRANTED", "The Isomorph save was not approved. Approve copying the app files with --confirm-save instead.", operationRef);
230
- 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."] });
224
+ output(`Isomorph is copying ${graph.deploymentScope.includedFiles.length} app files to the approved company code system. Nothing is deployed or released by that.`);
225
+ 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."] });
231
226
  }
232
227
  if (execution.status === "ADMIN_SETUP_REQUIRED")
233
228
  throw new CliError("ADMIN_SETUP_REQUIRED", "The company code connection needs one-time administrator setup.", operationRef);
@@ -247,14 +242,8 @@ async function deploy(rootArg, client, output, tenantId, includePaths = [], opti
247
242
  throw await operationFailure(client, operationRef, error);
248
243
  }
249
244
  }
250
- /**
251
- * What a builder is told to do when the checks have not passed. `isomorph check`
252
- * exercises the retained journeys and the pipeline's operation-coverage gate
253
- * against the running app, so both commands are named: the app has to be up
254
- * before the checks that matter can run at all.
255
- */
256
- const RUN_THE_CHECKS = "Run `isomorph check --app-root .` (it starts the local Isomorph services itself when `isomorph dev` is not running). Fix whatever it reports, then run this again.";
257
- const FIX_THE_CHECKS = "Fix what the checks reported (the detail is in .isomorph/local/check-report.json), then run `isomorph check --app-root .` again.";
245
+ /** What a builder is told to do when the checks failed here: the detail is in the report; fix, then run again. */
246
+ const FIX_THE_CHECKS = "Fix what the checks reported, then run this again.";
258
247
  /**
259
248
  * The checks the deployment pipeline replays in the cloud: each retained
260
249
  * journey under its own `journey:<file>` name and the operation-coverage gate
@@ -265,71 +254,70 @@ const FIX_THE_CHECKS = "Fix what the checks reported (the detail is in .isomorph
265
254
  function isReplayedGate(name) {
266
255
  return name === FLOW_CHECK || name.startsWith("journey:");
267
256
  }
268
- /** The retained checks this app keeps, enumerated exactly as `isomorph check` and the pipeline enumerate them. */
269
- async function retainedChecks(root) {
270
- return (await readdir(kitPaths(root).checks).catch(() => [])).filter(name => /\.(mjs|js|cjs)$/.test(name)).sort();
271
- }
272
- /** At most three names, so one long list of journey files cannot push the reason itself past the report cap. */
273
- function nameList(names) {
274
- return names.length > 3 ? `${names.slice(0, 3).join(", ")} and ${names.length - 3} more` : names.join(", ");
257
+ /**
258
+ * The gates that matter, as a report records them: the replayed gates that did
259
+ * not pass (`not_run` is a skip, never a pass), or none.
260
+ */
261
+ function skippedGates(report) {
262
+ const checks = Array.isArray(report.checks) ? report.checks : [];
263
+ const notRun = checks.filter(check => isReplayedGate(check.name) && check.status !== "pass").map(check => check.name);
264
+ const flowRan = checks.some(check => check.name === FLOW_CHECK && check.status === "pass");
265
+ return notRun.length || flowRan ? notRun : [FLOW_CHECK];
275
266
  }
276
267
  /**
277
- * Refuses a tree whose own retained checks have not passed *for that tree*.
268
+ * Makes sure the checks have passed *for this tree*, running them when they
269
+ * have not.
278
270
  *
279
271
  * `isomorph check` writes `.isomorph/local/check-report.json` with the digest of
280
- * the source it ran against, so three different things have to be true before a
281
- * deployment is worth anyone's time: the checks ran, they ran against these
282
- * bytes, and the ones that matter actually ran rather than being skipped.
283
- *
284
- * Scope: an app that keeps no retained checks is not gated. This is not a
285
- * loophole — `.isomorph/checks/` is what the pipeline replays, and an app that
286
- * keeps none is refused there by the same coverage gate the moment its code
287
- * performs any operation. Gating it here would only refuse, with a fix it
288
- * cannot carry out, every app that never adopted the kit.
272
+ * the source it ran against. A report is trusted when three things are true:
273
+ * the checks ran, they ran against these bytes, and the ones that matter
274
+ * actually ran rather than being skipped. Then nothing is run again — the gate
275
+ * takes 30–60 s and its answer for the same bytes is the same. Any other state
276
+ * (no report, a different tree, a skipped gate) runs the same checks `isomorph
277
+ * check` runs and carries on in this invocation; a report that ran and failed
278
+ * is refused as it stands, since running it again changes nothing.
289
279
  *
290
280
  * The digest is recomputed by the same `sourceDigest` `isomorph check` used, so
291
281
  * a match means one function saw the same bytes twice and no parallel
292
- * implementation can drift into refusing a checked tree. It spans the whole app
293
- * boundary, so a `--include` submission is compared against the tree the checks
294
- * actually ran on: conservative where the two differ, never permissive.
282
+ * implementation can drift into refusing a checked tree.
295
283
  */
296
- async function assertChecksPassedForTree(root, output) {
297
- const retained = await retainedChecks(root);
298
- if (!retained.length)
299
- return;
300
- const plural = retained.length === 1 ? "" : "s";
301
- const report = await readReport(root);
302
- if (!report)
303
- throw new CliError("CHECKS_NOT_RUN", `This app keeps ${retained.length} check${plural} that say whether it still works, and ${retained.length === 1 ? "it has" : "they have"} never been run against this code. Isomorph does not deploy an app whose own checks have not passed.`, undefined, RUN_THE_CHECKS);
284
+ async function ensureChecksPassed(root, output, options) {
285
+ const previous = await readReport(root);
304
286
  const tree = await sourceDigest(root);
305
- if (report.sourceDigest !== tree.digest) {
306
- // Name the paths. Without them the refusal is true but unactionable: a
287
+ if (previous && previous.sourceDigest === tree.digest) {
288
+ const checks = Array.isArray(previous.checks) ? previous.checks : [];
289
+ const failed = checks.filter(check => check.status === "fail").map(check => check.name);
290
+ if (failed.length || previous.passed !== true)
291
+ throw new CliError("CHECKS_FAILED", `The app's own checks last ran and did not pass${failed.length ? ` (${failed.length} check${failed.length === 1 ? "" : "s"})` : ""}. Isomorph does not deploy an app whose checks are failing; the deployment pipeline would refuse it too.`, undefined, FIX_THE_CHECKS, undefined, { paths: failed });
292
+ const skipped = skippedGates(previous);
293
+ if (!skipped.length) {
294
+ output(`The app's own checks passed for this exact code (last run ${previous.createdAt}).`);
295
+ return;
296
+ }
297
+ output(`The last check run skipped ${skipped.join(", ")}; running the checks again.`);
298
+ }
299
+ else if (previous) {
300
+ // Name the paths. Without them the line is true but unactionable: a
307
301
  // builder whose agent wrote scratch files into a gitignored folder inside
308
302
  // the app root hit this three times before finding the folder, because
309
303
  // everything inside the root is inside the deployment boundary whether git
310
- // tracks it or not. Reports written before CLI 0.3.4 carry no file hashes,
311
- // so the sentence degrades to what it always said.
312
- // The paths lead, and the whole sentence stays inside output.ts's
313
- // DETAIL_BOUND. That bound keeps both ENDS and elides the middle, precisely
314
- // because a filename once sat at index 266 and was never printed — so a
315
- // path placed mid-sentence is put in the one position the bounding deletes.
316
- const moved = describeSourceChange(report.sourceFiles, tree.entries);
317
- throw new CliError("CHECKS_STALE", `The app's code has changed since its checks last ran${moved ? ` (${moved})` : ""}. Isomorph does not deploy an app whose checks have not passed for this exact code; everything inside the app folder counts, including files git ignores.`, undefined, RUN_THE_CHECKS);
318
- }
319
- const checks = Array.isArray(report.checks) ? report.checks : [];
320
- const failed = checks.filter(check => check.status === "fail").map(check => check.name);
321
- if (failed.length || report.passed !== true)
322
- throw new CliError("CHECKS_FAILED", `The app's own checks last ran and did not pass${failed.length ? ` (${nameList(failed)})` : ""}. Isomorph does not deploy an app whose checks are failing; the deployment pipeline would refuse it too.`, undefined, FIX_THE_CHECKS);
323
- // "Skipped" is not "passed". A report whose journeys and coverage gate never
324
- // ran says nothing about whether the app works, and accepting it would hand
325
- // the discovery straight back to the pipeline.
326
- const notRun = checks.filter(check => isReplayedGate(check.name) && check.status !== "pass").map(check => check.name);
327
- const flowRan = checks.some(check => check.name === FLOW_CHECK && check.status === "pass");
328
- if (notRun.length || !flowRan) {
329
- const names = notRun.length ? notRun : [FLOW_CHECK];
330
- throw new CliError("CHECKS_NOT_RUN", `The last check run skipped ${nameList(names)} instead of passing ${names.length === 1 ? "it" : "them"}. The deployment pipeline runs the same ones in the cloud, so skipping them here only moves the failure.`, undefined, RUN_THE_CHECKS);
304
+ // tracks it or not.
305
+ const moved = describeSourceChange(previous.sourceFiles, tree.entries);
306
+ output(`The app's code has changed since its checks last ran${moved ? ` (${moved})` : ""}; running the checks first.`);
331
307
  }
332
- output(`The app's own checks passed for this exact code (${retained.length} retained check${plural}, last run ${report.createdAt}).`);
308
+ else
309
+ output("The app's checks have not run for this code yet; running them first.");
310
+ const report = await (options.checks?.runChecks ?? defaultRunChecks(options))(root, output);
311
+ if (!report.passed)
312
+ throw new CliError("CHECKS_FAILED", checksFailedMessage(report), undefined, CHECKS_FAILED_HINT, undefined, { paths: report.checks.filter(check => check.status === "fail").map(check => check.name) });
313
+ const skipped = skippedGates(report);
314
+ if (skipped.length)
315
+ throw new CliError("CHECKS_NOT_RUN", `The checks skipped ${skipped.length} gate${skipped.length === 1 ? "" : "s"} instead of passing ${skipped.length === 1 ? "it" : "them"}. The deployment pipeline runs the same ones in the cloud, so skipping them here only moves the failure.`, undefined, "Start `isomorph dev --app-root .`, run `isomorph check --app-root .`, then run this again.", undefined, { paths: skipped });
316
+ }
317
+ /** The checks as `isomorph check` runs them, with the bundle this deploy carries. */
318
+ function defaultRunChecks(options) {
319
+ const bundle = options.integrations?.bundle ?? EMBEDDED_KIT_BUNDLE;
320
+ return (root, output) => runChecks(root, { run: options.checks?.run ?? runCommand, bundle, output });
333
321
  }
334
322
  async function waitForIntake(client, operationRef) {
335
323
  for (let attempt = 0; attempt < 60; attempt += 1) {
@@ -359,7 +347,7 @@ function intakeRejection(status, operationRef) {
359
347
  * generic pair only where it gave nothing, and the operation reference always.
360
348
  */
361
349
  function refusal(failure, fallbackCode, fallbackMessage, operationRef) {
362
- return new CliError(failure?.code ?? fallbackCode, failure?.message ?? fallbackMessage, operationRef, failure?.remediationHint);
350
+ return new CliError(failure?.code ?? fallbackCode, failure?.message ?? fallbackMessage, operationRef, failure?.remediationHint, undefined, { layer: "governance" });
363
351
  }
364
352
  /**
365
353
  * Why the whole operation stopped. The outer catch used to answer this with one
@@ -370,24 +358,14 @@ function refusal(failure, fallbackCode, fallbackMessage, operationRef) {
370
358
  *
371
359
  * 1. A `CliError` already carries a reason; keep it, and only make sure it
372
360
  * names the operation, since by here one exists.
373
- * 2. The operation is not failed at all but waiting for its profile, audience
374
- * or secrets — say what is pending and which commands supply it.
375
- * 3. Otherwise the thrown value's own detail, then the refusal intake recorded
361
+ * 2. Otherwise the thrown value's own detail, then the refusal intake recorded
376
362
  * on the operation, and only then the generic line.
377
363
  */
378
364
  async function operationFailure(client, operationRef, error) {
379
365
  if (error instanceof CliError)
380
- return error.operationRef ? error : new CliError(error.code, error.message, operationRef, error.remediationHint, error.result);
366
+ return error.operationRef ? error : withOperation(error, operationRef);
381
367
  const detail = errorDetail(error);
382
- const state = await probeOperation(client, operationRef);
383
- // A pending-setup operation is not a failed operation: nothing is wrong with
384
- // it and re-running `productionise` after the setup commands carries it on.
385
- // It is still a failed *command* — no app was saved, verified or deployed —
386
- // so it keeps a non-zero exit rather than reporting a success that did not
387
- // happen, and carries the real next step instead of a bare code.
388
- if (state.pending.length)
389
- return new CliError(detail?.code ?? "SETUP_REQUIRED", state.plainEnglish ?? pendingSentence(state.pending), operationRef, setupRemediation(operationRef));
390
- return refusal(detail ?? state.failure, "OPERATION_FAILED", "Isomorph could not complete the started operation.", operationRef);
368
+ return refusal(detail ?? await recordedFailure(client, operationRef), "OPERATION_FAILED", "Isomorph could not complete the started operation.", operationRef);
391
369
  }
392
370
  /**
393
371
  * What the thrown value itself says. A refusal reaches us as a bare `Error`
@@ -412,42 +390,18 @@ function text(value) {
412
390
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
413
391
  }
414
392
  /**
415
- * What the server says about the operation once a call has failed. Both reads
416
- * are best-effort: the reason the builder gets must never depend on a second
417
- * call succeeding, so a probe that fails simply contributes nothing.
393
+ * The refusal intake recorded on the operation, if any. Best-effort: the reason
394
+ * the builder gets must never depend on a second call succeeding, so a probe
395
+ * that fails simply contributes nothing.
418
396
  */
419
- async function probeOperation(client, operationRef) {
420
- let failure;
397
+ async function recordedFailure(client, operationRef) {
421
398
  try {
422
- failure = pickSourceFailure(structured(await client.call("isomorph_get_operation_status", { operationId: operationRef, waitSeconds: 0 })));
423
- }
424
- catch { /* no status: fall back to what the error said */ }
425
- try {
426
- const setup = await getAppSetup(client, operationRef);
427
- return { pending: setup.pending ?? [], ...(setup.nextAction?.plainEnglish ? { plainEnglish: setup.nextAction.plainEnglish } : {}), ...(failure ? { failure } : {}) };
399
+ return pickSourceFailure(structured(await client.call("isomorph_get_operation_status", { operationId: operationRef, waitSeconds: 0 })));
428
400
  }
429
401
  catch {
430
- return { pending: [], ...(failure ? { failure } : {}) };
402
+ return undefined;
431
403
  }
432
404
  }
433
- /** The console's post-save steps, in the words the CLI already uses for them. */
434
- const SETUP_STEPS = {
435
- confirm_app_profile: "its name and description confirmed",
436
- confirm_app_audience: "its audience confirmed",
437
- provide_secrets: "the secrets it asked for"
438
- };
439
- function pendingSentence(pending) {
440
- return `The app still needs: ${pending.map(step => SETUP_STEPS[step] ?? step).join("; ")}.`;
441
- }
442
- /**
443
- * `isomorph retry` is what the help offers for resuming an operation, but it
444
- * restarts a deployment — on an operation still waiting for its setup there is
445
- * no deployment to restart, and running `productionise` again is what carries
446
- * it on. Name the commands that actually work, and say which one does not.
447
- */
448
- function setupRemediation(operationRef) {
449
- return `Run \`isomorph profile\` and \`isomorph audience\`, then \`isomorph productionise\` again — not \`isomorph retry\`. Full list: \`isomorph setup --operation ${operationRef}\`.`;
450
- }
451
405
  async function execute(client, operationRef, appId, graph, approval) {
452
406
  const args = { operationId: operationRef, appId, surface: "codex", graph, action: "save_baseline", mode: "EXECUTE" };
453
407
  if (approval)
@@ -478,9 +432,9 @@ async function waitForSave(client, operationRef, graph, first, output) {
478
432
  function boundedWait(value) {
479
433
  return Math.min(15, Math.max(0, Number.isFinite(value) ? Math.floor(value) : 15));
480
434
  }
481
- /** Isomorph accepts dotted MCP names (its handler keys) while publishing portable underscore names. */
435
+ /** Isomorph publishes and accepts exactly one tool spelling, `isomorph_<verb>`; a next step is compared as it is. */
482
436
  function matchesTool(actual, expected) {
483
- return actual.replaceAll(".", "_") === expected;
437
+ return actual === expected;
484
438
  }
485
439
  function safeVerificationResult(value, statusEvidence) {
486
440
  if (!value || typeof value !== "object" || Array.isArray(value))
@@ -538,18 +492,11 @@ async function finishDeployment(client, operationRef, output, options, verificat
538
492
  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);
539
493
  }
540
494
  output(outcomeLine(summary));
541
- // The console asks for the app's name, audience and secrets before it
542
- // offers promotion; say what is still open so a CLI-only builder knows.
543
- let setup;
544
- try {
545
- setup = await getAppSetup(client, operationRef);
546
- }
547
- catch {
548
- setup = undefined;
549
- }
550
- if (setup?.pending?.length)
551
- output(`${setup.nextAction?.plainEnglish ?? "The app still needs setup."} Run \`isomorph setup --operation ${operationRef}\`.`);
552
- return { cliVersion: CLI_VERSION, operationRef, result: { ...verification, ...summary, ...(setup ? { setup: { pending: setup.pending ?? [], ...(setup.nextAction?.plainEnglish ? { plainEnglish: setup.nextAction.plainEnglish } : {}) } } : {}) } };
495
+ return { cliVersion: CLI_VERSION, operationRef, result: { ...verification, ...summary } };
496
+ }
497
+ /** The three values as the command prints them before it starts. */
498
+ export function describeProfile(profile) {
499
+ return [`App name: ${profile.name}`, `Description: ${profile.description || "(none yet; Isomorph suggests one)"}`, `Audience: ${profile.audience.length ? profile.audience.join(", ") : "only you"}`];
553
500
  }
554
501
  function operationFinished(status) {
555
502
  if (status.production)
@@ -586,5 +533,5 @@ async function writePending(root, record) {
586
533
  }
587
534
  }
588
535
  function withOperation(error, operationRef) {
589
- return new CliError(error.code, error.message, operationRef, error.remediationHint, error.result);
536
+ return new CliError(error.code, error.message, operationRef, error.remediationHint, error.result, { layer: error.layer, ...(error.paths ? { paths: error.paths } : {}) });
590
537
  }
@@ -1,13 +1,17 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { closeSync, openSync } from "node:fs";
3
+ import { mkdir, readFile } from "node:fs/promises";
4
+ import { join } from "node:path";
2
5
  import { createForwarder } from "./forwarder.js";
3
6
  import { readKitLock } from "./kit.js";
4
7
  import { GovernanceClient, ensureLinkedApp, fileDeclaredRequests, IDENTITY_WORDS, renderGrantGroup } from "./integrations.js";
5
- import { acquireDevLock, allocatePorts, ensureSdk, LocalRuntime, nodePackageCommand, releaseDevLock, runCommand } from "./local-runtime.js";
8
+ import { acquireDevLock, allocatePorts, ensureSdk, LocalRuntime, nodePackageCommand, pidAlive, readDevLock, releaseDevLock, runCommand } from "./local-runtime.js";
9
+ import { kitPaths } from "./kit.js";
6
10
  import { CliError, safeError } from "./output.js";
7
11
  /**
8
12
  * Signed in at startup: link the app and file the access request for every
9
13
  * declared connection — one per connection, approved by IT once for every
10
- * environment; the same filing `productionise` does — so IT's queue holds the
14
+ * environment; the same filing `deploy` does — so IT's queue holds the
11
15
  * ask from the first run and no command stands between the builder and the
12
16
  * approval. Returns the banner lines saying where each connection stands.
13
17
  * Never throws: `isomorph dev` runs without company access.
@@ -123,3 +127,78 @@ export async function startDev(root, options) {
123
127
  throw error instanceof CliError ? error : new CliError("DEV_FAILED", error instanceof Error ? error.message : "isomorph dev could not start.");
124
128
  }
125
129
  }
130
+ /** Where a detached `isomorph dev` writes what the foreground one would have printed. */
131
+ export function devLogPath(root) { return join(kitPaths(root).local, "dev.log"); }
132
+ /**
133
+ * `isomorph dev --detach`: starts the services in a child that outlives this
134
+ * process, waits until the app answers on its origin, and returns `{origin,
135
+ * pid}` so the caller can print it and exit. Every bench run before this
136
+ * showed the same 3–5 turn stall — start dev in the background, sleep, tail a
137
+ * log to find the port — for information `dev.lock` already held.
138
+ *
139
+ * The child is the ordinary foreground `dev` (same code path, same lock, same
140
+ * `isomorph stop`); it only has its stdio pointed at `.isomorph/local/dev.log`
141
+ * and is unref'd so this process can exit. An app already running is answered
142
+ * from its lock rather than started twice. The origin is "up" when
143
+ * `/_harbour/identity/current` answers: the forwarder is listening and the
144
+ * gateway behind it has the session identity, which is what the app's first
145
+ * request needs.
146
+ */
147
+ export async function detachDev(root, options) {
148
+ const readLock = options.readLock ?? readDevLock;
149
+ const isAlive = options.isAlive ?? pidAlive;
150
+ const fetchImpl = options.fetch ?? fetch;
151
+ const sleep = options.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
152
+ const appId = async () => (await readKitLock(root).catch(() => undefined))?.appId || undefined;
153
+ const answered = async (origin) => { try {
154
+ return (await fetchImpl(`${origin}/_harbour/identity/current`)).status < 500;
155
+ }
156
+ catch {
157
+ return false;
158
+ } };
159
+ const running = await readLock(root);
160
+ if (running && isAlive(running.pid) && await answered(running.origin)) {
161
+ options.output(`Isomorph dev is already running: ${running.origin} (pid ${running.pid}).`);
162
+ const linked = await appId();
163
+ return { origin: running.origin, pid: running.pid, ...(linked ? { appId: linked } : {}) };
164
+ }
165
+ await mkdir(kitPaths(root).local, { recursive: true });
166
+ const logFd = openSync(devLogPath(root), "w");
167
+ let exited;
168
+ let child;
169
+ try {
170
+ child = (options.spawnDev ?? spawnForegroundDev(options.reset ?? false))(root, logFd);
171
+ child.on("exit", code => { exited = code ?? -1; });
172
+ child.unref();
173
+ }
174
+ finally {
175
+ closeSync(logFd);
176
+ }
177
+ options.output(`Starting Isomorph dev in the background (pid ${child.pid ?? "?"}); its output goes to .isomorph/local/dev.log.`);
178
+ const deadline = Date.now() + (options.timeoutMs ?? 5 * 60_000);
179
+ while (Date.now() < deadline) {
180
+ const lock = await readLock(root);
181
+ if (lock && (lock.pid === child.pid || isAlive(lock.pid)) && await answered(lock.origin)) {
182
+ const linked = await appId();
183
+ return { origin: lock.origin, pid: lock.pid, ...(linked ? { appId: linked } : {}) };
184
+ }
185
+ if (exited !== undefined)
186
+ throw new CliError("DEV_FAILED", `isomorph dev stopped before the app answered (exit ${exited}). ${await logTail(root)}`, undefined, "Read .isomorph/local/dev.log, fix what it reports, then run this again.");
187
+ await sleep(options.pollMs ?? 500);
188
+ }
189
+ throw new CliError("DEV_FAILED", "isomorph dev did not answer within the wait; it may still be starting.", undefined, `Read .isomorph/local/dev.log; run \`isomorph stop --app-root .\` and then this again.`);
190
+ }
191
+ /** The foreground `dev` of this same CLI, detached, with its output in the log file. */
192
+ function spawnForegroundDev(reset) {
193
+ return (root, logFd) => {
194
+ const child = spawn(process.execPath, [...process.execArgv, process.argv[1], "dev", "--app-root", root, ...(reset ? ["--reset"] : [])], { cwd: root, detached: true, stdio: ["ignore", logFd, logFd], windowsHide: true, env: process.env });
195
+ child.on("error", () => undefined);
196
+ return child;
197
+ };
198
+ }
199
+ /** The last few lines of the detached child's log, for a refusal that has nothing else to say. */
200
+ async function logTail(root) {
201
+ const text = await readFile(devLogPath(root), "utf8").catch(() => "");
202
+ const lines = text.trim().split("\n").filter(Boolean).slice(-3).join(" | ");
203
+ return lines ? `Last lines: ${lines}` : "";
204
+ }