@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.
@@ -4,14 +4,14 @@ import { CliError } from "./output.js";
4
4
  import { CLI_VERSION } from "./version.js";
5
5
  export class GovernanceClient {
6
6
  apiUrl;
7
- token;
8
7
  tenantId;
9
8
  fetchImpl;
9
+ token;
10
10
  constructor(apiUrl, token, tenantId, fetchImpl = fetch) {
11
11
  this.apiUrl = apiUrl;
12
- this.token = token;
13
12
  this.tenantId = tenantId;
14
13
  this.fetchImpl = fetchImpl;
14
+ this.token = typeof token === "string" ? async () => token : token;
15
15
  }
16
16
  link(input) {
17
17
  return this.call("POST", "/v1/development/apps/link", { tenantId: this.tenantId, ...input });
@@ -35,6 +35,8 @@ export class GovernanceClient {
35
35
  * environment's lane (governance files the requests itself), the company's AI
36
36
  * setup when the app calls governed AI, and the CLI version. One call, one
37
37
  * refusal shape, instead of the sequential integrations → AI → pipeline cycles.
38
+ * `deploy` no longer calls this — its deploy route runs the same preflight
39
+ * before opening the operation; `promote` still asks it for production.
38
40
  */
39
41
  deployPreflight(appId, body) {
40
42
  return this.call("POST", `/v1/development/apps/${encodeURIComponent(appId)}/deploy-preflight`, body);
@@ -46,18 +48,59 @@ export class GovernanceClient {
46
48
  submitFeedback(appId, input) {
47
49
  return this.call("POST", `/v1/development/apps/${encodeURIComponent(appId)}/feedback`, input);
48
50
  }
51
+ // The kit's one deploy transport (governance ADR 0020): open or resume, hand
52
+ // over the package, then read the same status the console reads.
53
+ /** Opens the app's deployment, or resumes the one already open for this package (same sha256). 409 `DEPLOY_BLOCKED` carries every blocker in `details`; 409 `DEPLOY_IN_FLIGHT` names the operation still deploying another package. */
54
+ async startDeployment(appId, body) {
55
+ const { status, data } = await this.exchange("POST", deployments(appId), body);
56
+ return { operationRef: data.operationRef, upload: data.upload, status: data.status, resumed: status !== 201 };
57
+ }
58
+ /** The package is uploaded: from here the server carries the deployment on by itself (intake, the managed save, verification). */
59
+ completePackage(appId, operationRef, body) {
60
+ return this.call("POST", `${deployments(appId)}/${encodeURIComponent(operationRef)}/package`, body);
61
+ }
62
+ /** The operation's status, long-polled up to `waitSeconds` (0–15) and answered early on change. */
63
+ deploymentStatus(appId, operationRef, waitSeconds = 0) {
64
+ return this.call("GET", `${deployments(appId)}/${encodeURIComponent(operationRef)}?wait=${Math.min(15, Math.max(0, Math.floor(waitSeconds)))}`);
65
+ }
66
+ promoteDeployment(appId, operationRef, body) {
67
+ return this.call("POST", `${deployments(appId)}/${encodeURIComponent(operationRef)}/promote`, body);
68
+ }
69
+ retryDeployment(appId, operationRef) {
70
+ return this.call("POST", `${deployments(appId)}/${encodeURIComponent(operationRef)}/retry`, {});
71
+ }
49
72
  async call(method, path, body) {
73
+ return (await this.exchange(method, path, body)).data;
74
+ }
75
+ /**
76
+ * One request, with governance's refusal carried whole: its code (or category,
77
+ * or the HTTP status), its sentence, and its `details` for the caller. A 401 is
78
+ * retried once with a re-resolved token — a sibling process may have rotated
79
+ * the pair — and a second one is a sign-in refusal. A transport failure and a
80
+ * 5xx are named as such (`GOVERNANCE_UNREACHABLE`, `HTTP_5xx`) so a long poll
81
+ * can tell a transient fault from a refusal; see `waitForSettled`.
82
+ */
83
+ async exchange(method, path, body) {
50
84
  const url = `${this.apiUrl.replace(/\/$/, "")}${path}`;
51
- const response = await this.fetchImpl(url, { method, headers: { authorization: `Bearer ${this.token}`, "x-harbour-tenant": this.tenantId, accept: "application/json", ...(body ? { "content-type": "application/json" } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}), redirect: "error" })
52
- .catch((error) => { throw new CliError("GOVERNANCE_UNREACHABLE", `Isomorph governance at ${new URL(url).host} could not be reached: ${transportFailure(error)}.`); });
85
+ let response = await this.send(url, method, body);
86
+ if (response.status === 401)
87
+ response = await this.send(url, method, body);
53
88
  const parsed = await response.json().catch(() => ({}));
54
89
  if (response.status === 401)
55
90
  throw new CliError("AUTH_REQUIRED", "Please sign in to Isomorph with `isomorph login`.", undefined, undefined, undefined, { layer: "governance" });
56
- if (!response.ok)
57
- throw new CliError(parsed.error?.details?.code ?? parsed.error?.category ?? `HTTP_${response.status}`, parsed.error?.message ?? "Isomorph governance rejected the request.", undefined, undefined, undefined, { layer: "governance" });
58
- return (parsed.data ?? parsed);
91
+ if (!response.ok) {
92
+ const { code, remediationHint, ...details } = parsed.error?.details ?? {};
93
+ throw new CliError(code ?? parsed.error?.category ?? `HTTP_${response.status}`, parsed.error?.message || "Isomorph governance rejected the request.", undefined, typeof remediationHint === "string" && remediationHint.trim() ? remediationHint.trim() : undefined, undefined, { layer: "governance", ...(Object.keys(details).length ? { details } : {}) });
94
+ }
95
+ return { status: response.status, data: (parsed.data ?? parsed) };
96
+ }
97
+ async send(url, method, body) {
98
+ const token = await this.token();
99
+ return this.fetchImpl(url, { method, headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), "x-harbour-tenant": this.tenantId, accept: "application/json", ...(body ? { "content-type": "application/json" } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}), redirect: "error" })
100
+ .catch((error) => { throw new CliError("GOVERNANCE_UNREACHABLE", `Isomorph governance at ${new URL(url).host} could not be reached: ${transportFailure(error)}.`); });
59
101
  }
60
102
  }
103
+ const deployments = (appId) => `/v1/development/apps/${encodeURIComponent(appId)}/deployments`;
61
104
  /**
62
105
  * What actually failed under a `fetch` rejection. undici reports every transport
63
106
  * failure as `TypeError: fetch failed` and keeps the reason in `cause`: `getaddrinfo
@@ -314,18 +357,31 @@ export async function assertDeployReady(root, client, tenantId, bundle, environm
314
357
  throw new CliError("DECLARATION_INVALID", `.isomorph/integrations.json is invalid: ${errors[0]}`);
315
358
  const appId = await ensureLinkedApp(root, client, tenantId, bundle);
316
359
  const preflight = await client.deployPreflight(appId, { environment, callsAi, declaration, cliVersion: CLI_VERSION });
317
- const blockers = Array.isArray(preflight.blockers) ? preflight.blockers.filter(blocker => blocker && typeof blocker.sentence === "string") : [];
318
- if (preflight.ready && !blockers.length) {
360
+ if (preflight.ready && !blockersOf(preflight.blockers).length) {
319
361
  output(`Isomorph confirmed the app can deploy to ${environment}.`);
320
362
  return appId;
321
363
  }
364
+ throw deployBlocked(preflight.blockers, environment, output);
365
+ }
366
+ /** The blockers as governance listed them; anything that is not a sentence is dropped rather than printed as `undefined`. */
367
+ function blockersOf(value) {
368
+ return Array.isArray(value) ? value.filter(blocker => blocker && typeof blocker.sentence === "string") : [];
369
+ }
370
+ /**
371
+ * The one refusal for a blocked deploy, wherever the preflight ran — `promote`'s
372
+ * own call or the deploy route's 409 `DEPLOY_BLOCKED`: every sentence printed,
373
+ * the fixes joined as the hint, and the code readers already know when the
374
+ * blockers are all of one kind.
375
+ */
376
+ export function deployBlocked(value, environment, output) {
377
+ const blockers = blockersOf(value);
322
378
  output(`Isomorph cannot deploy to ${environment} yet:`);
323
379
  for (const blocker of blockers)
324
380
  output(` ${blocker.sentence}`);
325
381
  const kinds = new Set(blockers.map(blocker => blocker.kind));
326
382
  const code = kinds.size === 1 ? BLOCKER_CODES[[...kinds][0]] ?? "DEPLOY_BLOCKED" : "DEPLOY_BLOCKED";
327
383
  const fixes = [...new Set(blockers.map(blocker => blocker.fix).filter(Boolean))].join(" ");
328
- throw new CliError(code, blockers.map(blocker => blocker.sentence).join(" ") || `Isomorph cannot deploy this app to ${environment} yet.`, undefined, fixes || undefined, undefined, { layer: "governance" });
384
+ return new CliError(code, blockers.map(blocker => blocker.sentence).join(" ") || `Isomorph cannot deploy this app to ${environment} yet.`, undefined, fixes || undefined, undefined, { layer: "governance" });
329
385
  }
330
386
  /** The code readers already know for a refusal that is all of one kind. */
331
387
  const BLOCKER_CODES = { integration: "INTEGRATIONS_NOT_READY", ai: "AI_NOT_READY", cli: "CLI_UPGRADE_REQUIRED" };
@@ -1,28 +1,28 @@
1
1
  export const PUBLISHED_KIT_BUNDLE = {
2
2
  "schema": "isomorph.kit-bundle/1.0",
3
- "kitVersion": "0.4.0",
3
+ "kitVersion": "0.5.1",
4
4
  "sdk": {
5
5
  "package": "@isomorph.ai/app-sdk",
6
- "version": "1.2.0",
7
- "tarballSha256": "6b41494883215527a73e05a1e07d022552dfe3402915e23950e357e5e9381c49",
8
- "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:6b41494883215527a73e05a1e07d022552dfe3402915e23950e357e5e9381c49"
6
+ "version": "1.2.1",
7
+ "tarballSha256": "607e3896aa4116d82d0473677d38a68fecd4855a250cd44e038780290c335c67",
8
+ "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:607e3896aa4116d82d0473677d38a68fecd4855a250cd44e038780290c335c67"
9
9
  },
10
10
  "images": {
11
- "appGateway": "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:9c7750897be5ed44af332069f463f0cfe9ca94694450b49ab469c019c97d7757",
12
- "sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:52bfe8ca4e40fd38e662ab13c32cc842721f0d48b494561e99ac1607e49e8c74"
11
+ "appGateway": "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:558e3aa74f315a8bf2ae18f1344be3b8fad0203286f43d14d411a4a1f3e15dc0",
12
+ "sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:578cc8d98ca687dc79d8567f46abc636db2df0b3d8a01ccf38b30d5932c43353"
13
13
  },
14
14
  "nativeRuntime": {
15
15
  "darwinArm64": {
16
- "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:7f6377c465b46cfb45402ae12c3f05063bae7c780c1c2be3aab5bd75614e809a",
17
- "sha256": "7f6377c465b46cfb45402ae12c3f05063bae7c780c1c2be3aab5bd75614e809a"
16
+ "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:e36aeea224867ebd7ae4a609284393460f707eaf6c50cb5f8ea1596926bae04b",
17
+ "sha256": "e36aeea224867ebd7ae4a609284393460f707eaf6c50cb5f8ea1596926bae04b"
18
18
  },
19
19
  "linuxX64": {
20
- "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:ea9fc7cab8b2ebf27fb39a59699cd3d76117d0f8fc00346f7d85cc1bab84778d",
21
- "sha256": "ea9fc7cab8b2ebf27fb39a59699cd3d76117d0f8fc00346f7d85cc1bab84778d"
20
+ "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:0673c75610f7fc8a5f1dbc1ec39570eee2278301430e7bbd4b4323319e7a1e6e",
21
+ "sha256": "0673c75610f7fc8a5f1dbc1ec39570eee2278301430e7bbd4b4323319e7a1e6e"
22
22
  },
23
23
  "windowsX64": {
24
- "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:0e1ef627daae405f1043b4b434a446e3cee937c370cb64ea49d84dc07c484d21",
25
- "sha256": "0e1ef627daae405f1043b4b434a446e3cee937c370cb64ea49d84dc07c484d21"
24
+ "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:8c1fc2e83d6b4cfd056b5252b9676a32e1e49e0596bb072f2c33faef63781aab",
25
+ "sha256": "8c1fc2e83d6b4cfd056b5252b9676a32e1e49e0596bb072f2c33faef63781aab"
26
26
  }
27
27
  },
28
28
  "brief": {
@@ -98,7 +98,7 @@ export function newKitLock(bundle, tenantId = "", appId = "") {
98
98
  return { schema: "isomorph.kit-lock/1.0", appId, tenantId, bundle, createdAt: now, updatedAt: now };
99
99
  }
100
100
  // ---- Paths and identity --------------------------------------------------------
101
- /** Where a kit app keeps its declaration, lock, retained checks, gate-written inventory and (ignored) local state. */
101
+ /** Where a kit app keeps its declaration, lock and app profile (committed), and its generated checks and local state (ignored). */
102
102
  export const KIT_DIRECTORY = ".isomorph";
103
103
  export function appRoot(rootArg) {
104
104
  if (!rootArg)
@@ -111,7 +111,7 @@ export function appRoot(rootArg) {
111
111
  export function kitPaths(root) {
112
112
  const kit = join(root, KIT_DIRECTORY);
113
113
  const local = join(kit, "local");
114
- return { kit, local, declaration: join(kit, "integrations.json"), app: join(kit, "app.json"), lock: join(kit, "kit.lock.json"), checks: join(kit, "checks"), devLock: join(local, "dev.lock"), state: join(local, "state"), report: join(local, "check-report.json") };
114
+ return { kit, local, declaration: join(kit, "integrations.json"), app: join(kit, "app.json"), lock: join(kit, "kit.lock.json"), devLock: join(local, "dev.lock"), state: join(local, "state"), report: join(local, "check-report.json"), deployNote: join(local, "deploy.json") };
115
115
  }
116
116
  export function defaultAppProfile(root) {
117
117
  // A real description from the start: the person changes it, but a first
@@ -169,7 +169,9 @@ export function linkIdempotencyKey(tenantId, root) {
169
169
  /**
170
170
  * sha256 over the tracked app files (same boundary as deploy), so any
171
171
  * edit changes it — and, per file, the hash of its contents, so a stale check
172
- * can say WHICH path moved rather than only that something did.
172
+ * can say WHICH path moved rather than only that something did. The boundary
173
+ * leaves `.isomorph/checks/` out (analyzer.ts): the gate regenerates it on
174
+ * every check, and a regeneration is not a change to the app.
173
175
  */
174
176
  export async function sourceDigest(root) {
175
177
  const graph = await scanWorkspace(root, { sourceBoundary: "root" });
@@ -370,19 +370,20 @@ export class LocalRuntime {
370
370
  })();
371
371
  }
372
372
  /**
373
- * Starts the kit gate: the pinned gateway image's `gate` subcommand as a
374
- * one-shot service of this project (`docker compose run`), the app mounted
375
- * read-only at /workspace, its control listener published on `port`, the
376
- * session's PostgreSQL and bucket named for its scratch database and files.
377
- * Resolves to the container id; the caller drives the control API
378
- * (check.ts) and stops it with stopGate.
373
+ * Starts the kit gate: the pinned native gateway's `gate` subcommand as a
374
+ * one-shot process of this project, the app as its workspace (worked on
375
+ * through a mirror, never written), its control listener on `port`, the
376
+ * session's PostgreSQL and bucket named for its scratch database and files,
377
+ * and this node (`HARBOUR_GATE_NODE`) to run the journeys with. Resolves to
378
+ * the gate id; the caller polls the control API (check.ts) and stops it
379
+ * with stopGate.
379
380
  */
380
- async startGate(bundle, port, publicUrl) {
381
+ async startGate(bundle, port) {
381
382
  void bundle;
382
383
  if (!this.ports)
383
384
  throw new CliError("LOCAL_RUNTIME_FAILED", "The local runtime was not prepared.");
384
385
  const id = `gate-${Date.now()}`;
385
- const child = spawn(this.binary, ["gate"], { cwd: this.root, env: { ...process.env, HARBOUR_GATE_WORKSPACE: this.root, HARBOUR_GATE_LISTEN: `127.0.0.1:${port}`, HARBOUR_GATE_PUBLIC_URL: publicUrl, HARBOUR_GATE_DATABASE_URL: `postgresql://${LOCAL.dbUser}:${LOCAL.dbPassword}@127.0.0.1:${this.ports.postgres}/${LOCAL.database}?sslmode=disable`, HARBOUR_GATE_S3_BUCKET: LOCAL.bucket, HARBOUR_GATE_FILES_DIRECTORY: join(kitPaths(this.root).state, "gate-files") }, stdio: ["ignore", "ignore", "pipe"] });
386
+ const child = spawn(this.binary, ["gate"], { cwd: this.root, env: { ...process.env, HARBOUR_GATE_WORKSPACE: this.root, HARBOUR_GATE_LISTEN: `127.0.0.1:${port}`, HARBOUR_GATE_NODE: process.execPath, HARBOUR_GATE_DATABASE_URL: `postgresql://${LOCAL.dbUser}:${LOCAL.dbPassword}@127.0.0.1:${this.ports.postgres}/${LOCAL.database}?sslmode=disable`, HARBOUR_GATE_S3_BUCKET: LOCAL.bucket, HARBOUR_GATE_FILES_DIRECTORY: join(kitPaths(this.root).state, "gate-files") }, stdio: ["ignore", "ignore", "pipe"] });
386
387
  this.gates.set(id, child);
387
388
  child.stderr?.on("data", chunk => this.gateOutput.set(id, `${this.gateOutput.get(id) ?? ""}${chunk}`.slice(-4000)));
388
389
  return id;
@@ -1,7 +1,14 @@
1
- import { structured } from "./remote-mcp-client.js";
2
1
  import { CliError } from "./output.js";
2
+ /** The status reader for one operation, bound to the deployment route. */
3
+ export function statusFetch(governance, appId, operationRef) {
4
+ return waitSeconds => governance.deploymentStatus(appId, operationRef, waitSeconds);
5
+ }
3
6
  const DEPLOYMENT_TERMINAL = new Set(["LIVE", "FAILED", "STALE", "RETRYABLE_FAILURE"]);
4
7
  const PRODUCTION_TERMINAL = new Set(["LIVE", "FAILED", "MANUAL_RECOVERY"]);
8
+ /** A save that has ended without a saved copy: nothing more happens to it without the builder acting. */
9
+ const SAVE_FAILED = new Set(["FAILED", "RETRYABLE_FAILURE"]);
10
+ /** The stages between the package hand-over and a deployment attempt, during which the server is at work with nothing for the builder to do. */
11
+ const SERVER_AT_WORK = new Set(["inspection", "awaiting-approval", "verification"]);
5
12
  export function summarize(status) {
6
13
  return {
7
14
  ...(status.operation?.stage ? { stage: status.operation.stage } : {}),
@@ -10,9 +17,12 @@ export function summarize(status) {
10
17
  ...(status.production ? { production: pickProduction(status.production) } : {}),
11
18
  ...(status.waiting ? { waiting: { stage: status.waiting.stage, plainEnglish: status.waiting.plainEnglish } } : {}),
12
19
  ...(status.nextAction?.plainEnglish ? { nextStep: status.nextAction.plainEnglish } : {}),
13
- ...(status.nextTool ? { nextTool: status.nextTool } : {})
20
+ ...(status.nextTool ? { nextTool: status.nextTool } : {}),
21
+ ...(isRecord(status.sourceControlEvidence) ? { sourceControlEvidence: status.sourceControlEvidence } : {}),
22
+ ...(isRecord(status.runnerEvidence) ? { runnerEvidence: status.runnerEvidence } : {})
14
23
  };
15
24
  }
25
+ const isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
16
26
  function pickDeployment(value) {
17
27
  return {
18
28
  state: value.state,
@@ -63,9 +73,13 @@ function pickProduction(value) {
63
73
  ...(value.failure ? { failure: pickFailure(value.failure) } : {})
64
74
  };
65
75
  }
66
- export async function fetchStatus(client, operationRef, waitSeconds = 0) {
67
- await client.initialize();
68
- return structured(await client.call("isomorph_get_operation_status", { operationId: operationRef, waitSeconds }));
76
+ /**
77
+ * The one way a refusal the server recorded on the operation becomes a failure
78
+ * the builder can act on: the server's own code, sentence and fix where it gave
79
+ * them, the generic pair only where it gave nothing, and the operation always.
80
+ */
81
+ export function recordedRefusal(failure, fallbackCode, fallbackMessage, operationRef, result) {
82
+ return new CliError(failure?.code ?? fallbackCode, failure?.message ?? fallbackMessage, operationRef, failure?.remediationHint, result, { layer: "governance" });
69
83
  }
70
84
  /** True when nothing more will change without a person acting. */
71
85
  export function isSettled(status) {
@@ -75,12 +89,29 @@ export function isSettled(status) {
75
89
  return PRODUCTION_TERMINAL.has(status.production.state);
76
90
  if (status.deployment)
77
91
  return DEPLOYMENT_TERMINAL.has(status.deployment.state);
78
- return false;
92
+ return status.operation?.stage === "failed" || SAVE_FAILED.has(status.sourceSave?.status ?? "");
93
+ }
94
+ /**
95
+ * Whether the server is still at work before any deployment attempt exists:
96
+ * inspecting the package, saving it, verifying the saved copy. The CLI used to
97
+ * drive these as its own calls and waited on each; now the server takes them
98
+ * on its own, so a watch treats them as work in flight — bounded by the wait
99
+ * ceiling, never by the short grace window that follows a verified save.
100
+ */
101
+ function saveInFlight(status) {
102
+ const save = status.sourceSave?.status ?? "";
103
+ return SERVER_AT_WORK.has(status.operation?.stage ?? "") || save === "AWAITING_APPROVAL" || save === "QUEUED" || save === "RUNNING";
79
104
  }
80
105
  /** What the wait was before `--max-wait` existed: a caller that never passes it waits exactly as long as it did. */
81
106
  const DEFAULT_MAX_WAIT_MS = 30 * 60_000;
82
107
  /** A line at least this often while nothing changes, so a wrapper with its own idle timeout (an agent's shell tool) keeps seeing output; see `follow`. */
83
108
  const HEARTBEAT_MS = 60_000;
109
+ /** A status read that failed for a reason a later read may not: the network, or governance answering 5xx. A refusal (sign-in, not found) is not one. */
110
+ function transient(error) {
111
+ if (!(error instanceof CliError))
112
+ return true;
113
+ return error.code === "GOVERNANCE_UNREACHABLE" || /^HTTP_5\d\d$/.test(error.code);
114
+ }
84
115
  /**
85
116
  * Follow the operation until the preview deployment (or an in-flight production
86
117
  * promotion) settles. Isomorph long-polls up to 15 s per call and returns early
@@ -97,7 +128,7 @@ const HEARTBEAT_MS = 60_000;
97
128
  * left of the bound, so `--max-wait N` answers within N seconds — an agent's
98
129
  * tool with a total cap of N sees the answer instead of killing the command.
99
130
  */
100
- export async function waitForSettled(client, operationRef, output, options = {}) {
131
+ export async function waitForSettled(fetch, operationRef, output, options = {}) {
101
132
  const now = options.now ?? Date.now;
102
133
  const sleep = options.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
103
134
  const maxWaitMs = options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
@@ -117,7 +148,7 @@ export async function waitForSettled(client, operationRef, output, options = {})
117
148
  return last;
118
149
  let status;
119
150
  try {
120
- status = await fetchStatus(client, operationRef, Math.min(15, Math.max(0, Math.floor(remainingMs / 1000))));
151
+ status = await fetch(Math.min(15, Math.max(0, Math.floor(remainingMs / 1000))));
121
152
  consecutiveFailures = 0;
122
153
  }
123
154
  catch (error) {
@@ -125,7 +156,7 @@ export async function waitForSettled(client, operationRef, output, options = {})
125
156
  // transiently (gateway timeout, dropped connection). The operation is
126
157
  // unaffected by our polling, so keep watching; give up only after a run
127
158
  // of failures, and say what the last one was instead of a generic error.
128
- if (error instanceof CliError)
159
+ if (!transient(error))
129
160
  throw error; // e.g. AUTH_REQUIRED: retrying cannot help.
130
161
  consecutiveFailures += 1;
131
162
  if (consecutiveFailures >= 5)
@@ -138,7 +169,7 @@ export async function waitForSettled(client, operationRef, output, options = {})
138
169
  if (isSettled(status))
139
170
  return status;
140
171
  const at = now();
141
- bound = status.deployment || status.production ? maxWaitMs : graceMs;
172
+ bound = status.deployment || status.production || saveInFlight(status) ? maxWaitMs : graceMs;
142
173
  const line = progressLine(status);
143
174
  if (line !== lastLine) {
144
175
  output(line);
@@ -156,15 +187,26 @@ export async function waitForSettled(client, operationRef, output, options = {})
156
187
  }
157
188
  }
158
189
  function safeMessage(error) {
159
- const raw = error instanceof Error ? error.message : String(error);
190
+ const raw = error instanceof CliError ? `${error.code}: ${error.message}` : error instanceof Error ? error.message : String(error);
160
191
  return raw.replace(/https?:\/\/\S+|Bearer\s+\S+/gi, "").replace(/\s+/g, " ").trim().slice(0, 160) || "no details";
161
192
  }
162
193
  function activity(status) {
163
- return status.production ? "promoting the app to production" : status.deployment ? "deploying the app" : "preparing the deployment";
194
+ if (status.production)
195
+ return "promoting the app to production";
196
+ if (status.deployment)
197
+ return "deploying the app";
198
+ return saveInFlight(status) ? "saving the app" : "preparing the deployment";
164
199
  }
200
+ /** What the server is doing right now, as one line; printed when it changes. */
165
201
  function progressLine(status) {
166
202
  const stage = status.production ?? status.deployment;
167
- return stage ? `Isomorph is ${activity(status)}${stage.message ? ` (${stage.message})` : ""}.` : "Isomorph saved the app and is preparing the deployment.";
203
+ if (stage)
204
+ return `Isomorph is ${activity(status)}${stage.message ? ` (${stage.message})` : ""}.`;
205
+ switch (status.operation?.stage) {
206
+ case "inspection": return "Isomorph is inspecting the app package.";
207
+ case "verification": return "Isomorph is verifying the saved app.";
208
+ default: return saveInFlight(status) ? "Isomorph is saving the app to the approved company code system. Nothing is deployed or released by that." : "Isomorph saved the app and is preparing the deployment.";
209
+ }
168
210
  }
169
211
  /**
170
212
  * The one sentence for a watch that has not ended: the heartbeat prints it as
@@ -172,7 +214,7 @@ function progressLine(status) {
172
214
  * wrapper that stops watching, or an agent whose tool did, runs the command
173
215
  * it names and continues the same operation — it never starts another.
174
216
  */
175
- function runningLine(status, elapsedMs, operationRef) {
217
+ export function runningLine(status, elapsedMs, operationRef) {
176
218
  const seconds = Math.round(elapsedMs / 1000);
177
219
  return `Isomorph is still ${activity(status)} (${Math.floor(seconds / 60)}m${seconds % 60}s). Safe to stop watching: \`${continueCommand(operationRef)}\` picks it up; do not start another.`;
178
220
  }
@@ -180,10 +222,11 @@ function runningLine(status, elapsedMs, operationRef) {
180
222
  * The one command that resumes a watch, as the CLI's help and the agent guide
181
223
  * print it too. Bounded at two minutes so a wrapper with an idle timeout gets
182
224
  * an answer and runs it again, rather than cutting a 30-minute wait off and
183
- * learning nothing.
225
+ * learning nothing. It names the app folder because the deployment routes are
226
+ * addressed by the app: `status` reads the app's identity from `.isomorph/`.
184
227
  */
185
228
  export function continueCommand(operationRef) {
186
- return `isomorph status --operation ${operationRef} --wait --max-wait 120 --json`;
229
+ return `isomorph status --app-root . --operation ${operationRef} --wait --max-wait 120 --json`;
187
230
  }
188
231
  /** The exit-relevant outcome of a status — success, a step the maker must take, an operation still in flight — as the summary with its `outcome` named; a failure is thrown. */
189
232
  export function outcomeFor(status, operationRef) {
@@ -210,14 +253,31 @@ export function outcomeFor(status, operationRef) {
210
253
  }
211
254
  if (status.deployment)
212
255
  return { ...summary, outcome: "running" };
213
- // No deployment yet has two very different causes. A save that stalled or
214
- // failed (RETRYABLE_FAILURE, FAILED) is resumed by running `deploy`
215
- // again — observed 2026-09-14 (fourier "Dad Jokes"): a builder read the
216
- // administrator sentence for a save that was simply resumable. Only a
217
- // SUCCEEDED save with no deployment behind it is the company-setup case.
256
+ // Before any deployment, the server can refuse the package (intake) or fail to
257
+ // save it. Intake does not merely refuse: it records why — a stable code, one
258
+ // plain-English sentence, and the commands that fix it — on the operation. Say
259
+ // what the server said, and keep the generic line only for an operation that
260
+ // failed without any detail at all.
261
+ if (status.operation?.stage === "failed")
262
+ throw recordedRefusal(pickSourceFailure(status), "PACKAGE_REJECTED", "Isomorph could not safely accept this app package.", operationRef, summary);
218
263
  const save = status.sourceSave?.status;
219
- if (save && save !== "SUCCEEDED")
264
+ if (save === "FAILED")
265
+ throw new CliError("FAILED", "Isomorph could not save the app.", operationRef, undefined, summary);
266
+ if (save === "RETRYABLE_FAILURE")
267
+ throw new CliError("RETRYABLE_FAILURE", "Isomorph could not complete the save yet. Resume this operation safely.", operationRef, undefined, summary);
268
+ // The server inspecting, saving or verifying is work in flight, like a deployment.
269
+ if (saveInFlight(status))
270
+ return { ...summary, outcome: "running" };
271
+ // No deployment yet has three different causes, and each names its own next
272
+ // step: a package the server has not received, a save in a state it does not
273
+ // carry on from by itself — resumed by running `deploy` again, observed
274
+ // 2026-09-14 (fourier "Dad Jokes"): a builder read the administrator sentence
275
+ // for a save that was simply resumable — and only a SUCCEEDED save with no
276
+ // deployment behind it is the company-setup case.
277
+ if (save && save !== "SUCCEEDED" && save !== "DUPLICATE")
220
278
  return { ...summary, outcome: "no_deployment", nextStep: summary.nextStep ?? `Isomorph has not finished saving the app (${save}). Run \`isomorph deploy\` again for this app: it resumes the same save and deploys; nothing is deployed twice.` };
279
+ if (!save && status.operation?.stage !== "complete")
280
+ return { ...summary, outcome: "no_deployment", nextStep: summary.nextStep ?? "Isomorph has not received this app's package yet. Run `isomorph deploy` again for this app: it continues the same operation; nothing is deployed twice." };
221
281
  return { ...summary, outcome: "no_deployment", nextStep: summary.nextStep ?? "Isomorph saved the app but has not started a deployment for this company yet. Ask your Isomorph administrator to connect deployment." };
222
282
  }
223
283
  /**
@@ -232,76 +292,46 @@ export function outcomeFor(status, operationRef) {
232
292
  * human by email and never reached the agent. The heartbeat, the bounded
233
293
  * `--max-wait`, and this outcome each close one part of that.
234
294
  */
235
- export async function follow(client, operationRef, output, options = {}) {
295
+ export async function follow(fetch, operationRef, output, options = {}) {
236
296
  const now = options.now ?? Date.now;
237
297
  const startedAt = now();
238
- const status = await waitForSettled(client, operationRef, output, options);
298
+ const status = await waitForSettled(fetch, operationRef, output, options);
239
299
  const summary = outcomeFor(status, operationRef);
240
300
  if (summary.outcome !== "running")
241
301
  return summary;
242
302
  const elapsedMs = now() - startedAt;
243
303
  return { ...summary, elapsedSeconds: Math.round(elapsedMs / 1000), nextStep: runningLine(status, elapsedMs, operationRef) };
244
304
  }
245
- export async function retryDeployment(client, operationRef, output, options = {}) {
246
- await client.initialize();
247
- const result = structured(await client.call("isomorph_retry_deployment", { operationId: operationRef }));
248
- output(result.retry?.replayed ? "Isomorph had already accepted this retry." : "Isomorph accepted the deployment retry.");
305
+ export async function retryDeployment(governance, appId, operationRef, output, options = {}) {
306
+ const result = await governance.retryDeployment(appId, operationRef);
307
+ output(result.replayed ? "Isomorph had already accepted this retry." : "Isomorph accepted the deployment retry.");
249
308
  if (options.wait === false)
250
- return summarize(await fetchStatus(client, operationRef));
251
- return follow(client, operationRef, output, options.waitOptions);
309
+ return summarize(result.status);
310
+ return follow(statusFetch(governance, appId, operationRef), operationRef, output, options.waitOptions);
252
311
  }
253
- /**
254
- * What governance records as the promotion attestation. `--confirm-tested` is
255
- * the one flag that means "the person tried the preview"; nothing is prompted
256
- * for, because the CLI is run by an agent and the person is in the browser.
257
- */
258
- export const PROMOTION_CONFIRMATION = "Tested.";
259
312
  /**
260
313
  * Promotion mirrors the console's "I tested this version" button: the maker
261
- * must have opened the live preview and say so, and the version they tested
262
- * is pinned so a redeployed preview is refused as stale rather than promoted
263
- * unseen.
314
+ * must have opened the live preview and say so. `--confirm-tested` is the one
315
+ * flag that means "the person tried the preview"; nothing is prompted for,
316
+ * because the CLI is run by an agent and the person is in the browser. The
317
+ * server pins the preview version it promotes from the operation's own live
318
+ * deployment and mints the attestation for the signed-in builder.
264
319
  */
265
- export async function promoteToProduction(client, operationRef, output, options = {}) {
266
- const status = await fetchStatus(client, operationRef);
320
+ export async function promoteToProduction(governance, appId, operationRef, app, output, options = {}) {
321
+ const fetch = statusFetch(governance, appId, operationRef);
322
+ const status = await fetch(0);
267
323
  if (status.production && !PRODUCTION_TERMINAL.has(status.production.state)) {
268
324
  output("Isomorph is already promoting this app to production.");
269
- return options.wait === false ? summarize(status) : follow(client, operationRef, output, options.waitOptions);
325
+ return options.wait === false ? summarize(status) : follow(fetch, operationRef, output, options.waitOptions);
270
326
  }
271
327
  const deployment = status.deployment;
272
328
  if (deployment?.state !== "LIVE" || !deployment.versionId || !deployment.protectedUrl)
273
329
  throw new CliError("LIVE_PREVIEW_REQUIRED", "The preview must be live before it can be promoted. Check `isomorph status` first.", operationRef);
274
330
  if (!options.confirmedTested)
275
331
  throw new CliError("PROMOTION_NOT_CONFIRMED", `The preview has not been confirmed as tested: ${deployment.protectedUrl}`, operationRef, "Once the person has opened the preview and it works, run this again with --confirm-tested.");
276
- const result = structured(await client.call("isomorph_promote_to_production", {
277
- operationId: operationRef,
278
- expectedPreviewVersionId: deployment.versionId,
279
- confirmation: { schema: "isomorph.stage-approval/1.0", step: "confirm_preview_tested_and_promote", approved: true, approvedByUser: true, userApprovalText: PROMOTION_CONFIRMATION }
280
- }));
332
+ const result = await governance.promoteDeployment(appId, operationRef, { tested: true, app });
281
333
  output("Isomorph accepted the production promotion.");
282
334
  if (options.wait === false)
283
- return { ...summarize(status), ...(result.production ? { production: pickProduction(result.production) } : {}) };
284
- return follow(client, operationRef, output, options.waitOptions);
285
- }
286
- export async function getAppSetup(client, operationRef) {
287
- await client.initialize();
288
- return structured(await client.call("isomorph_get_app_setup", { operationId: operationRef }));
289
- }
290
- export async function confirmProfile(client, operationRef, input, output) {
291
- const setup = await getAppSetup(client, operationRef);
292
- const displayName = input.displayName ?? setup.profile?.suggested?.displayName ?? setup.profile?.displayName;
293
- const description = input.description ?? setup.profile?.suggested?.description ?? setup.profile?.description;
294
- if (!displayName || !description)
295
- throw new CliError("PROFILE_INCOMPLETE", "The app needs a name and a description in .isomorph/app.json; Isomorph had no suggestion to fall back on.", operationRef);
296
- if (!setup.profile?.profileVersion)
297
- throw new CliError("PROFILE_VERSION_MISSING", "Isomorph did not return the app details version.", operationRef);
298
- await client.call("isomorph_confirm_app_profile", { operationId: operationRef, displayName, description, expectedVersion: setup.profile.profileVersion });
299
- output(`Isomorph recorded the app's name and description: ${displayName}.`);
300
- return getAppSetup(client, operationRef);
301
- }
302
- export async function confirmAudience(client, operationRef, emails, output) {
303
- await client.initialize();
304
- await client.call("isomorph_confirm_app_audience", { operationId: operationRef, emails });
305
- output(emails.length ? `Isomorph recorded the app's audience: ${emails.length} colleague${emails.length === 1 ? "" : "s"}.` : "Isomorph recorded that only you can open the app for now.");
306
- return getAppSetup(client, operationRef);
335
+ return summarize(result.status);
336
+ return follow(fetch, operationRef, output, options.waitOptions);
307
337
  }
@@ -24,7 +24,9 @@ export function safeError(error) {
24
24
  // Paths and identifiers are a list field, never prose: `redact` bounds prose and
25
25
  // would elide the one token the reader needs. They are carried whole, always.
26
26
  const paths = error instanceof CliError && error.paths?.length ? error.paths : undefined;
27
- return { code, layer, message: redact(raw) || NO_DETAIL, ...(hint ? { remediationHint: hint } : {}), ...(paths ? { paths } : {}) };
27
+ // Structured, so it is neither bounded nor redacted: a command's choices are its commands, and a refusal's blockers are what the reader acts on.
28
+ const details = error instanceof CliError && error.details && Object.keys(error.details).length ? error.details : undefined;
29
+ return { code, layer, message: redact(raw) || NO_DETAIL, ...(hint ? { remediationHint: hint } : {}), ...(paths ? { paths } : {}), ...(details ? { details } : {}) };
28
30
  }
29
31
  /** Anything that must never reach a terminal, a log or an envelope. */
30
32
  const SECRETS = /https?:\/\/\S+|Bearer\s+\S+|(?:x-harbour|authorization|content-type)[^\n]*/gi;
@@ -127,7 +129,11 @@ export function renderFailure(envelope) {
127
129
  const stages = ["production", "deployment"].filter(kind => result[kind]?.failure && result[kind]?.versionId).map(kind => `\n${stageLine(kind, result[kind])}`);
128
130
  // One path per line, verbatim: never through `said`, which would bound them.
129
131
  const paths = (error?.paths ?? []).map(path => `\n ${path}`);
130
- return `${error?.message ?? NO_DETAIL}${error?.remediationHint ? ` ${error.remediationHint}` : ""}${paths.join("")}${stages.join("")}\n`;
132
+ // A refusal's choices are commands: one per line, verbatim, however long the app root is.
133
+ const choices = (Array.isArray(error?.details?.choices) ? error.details.choices : [])
134
+ .filter(choice => typeof choice.command === "string")
135
+ .map(choice => `\n ${choice.command}${typeof choice.description === "string" ? ` (${choice.description})` : ""}`);
136
+ return `${error?.message ?? NO_DETAIL}${error?.remediationHint ? ` ${error.remediationHint}` : ""}${paths.join("")}${choices.join("")}${stages.join("")}\n`;
131
137
  }
132
138
  /**
133
139
  * Server-supplied text on its way to a terminal, given exactly the treatment
@@ -243,6 +249,8 @@ export class CliError extends Error {
243
249
  layer;
244
250
  /** Paths and identifiers the message refers to, carried whole: the envelope never bounds them. */
245
251
  paths;
252
+ /** The structured half of a governance refusal (`error.details` on the wire, minus its `code`): what a caller reads to act on the refusal, never printed. */
253
+ details;
246
254
  /** `result` becomes the envelope's `result`: what the command knew about the operation when it failed. */
247
255
  constructor(code, message, operationRef, remediationHint, result, options = {}) {
248
256
  super(message);
@@ -252,5 +260,6 @@ export class CliError extends Error {
252
260
  this.result = result;
253
261
  this.layer = options.layer ?? layerFor(code);
254
262
  this.paths = options.paths?.length ? [...options.paths] : undefined;
263
+ this.details = options.details;
255
264
  }
256
265
  }