@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,508 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { basename, dirname, parse, resolve } from "node:path";
|
|
3
|
+
import { readdir, readFile, mkdir, rename, writeFile } from "node:fs/promises";
|
|
4
|
+
import { scanWorkspace } from "../../../src/analyzer.js";
|
|
5
|
+
import { createSourceManifest } from "../../../src/source-intake.js";
|
|
6
|
+
import { structured } from "./remote-mcp-client.js";
|
|
7
|
+
import { archiveForManifest, putMultipart } from "./upload.js";
|
|
8
|
+
import { CliError } from "./output.js";
|
|
9
|
+
import { confirmAudience, confirmProfile, follow, getAppSetup, pickSourceFailure, readLine, fetchStatus, outcomeFor } from "./operations.js";
|
|
10
|
+
import { CLI_VERSION } from "./version.js";
|
|
11
|
+
import { isProhibitedSecretPath } from "../../../src/secret-paths.js";
|
|
12
|
+
import { assertAiReady, assertPreviewIntegrationsReady } from "./integrations.js";
|
|
13
|
+
import { kitPaths, readKitLock, recordKitAppId, sourceDigest } from "./kit.js";
|
|
14
|
+
import { FLOW_CHECK, preflightKitGate, readReport } from "./check.js";
|
|
15
|
+
export async function productionise(rootArg, client, output, tenantId, includePaths = [], options = {}) {
|
|
16
|
+
const root = resolve(rootArg);
|
|
17
|
+
output(`Isomorph is checking ${basename(root)}.`);
|
|
18
|
+
if (root === parse(root).root || root === resolve(homedir()))
|
|
19
|
+
throw new CliError("PREFLIGHT_APP_ROOT", "The selected app boundary is unsafe.");
|
|
20
|
+
let pending = await readPending(root, tenantId, client.endpoint);
|
|
21
|
+
let prior;
|
|
22
|
+
if (pending) {
|
|
23
|
+
try {
|
|
24
|
+
prior = await fetchStatus(client, pending.operationRef);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (error instanceof CliError)
|
|
28
|
+
throw withOperation(error, pending.operationRef);
|
|
29
|
+
throw new CliError("OPERATION_STATUS_UNAVAILABLE", "Isomorph could not read the existing operation. Continue it when the connection recovers; do not start another deployment.", pending.operationRef);
|
|
30
|
+
}
|
|
31
|
+
if (prior.operation?.stage === "failed" && pending.sourceDigest === (await sourceDigest(root)).digest)
|
|
32
|
+
throw intakeRejection(prior, pending.operationRef);
|
|
33
|
+
if (operationFinished(prior) && pending.sourceDigest !== (await sourceDigest(root)).digest)
|
|
34
|
+
pending = undefined;
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
const appId = options.appId ?? (await readKitLock(root))?.appId;
|
|
38
|
+
if (appId) {
|
|
39
|
+
await client.initialize();
|
|
40
|
+
const recovered = structured(await client.call("isomorph_get_operation_status", { appId }));
|
|
41
|
+
if (!recovered || !Object.hasOwn(recovered, "operation"))
|
|
42
|
+
throw new CliError("RECOVERY_UNAVAILABLE", "This company needs Isomorph's deployment recovery update before shipping. No new deployment was started.");
|
|
43
|
+
if (recovered.operation?.operationId && !operationFinished(recovered)) {
|
|
44
|
+
prior = recovered;
|
|
45
|
+
pending = { tenantId, endpoint: client.endpoint, operationRef: recovered.operation.operationId, sourceDigest: (await sourceDigest(root)).digest, uploaded: Boolean(recovered.sourceSave || recovered.deployment || recovered.waiting || recovered.operation.stage === "awaiting-approval"), graph: await scanWorkspace(root, { sourceBoundary: "root", ...(includePaths.length ? { includePaths } : {}) }), appId };
|
|
46
|
+
await writePending(root, pending);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (pending && (prior?.deployment || prior?.production || prior?.waiting || operationFinished(prior))) {
|
|
51
|
+
output("Isomorph is continuing the existing operation; no new deployment was started.");
|
|
52
|
+
return finishDeployment(client, pending.operationRef, output, options);
|
|
53
|
+
}
|
|
54
|
+
if (pending && prior?.sourceSave)
|
|
55
|
+
pending.uploaded = true;
|
|
56
|
+
if (pending && !pending.uploaded && pending.sourceDigest !== (await sourceDigest(root)).digest) {
|
|
57
|
+
throw new CliError("UPLOAD_SOURCE_CHANGED", "The app changed before its upload finished. Restore the submitted files before continuing this operation.", pending.operationRef);
|
|
58
|
+
}
|
|
59
|
+
const graph = pending?.graph ?? await scanWorkspace(root, { sourceBoundary: "root", ...(includePaths.length ? { includePaths } : {}) });
|
|
60
|
+
if (!graph.deploymentScope.includedFiles.length)
|
|
61
|
+
throw new CliError("PREFLIGHT_EMPTY", "The selected app boundary contains no eligible files.");
|
|
62
|
+
if (graph.deploymentScope.includedFiles.some(isProhibitedSecretPath))
|
|
63
|
+
throw new CliError("PREFLIGHT_SECRET_PATH", "The selected app boundary contains a prohibited secret file.");
|
|
64
|
+
// The app's own retained checks are the cheapest gate Isomorph has and the
|
|
65
|
+
// most expensive one to discover in the cloud. Measured: a builder shipped
|
|
66
|
+
// without running `isomorph check`, the retained checks no longer matched the
|
|
67
|
+
// code, and the pipeline's `flow` gate refused it twice — 2m23s and 2m12s per
|
|
68
|
+
// discovery — for a mismatch `isomorph check` names locally in about five
|
|
69
|
+
// seconds. Refuse here, before the tree is even read into memory, and long
|
|
70
|
+
// before any grant lookup, database replay, operation or upload.
|
|
71
|
+
if (!pending?.uploaded)
|
|
72
|
+
await assertChecksPassedForTree(root, output);
|
|
73
|
+
const files = pending?.uploaded ? [] : await Promise.all(graph.deploymentScope.includedFiles.map(async (path) => ({ path, content: new Uint8Array(await readFile(resolve(root, path))) })));
|
|
74
|
+
output(`Isomorph found ${graph.deploymentScope.includedFiles.length} app files.`);
|
|
75
|
+
// A declared connection IT has not approved yet would only park the
|
|
76
|
+
// deployment after the save; file the request and refuse here, before any operation exists.
|
|
77
|
+
const kitAppId = pending?.appId ?? (options.integrations ? await assertPreviewIntegrationsReady(root, options.integrations.governance, tenantId, options.integrations.bundle, output) : options.appId);
|
|
78
|
+
// An app that calls governed AI needs the company's AI setup to pass the
|
|
79
|
+
// deployment PLAN; ask governance now rather than discover it on the deployed button.
|
|
80
|
+
if (!pending?.uploaded && options.integrations && kitAppId && await appCallsAi(root))
|
|
81
|
+
await assertAiReady(kitAppId, "preview", options.integrations.governance, output);
|
|
82
|
+
// A kit app the pipeline's gate would refuse (a table without RLS, an
|
|
83
|
+
// operation no journey exercises, a cross-user leak) is refused here,
|
|
84
|
+
// before the save, by the same gate with the same wording.
|
|
85
|
+
const gate = options.kitGate === false ? undefined : options.kitGate ?? (options.integrations ? { bundle: options.integrations.bundle } : undefined);
|
|
86
|
+
if (gate && !pending?.uploaded)
|
|
87
|
+
await preflightKitGate(root, gate.bundle, output, gate.run);
|
|
88
|
+
try {
|
|
89
|
+
await client.initialize();
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
throw new CliError("NOT_STARTED", "Isomorph could not be reached before the operation started.");
|
|
93
|
+
}
|
|
94
|
+
let start;
|
|
95
|
+
const linked = kitAppId ? { appId: kitAppId } : {};
|
|
96
|
+
try {
|
|
97
|
+
start = pending ? { operation: { operationId: pending.operationRef } } : structured(await client.call("isomorph_start_productionization", { appPath: basename(root), appName: basename(root), surface: "codex", ...linked }));
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (error instanceof CliError)
|
|
101
|
+
throw error;
|
|
102
|
+
throw new CliError("START_UNCONFIRMED", "Isomorph did not confirm the start response. Run productionise again to recover the app operation before starting another deployment.");
|
|
103
|
+
}
|
|
104
|
+
const operationRef = start.operation?.operationId;
|
|
105
|
+
if (!operationRef)
|
|
106
|
+
throw new CliError("START_UNCONFIRMED", "Isomorph did not return the operation reference. Recover the app operation before starting another deployment.");
|
|
107
|
+
pending ??= { tenantId, endpoint: client.endpoint, operationRef, sourceDigest: (await sourceDigest(root)).digest, uploaded: false, graph, ...(kitAppId ? { appId: kitAppId } : {}) };
|
|
108
|
+
await writePending(root, pending);
|
|
109
|
+
output(`Isomorph operation saved. Continue with isomorph status --operation ${operationRef} --wait --json.`);
|
|
110
|
+
try {
|
|
111
|
+
let appId = pending.appId;
|
|
112
|
+
if (!pending.uploaded && !pending.uploadCompletion) {
|
|
113
|
+
const submitted = structured(await client.call("isomorph_submit_application_graph", { operationId: operationRef, surface: "codex", graph, ...linked }));
|
|
114
|
+
if (submitted.nextTool && !matchesTool(submitted.nextTool, "isomorph_prepare_source_upload"))
|
|
115
|
+
throw new CliError("UNSUPPORTED_NEXT_STEP", "Isomorph returned an unsupported next step.", operationRef);
|
|
116
|
+
appId = typeof submitted.appId === "string" ? submitted.appId.trim() : "";
|
|
117
|
+
if (!appId)
|
|
118
|
+
throw new CliError("APP_IDENTITY_MISSING", "Isomorph did not return the app identity for this operation.", operationRef);
|
|
119
|
+
if (kitAppId && appId !== kitAppId)
|
|
120
|
+
throw new CliError("APP_IDENTITY_MISMATCH", "Isomorph returned a different app identity than the one linked in .isomorph/kit.lock.json.", operationRef);
|
|
121
|
+
// The canonical identity lands in kit.lock so a later `integrations request` reuses it instead of linking a second app.
|
|
122
|
+
await recordKitAppId(root, appId, tenantId);
|
|
123
|
+
pending = { ...pending, appId, sourceDigest: (await sourceDigest(root)).digest };
|
|
124
|
+
await writePending(root, pending);
|
|
125
|
+
if (options.confirmedAppSetup && !pending.appSetupConfirmed) {
|
|
126
|
+
await confirmProfile(client, operationRef, options.confirmedAppSetup, output);
|
|
127
|
+
await confirmAudience(client, operationRef, options.confirmedAppSetup.audienceEmails, output);
|
|
128
|
+
pending = { ...pending, appSetupConfirmed: true };
|
|
129
|
+
await writePending(root, pending);
|
|
130
|
+
}
|
|
131
|
+
const manifest = await createSourceManifest({ tenantId, appId, operationId: operationRef, graphDigest: graph.graphDigest, files });
|
|
132
|
+
const preliminaryArchive = await archiveForManifest(root, manifest);
|
|
133
|
+
const prepared = structured(await client.call("isomorph_prepare_source_upload", { operationId: operationRef, appId, surface: "codex", graph, format: "zip", filename: `${appId}.zip`, compressedBytes: preliminaryArchive.body.byteLength, manifest: { schema: "isomorph.source-package-manifest/1.0", files: manifest.files.map(file => ({ path: file.path, size: file.bytes, sha256: file.sha256 })) } }));
|
|
134
|
+
if (!prepared.acceptedManifest || !prepared.sourceUpload)
|
|
135
|
+
throw new CliError("UPLOAD_CONTRACT_MISSING", "Isomorph did not return a safe upload contract.", operationRef);
|
|
136
|
+
if (prepared.nextAction?.tool && !matchesTool(prepared.nextAction.tool, "isomorph_complete_source_upload"))
|
|
137
|
+
throw new CliError("UNSUPPORTED_NEXT_STEP", "Isomorph returned an unsupported next step.", operationRef);
|
|
138
|
+
const archive = await archiveForManifest(root, prepared.acceptedManifest);
|
|
139
|
+
const parts = await putMultipart(prepared.sourceUpload, archive.body, (sent, total) => output(`Isomorph is uploading the app package (${Math.floor(sent * 100 / total)}%).`));
|
|
140
|
+
pending = { ...pending, uploadCompletion: { intentId: prepared.sourceUpload.intentId, parts, clientSha256: archive.digest } };
|
|
141
|
+
await writePending(root, pending);
|
|
142
|
+
}
|
|
143
|
+
if (!pending.uploaded && pending.uploadCompletion) {
|
|
144
|
+
await client.call("isomorph_complete_source_upload", { operationId: operationRef, ...pending.uploadCompletion });
|
|
145
|
+
pending = { ...pending, uploaded: true };
|
|
146
|
+
await writePending(root, pending);
|
|
147
|
+
output("Isomorph uploaded the secure app package and is inspecting it.");
|
|
148
|
+
}
|
|
149
|
+
if (!appId)
|
|
150
|
+
throw new CliError("APP_IDENTITY_MISSING", "The saved operation has no app identity.", operationRef);
|
|
151
|
+
if (!prior?.sourceSave)
|
|
152
|
+
await waitForIntake(client, operationRef);
|
|
153
|
+
let execution = prior?.sourceSave?.status && prior.sourceSave.status !== "AWAITING_APPROVAL" ? { status: prior.sourceSave.status } : await execute(client, operationRef, appId, graph);
|
|
154
|
+
if (execution.status === "APPROVAL_REQUIRED" || execution.approvalRequiredBeforeExternalAction === true) {
|
|
155
|
+
output(`Approval required: Isomorph can copy ${graph.deploymentScope.includedFiles.length} app files to the approved company code system. Nothing will be deployed or released. Type Approved. then press Enter.`);
|
|
156
|
+
const approval = await readApproval();
|
|
157
|
+
if (approval !== "Approved.")
|
|
158
|
+
throw new CliError("APPROVAL_NOT_GRANTED", "The Isomorph save was not approved.", operationRef);
|
|
159
|
+
execution = await execute(client, operationRef, appId, graph, { schema: "isomorph.stage-approval/1.0", step: "save_source_baseline", approved: true, approvedByUser: true, userApprovalText: approval, userVisibleProgress: "Approved.", nextStepSummary: "Isomorph can safely save the app.", dataOrActions: ["Save the app in the company code system."] });
|
|
160
|
+
}
|
|
161
|
+
if (execution.status === "ADMIN_SETUP_REQUIRED")
|
|
162
|
+
throw new CliError("ADMIN_SETUP_REQUIRED", "The company code connection needs one-time administrator setup.", operationRef);
|
|
163
|
+
if (execution.status === "FAILED")
|
|
164
|
+
throw new CliError("FAILED", "Isomorph could not save the app.", operationRef);
|
|
165
|
+
if (execution.status === "RETRYABLE_FAILURE")
|
|
166
|
+
throw new CliError("RETRYABLE_FAILURE", "Isomorph could not complete the save yet. Resume this operation safely.", operationRef);
|
|
167
|
+
const evidence = await waitForSave(client, operationRef, graph, execution, output);
|
|
168
|
+
const report = structured(await client.call("isomorph_build_verification_report", { operationId: operationRef, appId, surface: "codex", graph, sourceControlEvidence: evidence.sourceControlEvidence, runnerEvidence: evidence.runnerEvidence }));
|
|
169
|
+
output("Isomorph verified the saved app.");
|
|
170
|
+
const verification = safeVerificationResult(report, evidence);
|
|
171
|
+
if (options.waitForDeployment === false)
|
|
172
|
+
return { cliVersion: CLI_VERSION, operationRef, result: verification };
|
|
173
|
+
return finishDeployment(client, operationRef, output, options, verification);
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
throw await operationFailure(client, operationRef, error);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* What a builder is told to do when the checks have not passed. `isomorph check`
|
|
181
|
+
* exercises the retained journeys and the pipeline's operation-coverage gate
|
|
182
|
+
* against the running app, so both commands are named: the app has to be up
|
|
183
|
+
* before the checks that matter can run at all.
|
|
184
|
+
*/
|
|
185
|
+
const RUN_THE_CHECKS = "Run `isomorph check --app-root .` (it starts the local Isomorph services itself when `isomorph dev` is not running). Fix whatever it reports, then run this again.";
|
|
186
|
+
const FIX_THE_CHECKS = "Fix what the checks reported (the detail is in .isomorph/local/check-report.json), then run `isomorph check --app-root .` again.";
|
|
187
|
+
/**
|
|
188
|
+
* The checks the deployment pipeline replays in the cloud: each retained
|
|
189
|
+
* journey under its own `journey:<file>` name and the operation-coverage gate
|
|
190
|
+
* under the pipeline's rule for it. The kit gate records them as `not_run` —
|
|
191
|
+
* not as a pass — when an earlier check stopped the run, which is exactly the
|
|
192
|
+
* state that used to reach the pipeline and fail there.
|
|
193
|
+
*/
|
|
194
|
+
function isReplayedGate(name) {
|
|
195
|
+
return name === FLOW_CHECK || name.startsWith("journey:");
|
|
196
|
+
}
|
|
197
|
+
/** The retained checks this app keeps, enumerated exactly as `isomorph check` and the pipeline enumerate them. */
|
|
198
|
+
async function retainedChecks(root) {
|
|
199
|
+
return (await readdir(kitPaths(root).checks).catch(() => [])).filter(name => /\.(mjs|js|cjs)$/.test(name)).sort();
|
|
200
|
+
}
|
|
201
|
+
/** At most three names, so one long list of journey files cannot push the reason itself past the report cap. */
|
|
202
|
+
function nameList(names) {
|
|
203
|
+
return names.length > 3 ? `${names.slice(0, 3).join(", ")} and ${names.length - 3} more` : names.join(", ");
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Refuses a tree whose own retained checks have not passed *for that tree*.
|
|
207
|
+
*
|
|
208
|
+
* `isomorph check` writes `.isomorph/local/check-report.json` with the digest of
|
|
209
|
+
* the source it ran against, so three different things have to be true before a
|
|
210
|
+
* deployment is worth anyone's time: the checks ran, they ran against these
|
|
211
|
+
* bytes, and the ones that matter actually ran rather than being skipped.
|
|
212
|
+
*
|
|
213
|
+
* Scope: an app that keeps no retained checks is not gated. This is not a
|
|
214
|
+
* loophole — `.isomorph/checks/` is what the pipeline replays, and an app that
|
|
215
|
+
* keeps none is refused there by the same coverage gate the moment its code
|
|
216
|
+
* performs any operation. Gating it here would only refuse, with a fix it
|
|
217
|
+
* cannot carry out, every app that never adopted the kit.
|
|
218
|
+
*
|
|
219
|
+
* The digest is recomputed by the same `sourceDigest` `isomorph check` used, so
|
|
220
|
+
* a match means one function saw the same bytes twice and no parallel
|
|
221
|
+
* implementation can drift into refusing a checked tree. It spans the whole app
|
|
222
|
+
* boundary, so a `--include` submission is compared against the tree the checks
|
|
223
|
+
* actually ran on: conservative where the two differ, never permissive.
|
|
224
|
+
*/
|
|
225
|
+
async function assertChecksPassedForTree(root, output) {
|
|
226
|
+
const retained = await retainedChecks(root);
|
|
227
|
+
if (!retained.length)
|
|
228
|
+
return;
|
|
229
|
+
const plural = retained.length === 1 ? "" : "s";
|
|
230
|
+
const report = await readReport(root);
|
|
231
|
+
if (!report)
|
|
232
|
+
throw new CliError("CHECKS_NOT_RUN", `This app keeps ${retained.length} check${plural} that say whether it still works, and ${retained.length === 1 ? "it has" : "they have"} never been run against this code. Isomorph does not deploy an app whose own checks have not passed.`, undefined, RUN_THE_CHECKS);
|
|
233
|
+
const tree = await sourceDigest(root);
|
|
234
|
+
if (report.sourceDigest !== tree.digest)
|
|
235
|
+
throw new CliError("CHECKS_STALE", "The app's code has changed since its checks last ran, so that result no longer describes what would be deployed. Isomorph does not deploy an app whose own checks have not passed for this exact code.", undefined, RUN_THE_CHECKS);
|
|
236
|
+
const checks = Array.isArray(report.checks) ? report.checks : [];
|
|
237
|
+
const failed = checks.filter(check => check.status === "fail").map(check => check.name);
|
|
238
|
+
if (failed.length || report.passed !== true)
|
|
239
|
+
throw new CliError("CHECKS_FAILED", `The app's own checks last ran and did not pass${failed.length ? ` (${nameList(failed)})` : ""}. Isomorph does not deploy an app whose checks are failing; the deployment pipeline would refuse it too.`, undefined, FIX_THE_CHECKS);
|
|
240
|
+
// "Skipped" is not "passed". A report whose journeys and coverage gate never
|
|
241
|
+
// ran says nothing about whether the app works, and accepting it would hand
|
|
242
|
+
// the discovery straight back to the pipeline.
|
|
243
|
+
const notRun = checks.filter(check => isReplayedGate(check.name) && check.status !== "pass").map(check => check.name);
|
|
244
|
+
const flowRan = checks.some(check => check.name === FLOW_CHECK && check.status === "pass");
|
|
245
|
+
if (notRun.length || !flowRan) {
|
|
246
|
+
const names = notRun.length ? notRun : [FLOW_CHECK];
|
|
247
|
+
throw new CliError("CHECKS_NOT_RUN", `The last check run skipped ${nameList(names)} instead of passing ${names.length === 1 ? "it" : "them"}. The deployment pipeline runs the same ones in the cloud, so skipping them here only moves the failure.`, undefined, RUN_THE_CHECKS);
|
|
248
|
+
}
|
|
249
|
+
output(`The app's own checks passed for this exact code (${retained.length} retained check${plural}, last run ${report.createdAt}).`);
|
|
250
|
+
}
|
|
251
|
+
async function waitForIntake(client, operationRef) {
|
|
252
|
+
for (let attempt = 0; attempt < 60; attempt += 1) {
|
|
253
|
+
const status = structured(await client.call("isomorph_get_operation_status", { operationId: operationRef, waitSeconds: 3 }));
|
|
254
|
+
if (status.operation?.stage === "awaiting-approval" || status.sourceSave?.status === "QUEUED")
|
|
255
|
+
return;
|
|
256
|
+
if (status.operation?.stage === "failed")
|
|
257
|
+
throw intakeRejection(status, operationRef);
|
|
258
|
+
await new Promise(resolve => setTimeout(resolve, 1_000));
|
|
259
|
+
}
|
|
260
|
+
throw new CliError("INSPECTION_TIMEOUT", "Isomorph is still inspecting the app package. Resume this operation safely.", operationRef);
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Intake does not merely refuse a package; it records why — a stable code, one
|
|
264
|
+
* plain-English sentence, and the commands that fix it — on the operation. The
|
|
265
|
+
* stage alone was all this used to read, so every refusal reached the maker as
|
|
266
|
+
* the same sentence and the reason stayed on the server. Say what the server
|
|
267
|
+
* said, and keep the generic line only for an operation that failed without
|
|
268
|
+
* any detail at all.
|
|
269
|
+
*/
|
|
270
|
+
function intakeRejection(status, operationRef) {
|
|
271
|
+
return refusal(pickSourceFailure(status), "PACKAGE_REJECTED", "Isomorph could not safely accept this app package.", operationRef);
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* The one way this file turns "the server refused" into a failure the builder
|
|
275
|
+
* can act on: the server's own code, sentence and fix where it gave them, the
|
|
276
|
+
* generic pair only where it gave nothing, and the operation reference always.
|
|
277
|
+
*/
|
|
278
|
+
function refusal(failure, fallbackCode, fallbackMessage, operationRef) {
|
|
279
|
+
return new CliError(failure?.code ?? fallbackCode, failure?.message ?? fallbackMessage, operationRef, failure?.remediationHint);
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Why the whole operation stopped. The outer catch used to answer this with one
|
|
283
|
+
* fixed sentence for everything that was not already a `CliError`, which is
|
|
284
|
+
* every plain refusal the server raises — so the reason stayed on the server
|
|
285
|
+
* and the builder was told only that something failed. Read the reason from
|
|
286
|
+
* three places, in the order that serves the builder:
|
|
287
|
+
*
|
|
288
|
+
* 1. A `CliError` already carries a reason; keep it, and only make sure it
|
|
289
|
+
* names the operation, since by here one exists.
|
|
290
|
+
* 2. The operation is not failed at all but waiting for its profile, audience
|
|
291
|
+
* or secrets — say what is pending and which commands supply it.
|
|
292
|
+
* 3. Otherwise the thrown value's own detail, then the refusal intake recorded
|
|
293
|
+
* on the operation, and only then the generic line.
|
|
294
|
+
*/
|
|
295
|
+
async function operationFailure(client, operationRef, error) {
|
|
296
|
+
if (error instanceof CliError)
|
|
297
|
+
return error.operationRef ? error : new CliError(error.code, error.message, operationRef, error.remediationHint, error.result);
|
|
298
|
+
const detail = errorDetail(error);
|
|
299
|
+
const state = await probeOperation(client, operationRef);
|
|
300
|
+
// A pending-setup operation is not a failed operation: nothing is wrong with
|
|
301
|
+
// it and re-running `productionise` after the setup commands carries it on.
|
|
302
|
+
// It is still a failed *command* — no app was saved, verified or deployed —
|
|
303
|
+
// so it keeps a non-zero exit rather than reporting a success that did not
|
|
304
|
+
// happen, and carries the real next step instead of a bare code.
|
|
305
|
+
if (state.pending.length)
|
|
306
|
+
return new CliError(detail?.code ?? "SETUP_REQUIRED", state.plainEnglish ?? pendingSentence(state.pending), operationRef, setupRemediation(operationRef));
|
|
307
|
+
return refusal(detail ?? state.failure, "OPERATION_FAILED", "Isomorph could not complete the started operation.", operationRef);
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* What the thrown value itself says. A refusal reaches us as a bare `Error`
|
|
311
|
+
* carrying the server's sentence, and — once errors carry them — the server's
|
|
312
|
+
* stable code and remediation too; all of it must survive to the builder. A
|
|
313
|
+
* runtime fault (`TypeError` and friends) is a defect in this CLI rather than
|
|
314
|
+
* an answer for the builder, so its message is not borrowed as the reason
|
|
315
|
+
* unless it also carries a code.
|
|
316
|
+
*/
|
|
317
|
+
function errorDetail(error) {
|
|
318
|
+
if (!error || typeof error !== "object")
|
|
319
|
+
return undefined;
|
|
320
|
+
const raw = error;
|
|
321
|
+
const code = text(raw.code);
|
|
322
|
+
const remediationHint = text(raw.remediationHint);
|
|
323
|
+
const message = code || (error instanceof Error && error.constructor === Error) ? text(raw.message) : undefined;
|
|
324
|
+
if (!code && !message && !remediationHint)
|
|
325
|
+
return undefined;
|
|
326
|
+
return { ...(code ? { code } : {}), ...(message ? { message } : {}), ...(remediationHint ? { remediationHint } : {}) };
|
|
327
|
+
}
|
|
328
|
+
function text(value) {
|
|
329
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* What the server says about the operation once a call has failed. Both reads
|
|
333
|
+
* are best-effort: the reason the builder gets must never depend on a second
|
|
334
|
+
* call succeeding, so a probe that fails simply contributes nothing.
|
|
335
|
+
*/
|
|
336
|
+
async function probeOperation(client, operationRef) {
|
|
337
|
+
let failure;
|
|
338
|
+
try {
|
|
339
|
+
failure = pickSourceFailure(structured(await client.call("isomorph_get_operation_status", { operationId: operationRef, waitSeconds: 0 })));
|
|
340
|
+
}
|
|
341
|
+
catch { /* no status: fall back to what the error said */ }
|
|
342
|
+
try {
|
|
343
|
+
const setup = await getAppSetup(client, operationRef);
|
|
344
|
+
return { pending: setup.pending ?? [], ...(setup.nextAction?.plainEnglish ? { plainEnglish: setup.nextAction.plainEnglish } : {}), ...(failure ? { failure } : {}) };
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
return { pending: [], ...(failure ? { failure } : {}) };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
/** The console's post-save steps, in the words the CLI already uses for them. */
|
|
351
|
+
const SETUP_STEPS = {
|
|
352
|
+
confirm_app_profile: "its name and description confirmed",
|
|
353
|
+
confirm_app_audience: "its audience confirmed",
|
|
354
|
+
provide_secrets: "the secrets it asked for"
|
|
355
|
+
};
|
|
356
|
+
function pendingSentence(pending) {
|
|
357
|
+
return `The app still needs: ${pending.map(step => SETUP_STEPS[step] ?? step).join("; ")}.`;
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* `isomorph retry` is what the help offers for resuming an operation, but it
|
|
361
|
+
* restarts a deployment — on an operation still waiting for its setup there is
|
|
362
|
+
* no deployment to restart, and running `productionise` again is what carries
|
|
363
|
+
* it on. Name the commands that actually work, and say which one does not.
|
|
364
|
+
*/
|
|
365
|
+
function setupRemediation(operationRef) {
|
|
366
|
+
return `Run \`isomorph profile\` and \`isomorph audience\`, then \`isomorph productionise\` again — not \`isomorph retry\`. Full list: \`isomorph setup --operation ${operationRef}\`.`;
|
|
367
|
+
}
|
|
368
|
+
async function execute(client, operationRef, appId, graph, approval) {
|
|
369
|
+
const args = { operationId: operationRef, appId, surface: "codex", graph, action: "save_baseline", mode: "EXECUTE" };
|
|
370
|
+
if (approval)
|
|
371
|
+
args.approval = approval;
|
|
372
|
+
return structured(await client.call("isomorph_execute_source_control", args)).sourceControlExecution ?? {};
|
|
373
|
+
}
|
|
374
|
+
async function waitForSave(client, operationRef, graph, first, output) {
|
|
375
|
+
let waitSeconds = boundedWait(first.nextAction?.waitSeconds);
|
|
376
|
+
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
377
|
+
const status = structured(await client.call("isomorph_get_operation_status", { operationId: operationRef, waitSeconds }));
|
|
378
|
+
const terminal = status.sourceSave?.status;
|
|
379
|
+
if (terminal === "SUCCEEDED" || terminal === "DUPLICATE") {
|
|
380
|
+
const sourceControlEvidence = status.sourceControlEvidence ?? status.sourceSave?.sourceControlEvidence;
|
|
381
|
+
const runnerEvidence = status.runnerEvidence ?? status.sourceSave?.runnerEvidence;
|
|
382
|
+
if (!sourceControlEvidence || !runnerEvidence)
|
|
383
|
+
throw new CliError("EVIDENCE_MISSING", "Isomorph did not return managed save evidence.", operationRef);
|
|
384
|
+
return { sourceControlEvidence, runnerEvidence };
|
|
385
|
+
}
|
|
386
|
+
if (terminal === "FAILED")
|
|
387
|
+
throw new CliError("FAILED", "Isomorph could not save the app.", operationRef);
|
|
388
|
+
if (terminal === "RETRYABLE_FAILURE")
|
|
389
|
+
throw new CliError("RETRYABLE_FAILURE", "Isomorph could not complete the save yet. Resume this operation safely.", operationRef);
|
|
390
|
+
waitSeconds = boundedWait(status.nextAction?.waitSeconds ?? status.waitSeconds);
|
|
391
|
+
output("Isomorph is still saving the app.");
|
|
392
|
+
}
|
|
393
|
+
throw new CliError("RETRYABLE_FAILURE", "Isomorph could not finish the save within the safe retry window.", operationRef);
|
|
394
|
+
}
|
|
395
|
+
function boundedWait(value) {
|
|
396
|
+
return Math.min(15, Math.max(0, Number.isFinite(value) ? Math.floor(value) : 15));
|
|
397
|
+
}
|
|
398
|
+
/** Isomorph accepts dotted MCP names (its handler keys) while publishing portable underscore names. */
|
|
399
|
+
function matchesTool(actual, expected) {
|
|
400
|
+
return actual.replaceAll(".", "_") === expected;
|
|
401
|
+
}
|
|
402
|
+
function safeVerificationResult(value, statusEvidence) {
|
|
403
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
404
|
+
return { verification: "completed" };
|
|
405
|
+
const record = value;
|
|
406
|
+
const decision = record.decision && typeof record.decision === "object" && !Array.isArray(record.decision)
|
|
407
|
+
? record.decision
|
|
408
|
+
: undefined;
|
|
409
|
+
return {
|
|
410
|
+
verification: typeof decision?.status === "string"
|
|
411
|
+
? decision.status
|
|
412
|
+
: typeof record.status === "string"
|
|
413
|
+
? record.status
|
|
414
|
+
: "completed",
|
|
415
|
+
...(typeof decision?.assuranceLevel === "string" ? { assuranceLevel: decision.assuranceLevel } : {}),
|
|
416
|
+
...(typeof record.protectedUrl === "string" ? { protectedUrl: record.protectedUrl } : {}),
|
|
417
|
+
...((record.sourceControlEvidence ?? statusEvidence?.sourceControlEvidence) && typeof (record.sourceControlEvidence ?? statusEvidence?.sourceControlEvidence) === "object" && !Array.isArray(record.sourceControlEvidence ?? statusEvidence?.sourceControlEvidence)
|
|
418
|
+
? { sourceControlEvidence: record.sourceControlEvidence ?? statusEvidence?.sourceControlEvidence }
|
|
419
|
+
: {}),
|
|
420
|
+
...((record.runnerEvidence ?? statusEvidence?.runnerEvidence) && typeof (record.runnerEvidence ?? statusEvidence?.runnerEvidence) === "object" && !Array.isArray(record.runnerEvidence ?? statusEvidence?.runnerEvidence)
|
|
421
|
+
? { runnerEvidence: record.runnerEvidence ?? statusEvidence?.runnerEvidence }
|
|
422
|
+
: {})
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
function outcomeLine(summary) {
|
|
426
|
+
switch (summary.outcome) {
|
|
427
|
+
case "live": return `Isomorph deployed the app. Protected preview: ${summary.deployment?.protectedUrl ?? ""}`.trim();
|
|
428
|
+
case "production_live": return `The app is live in production: ${summary.production?.productionUrl ?? ""}`.trim();
|
|
429
|
+
case "waiting": return summary.waiting?.plainEnglish ?? "Isomorph needs one more step in the Isomorph console before it can deploy.";
|
|
430
|
+
// `running` and `no_deployment` both carry their own next step.
|
|
431
|
+
default: return summary.nextStep ?? "Isomorph saved the app; deployment has not started.";
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
const readApproval = readLine;
|
|
435
|
+
/**
|
|
436
|
+
* Whether the app really calls governed AI: the gate's inventory of
|
|
437
|
+
* `isomorph.ai.*` call sites (`.isomorph/local/check-report.json`,
|
|
438
|
+
* `gate.inventory.aiCallsites`). Not the capability word — a starter that
|
|
439
|
+
* merely carried an `ai()` accessor was derived as `ai` and would have been
|
|
440
|
+
* refused `AI_NOT_READY` at a company without AI for an app that never asks
|
|
441
|
+
* for it (2026-09-14). The call sites are what the deployment PLAN sees.
|
|
442
|
+
*/
|
|
443
|
+
export async function appCallsAi(root) {
|
|
444
|
+
const report = await readReport(root);
|
|
445
|
+
const callsites = report?.gate?.inventory?.aiCallsites;
|
|
446
|
+
return Array.isArray(callsites) && callsites.length > 0;
|
|
447
|
+
}
|
|
448
|
+
async function finishDeployment(client, operationRef, output, options, verification = {}) {
|
|
449
|
+
let summary;
|
|
450
|
+
try {
|
|
451
|
+
summary = options.waitForDeployment === false ? outcomeFor(await fetchStatus(client, operationRef), operationRef) : await follow(client, operationRef, output, options.waitOptions);
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
if (error instanceof CliError)
|
|
455
|
+
throw withOperation(error, operationRef);
|
|
456
|
+
throw new CliError("DEPLOYMENT_STATUS_UNAVAILABLE", `Isomorph could not follow the existing operation (${error instanceof Error ? error.message.replace(/https?:\/\/\S+|Bearer\s+\S+/gi, "").slice(0, 160) : "unknown error"}). Check again with \`isomorph status\`.`, operationRef);
|
|
457
|
+
}
|
|
458
|
+
output(outcomeLine(summary));
|
|
459
|
+
// The console asks for the app's name, audience and secrets before it
|
|
460
|
+
// offers promotion; say what is still open so a CLI-only builder knows.
|
|
461
|
+
let setup;
|
|
462
|
+
try {
|
|
463
|
+
setup = await getAppSetup(client, operationRef);
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
setup = undefined;
|
|
467
|
+
}
|
|
468
|
+
if (setup?.pending?.length)
|
|
469
|
+
output(`${setup.nextAction?.plainEnglish ?? "The app still needs setup."} Run \`isomorph setup --operation ${operationRef}\`.`);
|
|
470
|
+
return { cliVersion: CLI_VERSION, operationRef, result: { ...verification, ...summary, ...(setup ? { setup: { pending: setup.pending ?? [], ...(setup.nextAction?.plainEnglish ? { plainEnglish: setup.nextAction.plainEnglish } : {}) } } : {}) } };
|
|
471
|
+
}
|
|
472
|
+
function operationFinished(status) {
|
|
473
|
+
if (status.production)
|
|
474
|
+
return ["LIVE", "FAILED", "MANUAL_RECOVERY"].includes(status.production.state);
|
|
475
|
+
if (status.deployment)
|
|
476
|
+
return ["LIVE", "FAILED", "STALE", "RETRYABLE_FAILURE"].includes(status.deployment.state);
|
|
477
|
+
return status.operation?.stage === "failed";
|
|
478
|
+
}
|
|
479
|
+
async function readPending(root, tenantId, endpoint) {
|
|
480
|
+
let raw;
|
|
481
|
+
try {
|
|
482
|
+
raw = await readFile(resolve(root, ".isomorph/local/productionise.json"), "utf8");
|
|
483
|
+
}
|
|
484
|
+
catch (error) {
|
|
485
|
+
if (error.code === "ENOENT")
|
|
486
|
+
return undefined;
|
|
487
|
+
throw error;
|
|
488
|
+
}
|
|
489
|
+
const record = JSON.parse(raw);
|
|
490
|
+
if (!record.operationRef || !record.graph || typeof record.uploaded !== "boolean" || !record.sourceDigest)
|
|
491
|
+
throw new CliError("OPERATION_RECORD_INVALID", "The saved deployment reference cannot be read. Recover the operation from Isomorph before starting another.");
|
|
492
|
+
return record.tenantId === tenantId && record.endpoint === endpoint ? record : undefined;
|
|
493
|
+
}
|
|
494
|
+
async function writePending(root, record) {
|
|
495
|
+
const path = resolve(root, ".isomorph/local/productionise.json");
|
|
496
|
+
try {
|
|
497
|
+
await mkdir(dirname(path), { recursive: true });
|
|
498
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
499
|
+
await writeFile(temporary, JSON.stringify(record), { mode: 0o600 });
|
|
500
|
+
await rename(temporary, path);
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
throw new CliError("OPERATION_RECORD_WRITE_FAILED", "Isomorph started the operation but could not save its local reference. Continue with isomorph status using this operation reference.", record.operationRef);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
function withOperation(error, operationRef) {
|
|
507
|
+
return new CliError(error.code, error.message, operationRef, error.remediationHint, error.result);
|
|
508
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { CLI_VERSION } from "./version.js";
|
|
2
|
+
import { CliError } from "./output.js";
|
|
3
|
+
/** The code for a refusal the server did not label. Its own words still reach
|
|
4
|
+
* the builder; only the machine label is ours, and it says exactly that. */
|
|
5
|
+
const UNLABELLED_REFUSAL = "ISOMORPH_REFUSED";
|
|
6
|
+
/**
|
|
7
|
+
* Isomorph labels part of its refusals `CODE: sentence` on the wire — `src/mcp.ts`
|
|
8
|
+
* raises `APP_NOT_REGISTERED: …`, `SOURCE_UPLOAD_REQUIRED: …`, and the tool
|
|
9
|
+
* result carries that text and nothing else. Lifting the prefix into `code` is
|
|
10
|
+
* not classification: the label is the server's own, the sentence is kept
|
|
11
|
+
* whole either way, and a miss only means the refusal goes out unlabelled.
|
|
12
|
+
*/
|
|
13
|
+
const LABELLED = /^([A-Z][A-Z0-9_]{2,63}):[ \t]+(\S[\s\S]*)$/;
|
|
14
|
+
function refusalText(result) {
|
|
15
|
+
return (result.content ?? []).map(item => item.text ?? "").join("\n").trim();
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* A tool refusal, carried whole: the server's own code, its own sentence and
|
|
19
|
+
* its own fix. `structuredContent` is read first because that is where a code
|
|
20
|
+
* belongs and where the server will put one once `toolError` carries one; today
|
|
21
|
+
* only the labelled prefix is available.
|
|
22
|
+
*/
|
|
23
|
+
export function serverRefusal(result) {
|
|
24
|
+
const text = refusalText(result);
|
|
25
|
+
const detail = result.structuredContent && typeof result.structuredContent === "object" ? result.structuredContent : undefined;
|
|
26
|
+
const declared = typeof detail?.code === "string" ? detail.code.trim() : "";
|
|
27
|
+
const hint = typeof detail?.remediationHint === "string" && detail.remediationHint.trim() ? detail.remediationHint.trim() : undefined;
|
|
28
|
+
const labelled = declared ? null : LABELLED.exec(text);
|
|
29
|
+
return new CliError(declared || labelled?.[1] || UNLABELLED_REFUSAL, (labelled?.[2] ?? text) || "Isomorph rejected the request.", undefined, hint);
|
|
30
|
+
}
|
|
31
|
+
/** A JSON-RPC error is Isomorph refusing the call itself (an unknown method, a
|
|
32
|
+
* body it could not parse). Its numeric code is the only one it has, so it is
|
|
33
|
+
* reported as such rather than flattened away. */
|
|
34
|
+
function rpcRefusal(error, status) {
|
|
35
|
+
const text = error.message?.trim() ?? "";
|
|
36
|
+
const labelled = LABELLED.exec(text);
|
|
37
|
+
const code = labelled?.[1] ?? (typeof error.code === "number" ? `ISOMORPH_RPC_${error.code}` : `ISOMORPH_HTTP_${status}`);
|
|
38
|
+
return new CliError(code, (labelled?.[2] ?? text) || `Isomorph refused the request (HTTP ${status}).`);
|
|
39
|
+
}
|
|
40
|
+
async function readRpc(response) {
|
|
41
|
+
try {
|
|
42
|
+
return await response.json();
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
throw new Error(`Isomorph returned a response that was not JSON (HTTP ${response.status}).`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export class RemoteMcpClient {
|
|
49
|
+
url;
|
|
50
|
+
tenant;
|
|
51
|
+
id = 0;
|
|
52
|
+
initialized = false;
|
|
53
|
+
token;
|
|
54
|
+
constructor(url, token, tenant) {
|
|
55
|
+
this.url = url;
|
|
56
|
+
this.tenant = tenant;
|
|
57
|
+
this.token = typeof token === "string" ? async () => token : token;
|
|
58
|
+
}
|
|
59
|
+
get endpoint() { return this.url; }
|
|
60
|
+
async initialize() {
|
|
61
|
+
if (this.initialized)
|
|
62
|
+
return;
|
|
63
|
+
await this.request("initialize", { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "isomorph-cli", version: CLI_VERSION } });
|
|
64
|
+
await this.request("notifications/initialized", undefined, false);
|
|
65
|
+
this.initialized = true;
|
|
66
|
+
}
|
|
67
|
+
async call(name, args) {
|
|
68
|
+
const response = await this.request("tools/call", { name, arguments: args });
|
|
69
|
+
const result = response;
|
|
70
|
+
if (result.isError)
|
|
71
|
+
throw serverRefusal(result);
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
async request(method, params, expectResponse = true) {
|
|
75
|
+
const body = JSON.stringify({ jsonrpc: "2.0", id: ++this.id, method, ...(params === undefined ? {} : { params }) });
|
|
76
|
+
let response = await this.post(body);
|
|
77
|
+
// A 401 mid-command usually means the stored token was rotated by another
|
|
78
|
+
// process; re-resolve (which reloads the store) and retry once. A second
|
|
79
|
+
// 401 means the sign-in itself is gone.
|
|
80
|
+
if (response.status === 401)
|
|
81
|
+
response = await this.post(body);
|
|
82
|
+
if (response.status === 401)
|
|
83
|
+
throw new CliError("AUTH_REQUIRED", "Isomorph sign-in expired or was revoked. Run `isomorph login` again.");
|
|
84
|
+
if (!expectResponse)
|
|
85
|
+
return undefined;
|
|
86
|
+
const parsed = await readRpc(response);
|
|
87
|
+
if (parsed.error)
|
|
88
|
+
throw rpcRefusal(parsed.error, response.status);
|
|
89
|
+
// A non-ok response with no JSON-RPC error in it is the network between us
|
|
90
|
+
// and Isomorph, not Isomorph's answer, so it stays a plain Error: a long poll
|
|
91
|
+
// treats plain Errors as transient and tries again (see `waitForSettled`),
|
|
92
|
+
// and only `CliError` stops it. The wording is decided here rather than
|
|
93
|
+
// guessed from this string downstream.
|
|
94
|
+
if (!response.ok)
|
|
95
|
+
throw new Error(`Isomorph request failed (HTTP ${response.status}).`);
|
|
96
|
+
return parsed.result;
|
|
97
|
+
}
|
|
98
|
+
async post(body) {
|
|
99
|
+
const headers = { "content-type": "application/json", "mcp-protocol-version": "2025-11-25", "x-harbour-tenant": this.tenant };
|
|
100
|
+
const token = await this.token();
|
|
101
|
+
if (token)
|
|
102
|
+
headers.authorization = `Bearer ${token}`;
|
|
103
|
+
try {
|
|
104
|
+
return await fetch(this.url, { method: "POST", headers, body });
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
// Plain Error for the same reason as the non-ok branch: a dropped
|
|
108
|
+
// connection mid-deployment is transient and the poll must be free to
|
|
109
|
+
// retry. `redact()` strips the URL before any of this is printed.
|
|
110
|
+
throw new Error(`Isomorph could not be reached (${error instanceof Error ? error.message : "no details"}).`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
export function structured(result) {
|
|
115
|
+
if (result.isError)
|
|
116
|
+
throw serverRefusal(result);
|
|
117
|
+
return result.structuredContent;
|
|
118
|
+
}
|
|
119
|
+
export function plainText(result) {
|
|
120
|
+
return result.content?.map(item => item.text ?? "").join("\n") ?? "";
|
|
121
|
+
}
|