@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.
- package/README.md +55 -0
- package/dist/packages/harbour-cli/src/agent-setup.js +235 -0
- package/dist/packages/harbour-cli/src/app-schema.js +142 -0
- package/dist/packages/harbour-cli/src/auth.js +197 -0
- package/dist/packages/harbour-cli/src/check.js +317 -0
- package/dist/packages/harbour-cli/src/cli.js +321 -0
- package/dist/packages/harbour-cli/src/config.js +97 -0
- package/dist/packages/harbour-cli/src/dev.js +125 -0
- package/dist/packages/harbour-cli/src/forwarder.js +168 -0
- package/dist/packages/harbour-cli/src/integrations.js +385 -0
- package/dist/packages/harbour-cli/src/jobs.js +53 -0
- package/dist/packages/harbour-cli/src/kit-bundle.js +41 -0
- package/dist/packages/harbour-cli/src/kit-bundle.manifest.js +32 -0
- package/dist/packages/harbour-cli/src/kit.js +149 -0
- package/dist/packages/harbour-cli/src/local-runtime.js +526 -0
- package/dist/packages/harbour-cli/src/operations.js +382 -0
- package/dist/packages/harbour-cli/src/output.js +273 -0
- package/dist/packages/harbour-cli/src/productionise.js +508 -0
- package/dist/packages/harbour-cli/src/remote-mcp-client.js +121 -0
- package/dist/packages/harbour-cli/src/retained-checks.js +435 -0
- package/dist/packages/harbour-cli/src/source-inventory.js +239 -0
- package/dist/packages/harbour-cli/src/starter.js +557 -0
- package/dist/packages/harbour-cli/src/upload.js +163 -0
- package/dist/packages/harbour-cli/src/version.js +1 -0
- package/dist/src/analyzer.js +685 -0
- package/dist/src/contracts.js +82 -0
- package/dist/src/digest.js +26 -0
- package/dist/src/secret-paths.js +38 -0
- package/dist/src/source-intake.js +125 -0
- 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
|
+
}
|