@isomorph.ai/cli 0.2.0

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.
Files changed (30) hide show
  1. package/README.md +55 -0
  2. package/dist/packages/harbour-cli/src/agent-setup.js +235 -0
  3. package/dist/packages/harbour-cli/src/app-schema.js +142 -0
  4. package/dist/packages/harbour-cli/src/auth.js +197 -0
  5. package/dist/packages/harbour-cli/src/check.js +317 -0
  6. package/dist/packages/harbour-cli/src/cli.js +321 -0
  7. package/dist/packages/harbour-cli/src/config.js +97 -0
  8. package/dist/packages/harbour-cli/src/dev.js +125 -0
  9. package/dist/packages/harbour-cli/src/forwarder.js +168 -0
  10. package/dist/packages/harbour-cli/src/integrations.js +385 -0
  11. package/dist/packages/harbour-cli/src/jobs.js +53 -0
  12. package/dist/packages/harbour-cli/src/kit-bundle.js +41 -0
  13. package/dist/packages/harbour-cli/src/kit-bundle.manifest.js +32 -0
  14. package/dist/packages/harbour-cli/src/kit.js +149 -0
  15. package/dist/packages/harbour-cli/src/local-runtime.js +526 -0
  16. package/dist/packages/harbour-cli/src/operations.js +382 -0
  17. package/dist/packages/harbour-cli/src/output.js +273 -0
  18. package/dist/packages/harbour-cli/src/productionise.js +508 -0
  19. package/dist/packages/harbour-cli/src/remote-mcp-client.js +121 -0
  20. package/dist/packages/harbour-cli/src/retained-checks.js +435 -0
  21. package/dist/packages/harbour-cli/src/source-inventory.js +239 -0
  22. package/dist/packages/harbour-cli/src/starter.js +557 -0
  23. package/dist/packages/harbour-cli/src/upload.js +163 -0
  24. package/dist/packages/harbour-cli/src/version.js +1 -0
  25. package/dist/src/analyzer.js +685 -0
  26. package/dist/src/contracts.js +82 -0
  27. package/dist/src/digest.js +26 -0
  28. package/dist/src/secret-paths.js +38 -0
  29. package/dist/src/source-intake.js +125 -0
  30. package/package.json +43 -0
@@ -0,0 +1,382 @@
1
+ import { structured } from "./remote-mcp-client.js";
2
+ import { CliError } from "./output.js";
3
+ const DEPLOYMENT_TERMINAL = new Set(["LIVE", "FAILED", "STALE", "RETRYABLE_FAILURE"]);
4
+ const PRODUCTION_TERMINAL = new Set(["LIVE", "FAILED", "MANUAL_RECOVERY"]);
5
+ export function summarize(status) {
6
+ return {
7
+ ...(status.operation?.stage ? { stage: status.operation.stage } : {}),
8
+ ...(status.sourceSave?.status ? { sourceSave: { status: status.sourceSave.status } } : {}),
9
+ ...(status.deployment ? { deployment: pickDeployment(status.deployment) } : {}),
10
+ ...(status.production ? { production: pickProduction(status.production) } : {}),
11
+ ...(status.waiting ? { waiting: { stage: status.waiting.stage, plainEnglish: status.waiting.plainEnglish } } : {}),
12
+ ...(status.nextAction?.plainEnglish ? { nextStep: status.nextAction.plainEnglish } : {}),
13
+ ...(status.nextTool ? { nextTool: status.nextTool } : {})
14
+ };
15
+ }
16
+ function pickDeployment(value) {
17
+ return {
18
+ state: value.state,
19
+ ...(value.status ? { status: value.status } : {}),
20
+ ...(value.message ? { message: value.message } : {}),
21
+ ...(value.protectedUrl ? { protectedUrl: value.protectedUrl } : {}),
22
+ ...(value.versionId ? { versionId: value.versionId } : {}),
23
+ ...(value.failure ? { failure: pickFailure(value.failure) } : {})
24
+ };
25
+ }
26
+ function pickFailure(value) {
27
+ return { ...(value.message ? { message: value.message } : {}), ...(value.retryable !== undefined ? { retryable: value.retryable } : {}), ...(value.remediationHint ? { remediationHint: value.remediationHint } : {}) };
28
+ }
29
+ /**
30
+ * The refusal reason source intake stored on the operation. Read the same way
31
+ * `pickDeployment` reads a deployment failure: keep the server's own code,
32
+ * wording and remediation, drop anything blank, and report nothing at all when
33
+ * the server supplied nothing — so a caller can tell "no detail" apart from
34
+ * "detail that happened to be empty".
35
+ */
36
+ export function pickSourceFailure(status) {
37
+ const failure = status.operation?.source?.failure;
38
+ if (!failure || typeof failure !== "object")
39
+ return undefined;
40
+ const code = text(failure.code);
41
+ const message = text(failure.message);
42
+ const remediationHint = text(failure.remediationHint);
43
+ if (!code && !message && !remediationHint)
44
+ return undefined;
45
+ return {
46
+ ...(code ? { code } : {}),
47
+ ...(failure.retryable !== undefined ? { retryable: failure.retryable } : {}),
48
+ ...(message ? { message } : {}),
49
+ ...(remediationHint ? { remediationHint } : {})
50
+ };
51
+ }
52
+ function text(value) {
53
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
54
+ }
55
+ function pickProduction(value) {
56
+ return {
57
+ state: value.state,
58
+ ...(value.status ? { status: value.status } : {}),
59
+ ...(value.message ? { message: value.message } : {}),
60
+ ...(value.productionUrl ? { productionUrl: value.productionUrl } : {}),
61
+ ...(value.versionId ? { versionId: value.versionId } : {}),
62
+ ...(value.previewVersionId ? { previewVersionId: value.previewVersionId } : {}),
63
+ ...(value.failure ? { failure: pickFailure(value.failure) } : {})
64
+ };
65
+ }
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 }));
69
+ }
70
+ /** True when nothing more will change without a person acting. */
71
+ export function isSettled(status) {
72
+ if (status.waiting)
73
+ return true;
74
+ if (status.production)
75
+ return PRODUCTION_TERMINAL.has(status.production.state);
76
+ if (status.deployment)
77
+ return DEPLOYMENT_TERMINAL.has(status.deployment.state);
78
+ return false;
79
+ }
80
+ /** What the wait was before `--max-wait` existed: a caller that never passes it waits exactly as long as it did. */
81
+ const DEFAULT_MAX_WAIT_MS = 30 * 60_000;
82
+ /** 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
+ const HEARTBEAT_MS = 60_000;
84
+ /**
85
+ * Follow the operation until the preview deployment (or an in-flight production
86
+ * promotion) settles. Isomorph long-polls up to 15 s per call and returns early
87
+ * on change, so the loop is bounded by wall clock rather than by call count.
88
+ * `noDeploymentGraceMs` covers the gap between the source save succeeding and
89
+ * the deployment attempt being recorded; past it, a saved operation with no
90
+ * deployment is reported as such instead of waited on forever.
91
+ *
92
+ * Returns for exactly three reasons, and the status says which: it settled;
93
+ * nothing was in flight when the grace window closed; or something was in
94
+ * flight when the ceiling passed. The ceiling is never thrown — see `follow`.
95
+ * Two waits, two bounds: the grace window while nothing is in flight, the
96
+ * ceiling once something is. Each long-poll and each pause is cut to what is
97
+ * left of the bound, so `--max-wait N` answers within N seconds — an agent's
98
+ * tool with a total cap of N sees the answer instead of killing the command.
99
+ */
100
+ export async function waitForSettled(client, operationRef, output, options = {}) {
101
+ const now = options.now ?? Date.now;
102
+ const sleep = options.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
103
+ const maxWaitMs = options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
104
+ const graceMs = options.noDeploymentGraceMs ?? 90_000;
105
+ const startedAt = now();
106
+ // Until the first status says what is in flight, the shorter bound holds.
107
+ let bound = Math.min(graceMs, maxWaitMs);
108
+ let last;
109
+ let lastLine = "";
110
+ let lastOutputAt = startedAt;
111
+ let consecutiveFailures = 0;
112
+ while (true) {
113
+ const callStarted = now();
114
+ const remainingMs = startedAt + bound - callStarted;
115
+ // Nothing more fits inside the bound: the watch answers with what it last saw.
116
+ if (last && remainingMs <= 1_000)
117
+ return last;
118
+ let status;
119
+ try {
120
+ status = await fetchStatus(client, operationRef, Math.min(15, Math.max(0, Math.floor(remainingMs / 1000))));
121
+ consecutiveFailures = 0;
122
+ }
123
+ catch (error) {
124
+ // A deployment takes minutes and one status call out of dozens can fail
125
+ // transiently (gateway timeout, dropped connection). The operation is
126
+ // unaffected by our polling, so keep watching; give up only after a run
127
+ // of failures, and say what the last one was instead of a generic error.
128
+ if (error instanceof CliError)
129
+ throw error; // e.g. AUTH_REQUIRED: retrying cannot help.
130
+ consecutiveFailures += 1;
131
+ if (consecutiveFailures >= 5)
132
+ throw new CliError("DEPLOYMENT_STATUS_UNAVAILABLE", `Isomorph stopped answering status checks (${safeMessage(error)}). The deployment may still be running; check again with \`isomorph status\`.`, operationRef);
133
+ output("Isomorph did not answer that status check; trying again.");
134
+ await sleep(Math.min(15_000, 3_000 * consecutiveFailures));
135
+ continue;
136
+ }
137
+ last = status;
138
+ if (isSettled(status))
139
+ return status;
140
+ const at = now();
141
+ bound = status.deployment || status.production ? maxWaitMs : graceMs;
142
+ const line = progressLine(status);
143
+ if (line !== lastLine) {
144
+ output(line);
145
+ lastLine = line;
146
+ lastOutputAt = at;
147
+ }
148
+ else if (at - lastOutputAt >= HEARTBEAT_MS) {
149
+ output(runningLine(status, at - startedAt, operationRef));
150
+ lastOutputAt = at;
151
+ }
152
+ // Isomorph returns immediately when nothing is in flight yet; do not spin — and never past the bound.
153
+ const elapsed = at - callStarted;
154
+ if (elapsed < 3_000)
155
+ await sleep(Math.min(3_000 - elapsed, Math.max(0, startedAt + bound - at)));
156
+ }
157
+ }
158
+ function safeMessage(error) {
159
+ const raw = error instanceof Error ? error.message : String(error);
160
+ return raw.replace(/https?:\/\/\S+|Bearer\s+\S+/gi, "").replace(/\s+/g, " ").trim().slice(0, 160) || "no details";
161
+ }
162
+ function activity(status) {
163
+ return status.production ? "promoting the app to production" : status.deployment ? "deploying the app" : "preparing the deployment";
164
+ }
165
+ function progressLine(status) {
166
+ 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.";
168
+ }
169
+ /**
170
+ * The one sentence for a watch that has not ended: the heartbeat prints it as
171
+ * time passes, and a ceiling that passes returns it as the next step. A
172
+ * wrapper that stops watching, or an agent whose tool did, runs the command
173
+ * it names and continues the same operation — it never starts another.
174
+ */
175
+ function runningLine(status, elapsedMs, operationRef) {
176
+ const seconds = Math.round(elapsedMs / 1000);
177
+ 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
+ }
179
+ /**
180
+ * The one command that resumes a watch, as the CLI's help and the agent guide
181
+ * print it too. Bounded at two minutes so a wrapper with an idle timeout gets
182
+ * an answer and runs it again, rather than cutting a 30-minute wait off and
183
+ * learning nothing.
184
+ */
185
+ export function continueCommand(operationRef) {
186
+ return `isomorph status --operation ${operationRef} --wait --max-wait 120 --json`;
187
+ }
188
+ /** 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
+ export function outcomeFor(status, operationRef) {
190
+ const summary = summarize(status);
191
+ // A failed stage, reported as the stage recorded it: its sentence, then its
192
+ // fix, with the summary attached so the envelope carries the version the
193
+ // console's releases tab and the email name. Only a stage that recorded no
194
+ // sentence at all gets the generic one.
195
+ const failed = (code, stage, fallback, suffix = "") => new CliError(code, `${stage.failure?.message ?? stage.message ?? fallback}${suffix}`, operationRef, stage.failure?.remediationHint, summary);
196
+ if (status.production?.state === "LIVE")
197
+ return { ...summary, outcome: "production_live" };
198
+ if (status.production && PRODUCTION_TERMINAL.has(status.production.state))
199
+ throw failed("PRODUCTION_PROMOTION_FAILED", status.production, "Isomorph could not complete the production promotion.");
200
+ if (status.waiting)
201
+ return { ...summary, outcome: "waiting" };
202
+ // A promotion in flight rides on a LIVE preview; the promotion is what is being followed.
203
+ if (status.production)
204
+ return { ...summary, outcome: "running" };
205
+ if (status.deployment?.state === "LIVE")
206
+ return { ...summary, outcome: "live" };
207
+ if (status.deployment && DEPLOYMENT_TERMINAL.has(status.deployment.state)) {
208
+ const retryable = status.deployment.failure?.retryable === true;
209
+ throw failed(retryable ? "DEPLOYMENT_RETRYABLE_FAILURE" : "DEPLOYMENT_FAILED", status.deployment, "Isomorph could not complete the deployment.", retryable ? " Run `isomorph retry` to start it again." : "");
210
+ }
211
+ if (status.deployment)
212
+ 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 `productionise`
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.
218
+ const save = status.sourceSave?.status;
219
+ if (save && save !== "SUCCEEDED")
220
+ return { ...summary, outcome: "no_deployment", nextStep: summary.nextStep ?? `Isomorph has not finished saving the app (${save}). Run \`isomorph productionise\` again for this app: it resumes the same save and deploys; nothing is deployed twice.` };
221
+ 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
+ }
223
+ /**
224
+ * `waitForSettled` then `outcomeFor`, as the one summary a command returns.
225
+ *
226
+ * A ceiling that passes is a `running` outcome and never an error: exit 0, the
227
+ * operation untouched, and the summary says how long this command watched and
228
+ * names the exact command that resumes the same watch. Measured 2026-09-12:
229
+ * the ceiling was a thrown `DEPLOYMENT_TIMEOUT`, an agent's shell tool cut the
230
+ * silent 30-minute wait off long before it, the agent started a second deploy
231
+ * to get "a status-producing call", and the first one's refusal reached the
232
+ * human by email and never reached the agent. The heartbeat, the bounded
233
+ * `--max-wait`, and this outcome each close one part of that.
234
+ */
235
+ export async function follow(client, operationRef, output, options = {}) {
236
+ const now = options.now ?? Date.now;
237
+ const startedAt = now();
238
+ const status = await waitForSettled(client, operationRef, output, options);
239
+ const summary = outcomeFor(status, operationRef);
240
+ if (summary.outcome !== "running")
241
+ return summary;
242
+ const elapsedMs = now() - startedAt;
243
+ return { ...summary, elapsedSeconds: Math.round(elapsedMs / 1000), nextStep: runningLine(status, elapsedMs, operationRef) };
244
+ }
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.");
249
+ if (options.wait === false)
250
+ return summarize(await fetchStatus(client, operationRef));
251
+ return follow(client, operationRef, output, options.waitOptions);
252
+ }
253
+ /**
254
+ * Promotion mirrors the console's "I tested this version" button: the maker
255
+ * must have opened the live preview and say so, and the version they tested
256
+ * is pinned so a redeployed preview is refused as stale rather than promoted
257
+ * unseen.
258
+ */
259
+ export async function promoteToProduction(client, operationRef, output, options = {}) {
260
+ const status = await fetchStatus(client, operationRef);
261
+ if (status.production && !PRODUCTION_TERMINAL.has(status.production.state)) {
262
+ output("Isomorph is already promoting this app to production.");
263
+ return options.wait === false ? summarize(status) : follow(client, operationRef, output, options.waitOptions);
264
+ }
265
+ const deployment = status.deployment;
266
+ if (deployment?.state !== "LIVE" || !deployment.versionId || !deployment.protectedUrl)
267
+ throw new CliError("LIVE_PREVIEW_REQUIRED", "The preview must be live before it can be promoted. Check `isomorph status` first.", operationRef);
268
+ output(`Open the protected preview and test it: ${deployment.protectedUrl}`);
269
+ output("When it works as expected, type Tested. then press Enter to promote it to production.");
270
+ const reply = await (options.readConfirmation ?? readLine)();
271
+ if (reply !== "Tested.")
272
+ throw new CliError("PROMOTION_NOT_CONFIRMED", "The production promotion was not confirmed.", operationRef);
273
+ const result = structured(await client.call("isomorph_promote_to_production", {
274
+ operationId: operationRef,
275
+ expectedPreviewVersionId: deployment.versionId,
276
+ confirmation: { schema: "isomorph.stage-approval/1.0", step: "confirm_preview_tested_and_promote", approved: true, approvedByUser: true, userApprovalText: reply }
277
+ }));
278
+ output("Isomorph accepted the production promotion.");
279
+ if (options.wait === false)
280
+ return { ...summarize(status), ...(result.production ? { production: pickProduction(result.production) } : {}) };
281
+ return follow(client, operationRef, output, options.waitOptions);
282
+ }
283
+ export async function readLine() {
284
+ if (!process.stdin.isTTY) {
285
+ const chunks = [];
286
+ for await (const chunk of process.stdin)
287
+ chunks.push(Buffer.from(chunk));
288
+ return Buffer.concat(chunks).toString("utf8").trim();
289
+ }
290
+ return new Promise(resolve => { process.stdin.setEncoding("utf8"); process.stdin.once("data", value => resolve(String(value).trim())); });
291
+ }
292
+ export async function getAppSetup(client, operationRef) {
293
+ await client.initialize();
294
+ return structured(await client.call("isomorph_get_app_setup", { operationId: operationRef }));
295
+ }
296
+ export async function confirmProfile(client, operationRef, input, output) {
297
+ const setup = await getAppSetup(client, operationRef);
298
+ const displayName = input.displayName ?? setup.profile?.suggested?.displayName ?? setup.profile?.displayName;
299
+ const description = input.description ?? setup.profile?.suggested?.description ?? setup.profile?.description;
300
+ if (!displayName || !description)
301
+ throw new CliError("PROFILE_INCOMPLETE", "Give the app a name (--name) and a description (--description); Isomorph had no suggestion to fall back on.", operationRef);
302
+ if (!setup.profile?.profileVersion)
303
+ throw new CliError("PROFILE_VERSION_MISSING", "Isomorph did not return the app details version.", operationRef);
304
+ await client.call("isomorph_confirm_app_profile", { operationId: operationRef, displayName, description, expectedVersion: setup.profile.profileVersion });
305
+ output(`Isomorph recorded the app's name and description: ${displayName}.`);
306
+ return getAppSetup(client, operationRef);
307
+ }
308
+ export async function confirmAudience(client, operationRef, emails, output) {
309
+ await client.initialize();
310
+ await client.call("isomorph_confirm_app_audience", { operationId: operationRef, emails });
311
+ 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.");
312
+ return getAppSetup(client, operationRef);
313
+ }
314
+ export async function listSecrets(client, operationRef) {
315
+ return (await getAppSetup(client, operationRef)).secrets;
316
+ }
317
+ /**
318
+ * The value is read by the CLI itself — from the terminal with echo off, or
319
+ * from stdin when the caller pipes it — and sent straight to Isomorph. It is
320
+ * never printed, never placed in the result, and never seen by an AI tool that
321
+ * merely launched this command.
322
+ */
323
+ export async function setSecret(client, operationRef, input, output) {
324
+ await client.initialize();
325
+ const value = (await input.readValue()).replace(/\r?\n$/, "");
326
+ if (!value)
327
+ throw new CliError("SECRET_VALUE_REQUIRED", "No value was entered.", operationRef);
328
+ const result = structured(await client.call("isomorph_set_app_secret", { operationId: operationRef, name: input.name, value, scope: input.personal ? "personal" : "app", ...(input.personal ? { acknowledgePersonal: true } : {}) }));
329
+ output(`Isomorph stored the value for ${input.name}.${result.resumedOperationIds?.length ? " The paused deployment is resuming." : ""}`);
330
+ return { ...(result.secret ? { secret: result.secret } : {}), resumedOperationIds: result.resumedOperationIds ?? [] };
331
+ }
332
+ export async function dismissSecret(client, operationRef, name, output) {
333
+ await client.initialize();
334
+ const result = structured(await client.call("isomorph_dismiss_app_secret", { operationId: operationRef, name }));
335
+ output(`Isomorph recorded that ${name} is not needed.`);
336
+ return { ...(result.secret ? { secret: result.secret } : {}), resumedOperationIds: result.resumedOperationIds ?? [] };
337
+ }
338
+ /** Read one line from the controlling terminal with echo off, so the value
339
+ * never lands in shell history, scrollback, or a parent process's capture. */
340
+ export async function readSecretFromTerminal(prompt) {
341
+ const { openSync, readSync, closeSync } = await import("node:fs");
342
+ let fd;
343
+ try {
344
+ fd = openSync("/dev/tty", "r+");
345
+ }
346
+ catch {
347
+ throw new CliError("NO_TERMINAL", "No terminal is available to enter the value. Pipe it on stdin with --value-stdin instead.");
348
+ }
349
+ const { writeSync } = await import("node:fs");
350
+ writeSync(fd, prompt);
351
+ const stty = await import("node:child_process");
352
+ try {
353
+ stty.execSync("stty -echo", { stdio: ["inherit", "ignore", "ignore"] });
354
+ }
355
+ catch { /* best effort: some terminals cannot toggle echo */ }
356
+ const chunks = [];
357
+ const buffer = Buffer.alloc(1);
358
+ try {
359
+ while (true) {
360
+ const read = readSync(fd, buffer, 0, 1, null);
361
+ if (read === 0 || buffer[0] === 0x0a)
362
+ break;
363
+ if (buffer[0] !== 0x0d)
364
+ chunks.push(Buffer.from(buffer));
365
+ }
366
+ }
367
+ finally {
368
+ try {
369
+ stty.execSync("stty echo", { stdio: ["inherit", "ignore", "ignore"] });
370
+ }
371
+ catch { /* ignore */ }
372
+ writeSync(fd, "\n");
373
+ closeSync(fd);
374
+ }
375
+ return Buffer.concat(chunks).toString("utf8");
376
+ }
377
+ export async function readSecretFromStdin() {
378
+ const chunks = [];
379
+ for await (const chunk of process.stdin)
380
+ chunks.push(Buffer.from(chunk));
381
+ return Buffer.concat(chunks).toString("utf8");
382
+ }
@@ -0,0 +1,273 @@
1
+ /** The one line for a failure that arrived with no detail at all. It is a
2
+ * statement about what this CLI knows, not a claim about what happened, so it
3
+ * is safe to print when — and only when — there is genuinely nothing to show. */
4
+ export const NO_DETAIL = "Isomorph could not complete the request.";
5
+ /**
6
+ * The failure as it was actually reported: the code it came with and the words
7
+ * it came with, redacted and capped. Nothing here infers what went wrong.
8
+ *
9
+ * Every non-`CliError` used to go through a keyword classifier over the message
10
+ * text, which did not merely lose the reason — it asserted false ones. A refusal
11
+ * reading "Isomorph has no saved app for this operation yet" matched on `save`
12
+ * and printed as "Saving to company code storage failed safely; nothing was
13
+ * deployed", sending a human after a code-storage failure that never happened
14
+ * and leaving an agent with nothing true to act on. Detail we do not have is
15
+ * now reported as detail we do not have.
16
+ */
17
+ export function safeError(error) {
18
+ const code = error instanceof CliError ? error.code : "CLI_FAILED";
19
+ const raw = error instanceof Error ? error.message : typeof error === "string" ? error : "";
20
+ // The hint is a separate field rather than more sentence: it is often as long
21
+ // as the message, and appending it would push the reason itself past the cap.
22
+ const hint = error instanceof CliError && error.remediationHint ? redact(error.remediationHint) : "";
23
+ return { code, message: redact(raw) || NO_DETAIL, ...(hint ? { remediationHint: hint } : {}) };
24
+ }
25
+ /** Anything that must never reach a terminal, a log or an envelope. */
26
+ const SECRETS = /https?:\/\/\S+|Bearer\s+\S+|(?:x-harbour|authorization|content-type)[^\n]*/gi;
27
+ /** The bound on one reported string. It is the width of a terminal paragraph
28
+ * and of a field an agent has to read in full, and it is what
29
+ * `tests/cli-server-failure-detail.test.ts` holds the envelope to. This change
30
+ * does not raise it: a bigger fixed head-cap fails again on a longer message,
31
+ * because the problem is which end is kept, not how much. */
32
+ const DETAIL_BOUND = 240;
33
+ /** Marks where this CLI cut, so neither a human nor an agent reads the two
34
+ * retained halves as one continuous sentence. */
35
+ const ELISION = " \u2026 ";
36
+ /**
37
+ * Redact, then bound — keeping both ends.
38
+ *
39
+ * Bounding used to be `.slice(0, DETAIL_BOUND)`: keep the head, drop the tail.
40
+ * Server failure text is built the other way round. The boilerplate and the
41
+ * error class lead; the identifying token — the file, the command, the exit
42
+ * status — is last. Measured 2026-09-11 on one vibecoder session: a 317-char
43
+ * `transform.non-retryable: kit.check-failed:` refusal named `files-journey.mjs`
44
+ * at index 266, and a second, independently reproduced refusal the same day put
45
+ * it at index 278 of 295. Both sat past 240; neither filename was ever printed.
46
+ * The builder repaired the wrong file twice, at 2m23s and 2m12s of pipeline.
47
+ *
48
+ * The cap did not merely shorten those messages, it removed the answer and left
49
+ * a grammatical sentence behind, so two engineers with full log access read
50
+ * "...(real App Gateway" and went hunting in CloudWatch rather than registering
51
+ * that the CLI had cut it.
52
+ *
53
+ * So an over-long string now keeps BOTH ends — the head carries the error class,
54
+ * the tail carries the file and the exit status — with `ELISION` marking the
55
+ * gap. `DETAIL_BOUND` is unchanged, so nothing larger reaches a terminal or an
56
+ * envelope than before; the same characters are spent on both ends instead of
57
+ * one. Every string `safeError` reports goes through here, `remediationHint`
58
+ * included: hints have the same shape, and the command a builder has to run is
59
+ * the last one in them.
60
+ *
61
+ * This reads nothing and decides nothing from the words themselves — the split
62
+ * is positional. Stripping "known boilerplate prefixes" was considered and
63
+ * rejected for exactly that reason: it needs a maintained list of the server's
64
+ * phrasings, it deletes words the server actually said, and it is the kind of
65
+ * meaning-guessing the classifier deleted above was deleted for.
66
+ */
67
+ function redact(value) {
68
+ const said = value.replace(SECRETS, "").replace(/\s+/g, " ").trim();
69
+ if (said.length <= DETAIL_BOUND)
70
+ return said;
71
+ const keep = DETAIL_BOUND - ELISION.length;
72
+ const headEnd = Math.floor(keep / 2);
73
+ return `${wholeWordsBefore(said, headEnd)}${ELISION}${wholeWordsAfter(said, said.length - (keep - headEnd))}`;
74
+ }
75
+ /** How far from a seam this will look for a word boundary. Bounded, so text
76
+ * with no spaces near the seam keeps its full share either way. */
77
+ const SNAP = 24;
78
+ /** `said.slice(0, end)` and `said.slice(start)`, backed off to a word boundary
79
+ * when the seam falls inside a word. The elision has to read as a cut: the
80
+ * engineer who read "(real App Gateway " went to CloudWatch rather than
81
+ * noticing the CLI had truncated it, and a seam that reads as a typo is the
82
+ * same failure in miniature. */
83
+ function wholeWordsBefore(said, end) {
84
+ if (said[end] === " ")
85
+ return said.slice(0, end);
86
+ const at = said.lastIndexOf(" ", end);
87
+ return at > 0 && end - at <= SNAP ? said.slice(0, at) : said.slice(0, end);
88
+ }
89
+ function wholeWordsAfter(said, start) {
90
+ if (said[start - 1] === " ")
91
+ return said.slice(start);
92
+ const at = said.indexOf(" ", start);
93
+ return at >= 0 && at - start <= SNAP ? said.slice(at + 1) : said.slice(start);
94
+ }
95
+ /**
96
+ * What a failed command reports: the JSON envelope on stdout and the line on
97
+ * stderr. One place, so the human line and the envelope always carry the same
98
+ * reason and the same fix.
99
+ */
100
+ export function failureEnvelope(error, cliVersion) {
101
+ const operationRef = error instanceof CliError ? error.operationRef : undefined;
102
+ const result = error instanceof CliError ? error.result : undefined;
103
+ return { schema: "isomorph.cli-result/1.0", cliVersion, status: "FAILED", operationStarted: Boolean(operationRef), ...(operationRef ? { operationRef } : {}), error: safeError(error), ...(result !== undefined ? { result } : {}) };
104
+ }
105
+ /**
106
+ * The envelope for a command that finished without error: `SUCCEEDED`, or
107
+ * `RUNNING` when the operation it followed is still in flight (the result's
108
+ * `outcome`, set by `follow`) — see `CliEnvelope.status`.
109
+ */
110
+ export function operationEnvelope(result, operationRef, cliVersion) {
111
+ const running = result?.outcome === "running";
112
+ return { schema: "isomorph.cli-result/1.0", cliVersion, status: running ? "RUNNING" : "SUCCEEDED", operationStarted: true, operationRef, result };
113
+ }
114
+ /**
115
+ * The reason and the fix on one line, and — when the failure is a deployment
116
+ * or a promotion the result knows the version of — the stage line beneath it,
117
+ * so the builder and whoever reads the console or the email are looking at
118
+ * the same attempt.
119
+ */
120
+ export function renderFailure(envelope) {
121
+ const error = envelope.error;
122
+ const result = (envelope.result ?? {});
123
+ const stages = ["production", "deployment"].filter(kind => result[kind]?.failure && result[kind]?.versionId).map(kind => `\n${stageLine(kind, result[kind])}`);
124
+ return `${error?.message ?? NO_DETAIL}${error?.remediationHint ? ` ${error.remediationHint}` : ""}${stages.join("")}\n`;
125
+ }
126
+ /**
127
+ * Server-supplied text on its way to a terminal, given exactly the treatment
128
+ * `safeError` gives the failure path: secrets stripped, whitespace collapsed,
129
+ * bounded at `DETAIL_BOUND` keeping both ends.
130
+ *
131
+ * `safeError` was `redact`’s only caller. `renderSummary` printed the server’s
132
+ * words straight from the payload, so on the success-shaped path — a deployment
133
+ * that reports a failure inside an otherwise-successful envelope, which is what
134
+ * `status` and `productionise` emit after a failed build — that text reached the
135
+ * terminal both unredacted and unbounded. A presigned URL, a bearer token or a
136
+ * forwarded header dump printed verbatim into whatever captured stdout, and a
137
+ * pathological message printed whole.
138
+ *
139
+ * That is the opposite defect to the one the tail-keeping change fixed: that
140
+ * one lost information, this one leaks and floods. One function and not a
141
+ * second implementation, because a second implementation is how the two paths
142
+ * came to differ at all.
143
+ */
144
+ function said(value) {
145
+ return value ? redact(value) : "";
146
+ }
147
+ /**
148
+ * Human-readable stdout for runs without `--json`: the operation reference and
149
+ * whatever link or next step Isomorph reported, nothing internal.
150
+ *
151
+ * Every server-supplied string printed here goes through `said`. What does not
152
+ * is listed below, because each is a value whose whole job is to be reproduced
153
+ * verbatim, and each would be destroyed by the very rules that make redaction
154
+ * worth doing:
155
+ *
156
+ * - `protectedUrl`, `productionUrl` — `SECRETS` strips URLs, and these two are
157
+ * the app’s address: the thing the builder ran the command to get.
158
+ * - `operationRef` — the handle every later `--operation` needs, and already
159
+ * exempt on the failure path: `safeError` redacts the message and the hint,
160
+ * never the reference they travel with. The same policy on both paths.
161
+ * - `deployment.versionId`, `production.versionId` — printed only on a failed
162
+ * stage, and for the same reason: it is the reference the console and the
163
+ * email carry for that attempt, and a bounded or reworded one names nothing.
164
+ * - `secrets.asks[].name` and `.prefilledFromPath` — identifiers, not prose. A
165
+ * secret named `AUTHORIZATION_TOKEN` matches the header pattern and would be
166
+ * erased whole, printing `Secret : needs a value`; the path is read off the
167
+ * local checkout rather than sent by the server.
168
+ * - `audience.emails` — the builder’s own confirmed list, echoed back. A
169
+ * 240-character bound over a joined list silently drops members, which is
170
+ * the information loss the tail-keeping change was about.
171
+ * - `ask.status`, `ask.scope`, `secrets.available`, `audience.confirmed` and
172
+ * the three mapped `pending` steps — compared against fixed literals, so
173
+ * only Isomorph’s own words ever print. An unmapped `pending` step is the
174
+ * server’s own id, so that one does go through `said`.
175
+ *
176
+ * `deployment.message` and `production.message` are declared on the payload and
177
+ * never printed here; nothing routes them, and a line that printed one would
178
+ * need `said` like the rest. The `--json` envelope is unchanged: it carries the
179
+ * raw structured result by design and this is about what is printed. Whether
180
+ * the envelope should be redacted too is a decision about that contract, not
181
+ * one to take in the renderer.
182
+ */
183
+ export function renderSummary(envelope) {
184
+ const lines = [];
185
+ const result = (envelope.result ?? {});
186
+ if (envelope.operationRef)
187
+ lines.push(`Operation reference: ${envelope.operationRef}`);
188
+ const verification = said(result.verification);
189
+ const assurance = said(result.assuranceLevel);
190
+ if (verification)
191
+ lines.push(`Saved app: ${verification}${assurance ? ` (${assurance})` : ""}`);
192
+ if (result.production) {
193
+ lines.push(stageLine("production", result.production));
194
+ if (result.production.failure?.message)
195
+ lines.push(` ${failureText(result.production.failure)}`);
196
+ }
197
+ if (result.deployment) {
198
+ lines.push(stageLine("deployment", result.deployment));
199
+ if (result.deployment.failure?.message)
200
+ lines.push(` ${failureText(result.deployment.failure)}`);
201
+ }
202
+ // Gated on the redacted value rather than the raw one, so guidance that is
203
+ // nothing but a link falls through to the next step instead of printing a
204
+ // label with nothing after it.
205
+ const waiting = said(result.waiting?.plainEnglish);
206
+ const nextStep = said(result.nextStep);
207
+ if (waiting)
208
+ lines.push(`Action needed: ${waiting}`);
209
+ else if (nextStep)
210
+ lines.push(`Next: ${nextStep}`);
211
+ const setup = envelope.result?.setup;
212
+ const secrets = setup?.secrets ?? envelope.result?.secrets;
213
+ if (setup?.profile) {
214
+ lines.push(`App name: ${said(setup.profile.displayName) || "(none)"}${setup.profile?.confirmed ? " (confirmed)" : setup.profile?.suggested?.displayName ? ` — suggested: ${said(setup.profile.suggested.displayName)}` : " (not confirmed)"}`);
215
+ if (setup.profile?.description || setup.profile?.suggested?.description)
216
+ lines.push(`Description: ${said(setup.profile?.confirmed ? setup.profile.description : setup.profile?.suggested?.description ?? setup.profile?.description)}`);
217
+ if (setup.audience?.available === false)
218
+ lines.push(`Audience: ${said(setup.audience.plainEnglish) || "console only"}`);
219
+ else
220
+ lines.push(`Audience: ${setup.audience?.confirmed ? (setup.audience.emails?.length ? setup.audience.emails.join(", ") : "only you") + " (confirmed)" : "not confirmed"}`);
221
+ }
222
+ if (secrets) {
223
+ if (secrets.available === false)
224
+ lines.push(`Secrets: ${said(secrets.plainEnglish) || "console only"}`);
225
+ else if (!secrets.asks?.length)
226
+ lines.push("Secrets: none asked for");
227
+ else
228
+ for (const ask of secrets.asks)
229
+ lines.push(`Secret ${ask.name}: ${ask.status === "UNSET" ? "needs a value" : ask.status === "SET" ? "set" : "not needed"}${ask.scope === "personal" ? " (personal)" : ""}${ask.prefilledFromPath ? ` (from ${ask.prefilledFromPath})` : ""}`);
230
+ }
231
+ // productionise carries only the open steps; the setup commands carry the full view.
232
+ const openSteps = setup?.pending?.length ? setup.pending.map(step => step === "confirm_app_profile" ? "confirm the app's name and description" : step === "confirm_app_audience" ? "confirm who may open it" : step === "provide_secrets" ? "provide the secrets it asked for" : said(step)) : [];
233
+ if (openSteps.length)
234
+ lines.push(`Still needed: ${openSteps.join("; ")}${envelope.result.setup?.plainEnglish && !setup?.profile ? ` — run \`isomorph setup --operation ${envelope.operationRef ?? "<reference>"}\`` : ""}`);
235
+ else if (setup?.profile)
236
+ lines.push("Setup complete.");
237
+ return `${lines.join("\n")}\n`;
238
+ }
239
+ /**
240
+ * `Preview deployment: FAILED (fourier#app#preview#000001) — <url>`. On a
241
+ * failure the version is the reference the console's releases tab and the
242
+ * email carry, printed so everyone is looking at the same attempt. It is an
243
+ * identifier, exempt from `said` for the reason the operation reference is.
244
+ */
245
+ function stageLine(kind, stage) {
246
+ const [label, state, url] = kind === "production" ? ["Production", stage.state, stage.productionUrl] : ["Preview deployment", stage.status ?? stage.state, stage.protectedUrl];
247
+ return `${label}: ${said(state)}${stage.failure && stage.versionId ? ` (${stage.versionId})` : ""}${url ? ` — ${url}` : ""}`;
248
+ }
249
+ /**
250
+ * The message, then the hint, each redacted and bounded on its own — exactly
251
+ * as `safeError` bounds them separately, so the hint never spends the
252
+ * message's budget. `|| NO_DETAIL` for the reason `safeError` has it: redaction
253
+ * can empty a string, and a failure reported as two spaces is worse than one
254
+ * that says plainly this CLI has nothing to show.
255
+ */
256
+ function failureText(failure) {
257
+ const hint = said(failure.remediationHint);
258
+ return `${said(failure.message) || NO_DETAIL}${hint ? ` ${hint}` : ""}`;
259
+ }
260
+ export class CliError extends Error {
261
+ code;
262
+ operationRef;
263
+ remediationHint;
264
+ result;
265
+ /** `result` becomes the envelope's `result`: what the command knew about the operation when it failed. */
266
+ constructor(code, message, operationRef, remediationHint, result) {
267
+ super(message);
268
+ this.code = code;
269
+ this.operationRef = operationRef;
270
+ this.remediationHint = remediationHint;
271
+ this.result = result;
272
+ }
273
+ }