@isomorph.ai/cli 0.4.2 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -6
- package/dist/packages/harbour-cli/src/agent-setup.js +1 -1
- package/dist/packages/harbour-cli/src/check.js +42 -103
- package/dist/packages/harbour-cli/src/cli.js +54 -34
- package/dist/packages/harbour-cli/src/deploy.js +128 -330
- package/dist/packages/harbour-cli/src/guide.js +2 -2
- package/dist/packages/harbour-cli/src/integrations.js +66 -10
- package/dist/packages/harbour-cli/src/kit-bundle.manifest.js +12 -12
- package/dist/packages/harbour-cli/src/kit.js +5 -3
- package/dist/packages/harbour-cli/src/local-runtime.js +9 -8
- package/dist/packages/harbour-cli/src/operations.js +101 -71
- package/dist/packages/harbour-cli/src/output.js +11 -2
- package/dist/packages/harbour-cli/src/package.js +101 -0
- package/dist/packages/harbour-cli/src/starter.js +44 -22
- package/dist/packages/harbour-cli/src/upload.js +8 -2
- package/dist/src/analyzer.js +3 -17
- package/dist/src/secret-paths.js +6 -6
- package/package.json +2 -2
- package/dist/packages/harbour-cli/src/app-schema.js +0 -150
- package/dist/packages/harbour-cli/src/remote-mcp-client.js +0 -135
- package/dist/packages/harbour-cli/src/retained-checks.js +0 -443
- package/dist/packages/harbour-cli/src/source-inventory.js +0 -239
|
@@ -1,29 +1,26 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { basename, dirname, parse, resolve } from "node:path";
|
|
1
|
+
import { basename, dirname, resolve } from "node:path";
|
|
3
2
|
import { readFile, mkdir, rename, writeFile } from "node:fs/promises";
|
|
4
|
-
import { scanWorkspace } from "../../../src/analyzer.js";
|
|
5
3
|
import { createSourceManifest } from "../../../src/source-intake.js";
|
|
6
|
-
import { structured } from "./remote-mcp-client.js";
|
|
7
4
|
import { archiveForManifest, putMultipart } from "./upload.js";
|
|
8
5
|
import { CliError } from "./output.js";
|
|
9
|
-
import {
|
|
6
|
+
import { continueCommand, follow, outcomeFor, pickSourceFailure, recordedRefusal, runningLine, statusFetch, summarize } from "./operations.js";
|
|
10
7
|
import { CLI_VERSION } from "./version.js";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import { describeSourceChange, readAppProfile,
|
|
8
|
+
import { assertSafeAppRoot, readAppTree } from "./package.js";
|
|
9
|
+
import { deployBlocked, ensureLinkedApp } from "./integrations.js";
|
|
10
|
+
import { describeSourceChange, kitPaths, readAppProfile, readDeclaration, readKitLock, sourceDigest } from "./kit.js";
|
|
14
11
|
import { CHECKS_FAILED_HINT, checksFailedMessage, FLOW_CHECK, readReport, runChecks } from "./check.js";
|
|
15
12
|
import { runCommand } from "./local-runtime.js";
|
|
16
13
|
import { EMBEDDED_KIT_BUNDLE } from "./kit-bundle.js";
|
|
17
14
|
/**
|
|
18
|
-
* The
|
|
19
|
-
*
|
|
20
|
-
* this
|
|
21
|
-
* agent shows them before running `deploy`, and the wire schema is unchanged.
|
|
15
|
+
* The manifest built here is local: the server sees the package's digest and
|
|
16
|
+
* its file list, and opens (or resumes) the operation from those. The operation
|
|
17
|
+
* id in this manifest is therefore a fixed label, never sent.
|
|
22
18
|
*/
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
await
|
|
19
|
+
const LOCAL_MANIFEST_OPERATION = "local";
|
|
20
|
+
/** Deploys the private preview: one REST route to open the operation, one to hand over the package, then the status route until it settles. */
|
|
21
|
+
export async function deploy(rootArg, governance, output, tenantId, options = {}) {
|
|
22
|
+
const deployment = await runDeploy(rootArg, governance, output, tenantId, options);
|
|
23
|
+
await reportSession(rootArg, governance, deployment.operationRef, options);
|
|
27
24
|
return deployment;
|
|
28
25
|
}
|
|
29
26
|
/**
|
|
@@ -34,25 +31,23 @@ export async function deploy(rootArg, client, output, tenantId, options = {}) {
|
|
|
34
31
|
* outage, a missing Atlas token, or a runtime too old to have the feedback route
|
|
35
32
|
* must never fail, delay or alter a deployment the builder already asked for.
|
|
36
33
|
*/
|
|
37
|
-
async function reportSession(rootArg, operationRef, options) {
|
|
34
|
+
async function reportSession(rootArg, governance, operationRef, options) {
|
|
38
35
|
const notes = options.sessionNotes?.trim();
|
|
39
|
-
if (!notes
|
|
36
|
+
if (!notes)
|
|
40
37
|
return;
|
|
41
38
|
try {
|
|
42
|
-
const appId =
|
|
39
|
+
const appId = (await readKitLock(resolve(rootArg)))?.appId;
|
|
43
40
|
if (!appId)
|
|
44
41
|
return;
|
|
45
|
-
await
|
|
46
|
-
operationRef, cliVersion: CLI_VERSION, kitBundleVersion: options.integrations.bundle.kitVersion, notes
|
|
47
|
-
});
|
|
42
|
+
await governance.submitFeedback(appId, { operationRef, cliVersion: CLI_VERSION, kitBundleVersion: (options.bundle ?? EMBEDDED_KIT_BUNDLE).kitVersion, notes });
|
|
48
43
|
}
|
|
49
44
|
catch { /* never surfaced: the deployment is what the builder asked for. */ }
|
|
50
45
|
}
|
|
51
|
-
async function runDeploy(rootArg,
|
|
46
|
+
async function runDeploy(rootArg, governance, output, tenantId, options) {
|
|
52
47
|
const root = resolve(rootArg);
|
|
48
|
+
const bundle = options.bundle ?? EMBEDDED_KIT_BUNDLE;
|
|
53
49
|
output(`Isomorph is checking ${basename(root)}.`);
|
|
54
|
-
|
|
55
|
-
throw new CliError("PREFLIGHT_APP_ROOT", "The selected app boundary is unsafe.");
|
|
50
|
+
assertSafeAppRoot(root);
|
|
56
51
|
// Only a kit app deploys from here: the checks, the gate and the profile all
|
|
57
52
|
// live in `.isomorph/`. Anything else deploys from the console.
|
|
58
53
|
if (!(await readKitLock(root)))
|
|
@@ -63,63 +58,18 @@ async function runDeploy(rootArg, client, output, tenantId, options = {}) {
|
|
|
63
58
|
for (const line of describeProfile(profile))
|
|
64
59
|
output(line);
|
|
65
60
|
// Refused here, in the first second: a deploy with no description used to run
|
|
66
|
-
// the whole gate and open an operation before
|
|
67
|
-
//
|
|
68
|
-
//
|
|
61
|
+
// the whole gate and open an operation before the profile was refused (live,
|
|
62
|
+
// 2026-09-16). The deploy route records all three values, so it refuses one
|
|
63
|
+
// that is blank too; this only says so before the checks spend a minute.
|
|
69
64
|
if (!profile.name.trim() || !profile.description.trim())
|
|
70
65
|
throw new CliError("PROFILE_INCOMPLETE", "The app needs a name and a description in .isomorph/app.json before it can deploy.", undefined, "Edit `name` and `description` in .isomorph/app.json (show them to the person first), then run this again.");
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
try {
|
|
75
|
-
prior = await fetchStatus(client, pending.operationRef);
|
|
76
|
-
}
|
|
77
|
-
catch (error) {
|
|
78
|
-
if (error instanceof CliError)
|
|
79
|
-
throw withOperation(error, pending.operationRef);
|
|
80
|
-
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);
|
|
81
|
-
}
|
|
82
|
-
if (prior.operation?.stage === "failed" && pending.sourceDigest === (await sourceDigest(root)).digest)
|
|
83
|
-
throw intakeRejection(prior, pending.operationRef);
|
|
84
|
-
if (operationFinished(prior) && pending.sourceDigest !== (await sourceDigest(root)).digest)
|
|
85
|
-
pending = undefined;
|
|
86
|
-
}
|
|
87
|
-
else {
|
|
88
|
-
const appId = options.appId ?? (await readKitLock(root))?.appId;
|
|
89
|
-
if (appId) {
|
|
90
|
-
await client.initialize();
|
|
91
|
-
const recovered = structured(await client.call("isomorph_get_operation_status", { appId }));
|
|
92
|
-
if (!recovered || !Object.hasOwn(recovered, "operation"))
|
|
93
|
-
throw new CliError("RECOVERY_UNAVAILABLE", "This company needs Isomorph's deployment recovery update before shipping. No new deployment was started.");
|
|
94
|
-
if (recovered.operation?.operationId && !operationFinished(recovered)) {
|
|
95
|
-
prior = recovered;
|
|
96
|
-
pending = { tenantId, endpoint: client.endpoint, operationRef: recovered.operation.operationId, sourceDigest: (await sourceDigest(root)).digest, uploaded: Boolean(recovered.sourceSave || recovered.deployment || recovered.waiting || recovered.operation.stage === "awaiting-approval"), graph: await scanWorkspace(root, { sourceBoundary: "root" }), appId };
|
|
97
|
-
await writePending(root, pending);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
if (pending && (prior?.deployment || prior?.production || prior?.waiting || operationFinished(prior))) {
|
|
102
|
-
output("Isomorph is continuing the existing operation; no new deployment was started.");
|
|
103
|
-
return finishDeployment(client, pending.operationRef, output, options);
|
|
104
|
-
}
|
|
105
|
-
if (pending && prior?.sourceSave)
|
|
106
|
-
pending.uploaded = true;
|
|
107
|
-
// A record whose upload never finished pins nothing: nothing of the old tree
|
|
108
|
-
// reached Isomorph, so the changed tree is simply what this deploy carries.
|
|
109
|
-
// Only an upload already in progress (parts sent, completion pending) must
|
|
110
|
-
// see the bytes it started with.
|
|
111
|
-
if (pending && !pending.uploaded && pending.sourceDigest !== (await sourceDigest(root)).digest) {
|
|
112
|
-
if (pending.uploadCompletion)
|
|
113
|
-
throw new CliError("UPLOAD_SOURCE_CHANGED", "The app changed while its upload was in progress. Restore the submitted files to finish it, or wait for it to expire and deploy again.", pending.operationRef);
|
|
114
|
-
output("The app changed since the previous attempt started; deploying the current code on the same operation.");
|
|
115
|
-
pending = { ...pending, sourceDigest: (await sourceDigest(root)).digest, graph: await scanWorkspace(root, { sourceBoundary: "root" }) };
|
|
116
|
-
await writePending(root, pending);
|
|
117
|
-
}
|
|
66
|
+
const { declaration, errors } = await readDeclaration(root);
|
|
67
|
+
if (errors.length && !errors[0].startsWith(".isomorph/integrations.json is missing"))
|
|
68
|
+
throw new CliError("DECLARATION_INVALID", `.isomorph/integrations.json is invalid: ${errors[0]}`);
|
|
118
69
|
// Link first: the app's identity lands in `.isomorph/kit.lock.json`, which is
|
|
119
70
|
// one of the deployable files, so a report pinned after the link stays fresh
|
|
120
|
-
// through the
|
|
121
|
-
|
|
122
|
-
await ensureLinkedApp(root, options.integrations.governance, tenantId, options.integrations.bundle);
|
|
71
|
+
// through the checks and the package is built from the linked tree.
|
|
72
|
+
const appId = await ensureLinkedApp(root, governance, tenantId, bundle);
|
|
123
73
|
// The app's own checks are the cheapest gate Isomorph has and the most
|
|
124
74
|
// expensive one to discover in the cloud. Measured: a builder shipped without
|
|
125
75
|
// running `isomorph check`, the retained checks no longer matched the code,
|
|
@@ -127,133 +77,95 @@ async function runDeploy(rootArg, client, output, tenantId, options = {}) {
|
|
|
127
77
|
// discovery — for a mismatch `isomorph check` names locally in seconds. A
|
|
128
78
|
// report that passed for these exact bytes is trusted as it stands; anything
|
|
129
79
|
// else runs the checks here, once, before the tree is read into memory and
|
|
130
|
-
// long before any
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
}
|
|
158
|
-
let start;
|
|
159
|
-
const linked = kitAppId ? { appId: kitAppId } : {};
|
|
160
|
-
// TODO(kit-band-a): every call below sends `surface: "codex"`. `src/mcp.ts` validates `surface`
|
|
161
|
-
// against its SURFACES enum (codex, claude_code, lovable, replit, v0, other), which has no "cli"
|
|
162
|
-
// member yet; send "cli" once the server accepts it.
|
|
80
|
+
// long before any operation or upload. The gate writes into `.isomorph/` as
|
|
81
|
+
// it runs, so the tree is scanned only after it.
|
|
82
|
+
await ensureChecksPassed(root, output, options);
|
|
83
|
+
const { graph, files } = await readAppTree(root);
|
|
84
|
+
output(`Isomorph found ${files.length} app files.`);
|
|
85
|
+
// The package: built once, from the bytes read above; `archiveForManifest`
|
|
86
|
+
// re-reads every file and compares its sha256, so a tree that moved under us
|
|
87
|
+
// is refused here rather than uploaded. Its digest is the operation's
|
|
88
|
+
// identity on the server — the same package resumes the same operation.
|
|
89
|
+
const manifest = await createSourceManifest({ tenantId, appId, operationId: LOCAL_MANIFEST_OPERATION, graphDigest: graph.graphDigest, files });
|
|
90
|
+
const archive = await archiveForManifest(root, manifest);
|
|
91
|
+
const note = await readDeployNote(root, tenantId);
|
|
92
|
+
if (note?.sha256 === archive.digest)
|
|
93
|
+
output(`Isomorph is continuing operation ${note.operationRef}; no new deployment was started.`);
|
|
94
|
+
// One call opens the operation — or resumes it — and runs the deploy
|
|
95
|
+
// pre-flight first: a declared connection IT has not approved, a company
|
|
96
|
+
// whose AI setup is not ready for an app that calls it, or a CLI the platform
|
|
97
|
+
// no longer accepts would only park the deployment after the save, so
|
|
98
|
+
// governance refuses here, with every blocker at once, before any operation
|
|
99
|
+
// exists. The reviewed profile rides on the same call.
|
|
100
|
+
const body = {
|
|
101
|
+
environment: "preview",
|
|
102
|
+
app: { name: profile.name, description: profile.description, audience: profile.audience },
|
|
103
|
+
package: { sha256: archive.digest, bytes: archive.body.byteLength, manifest: { schema: "isomorph.source-package-manifest/1.0", files: manifest.files.map(file => ({ path: file.path, size: file.bytes, sha256: file.sha256 })) } },
|
|
104
|
+
graph, cliVersion: CLI_VERSION, kitBundleVersion: bundle.kitVersion, declaration, callsAi: await appCallsAi(root)
|
|
105
|
+
};
|
|
106
|
+
let opened;
|
|
163
107
|
try {
|
|
164
|
-
|
|
108
|
+
opened = await governance.startDeployment(appId, body);
|
|
165
109
|
}
|
|
166
110
|
catch (error) {
|
|
167
|
-
if (error instanceof CliError)
|
|
168
|
-
throw error;
|
|
169
|
-
|
|
111
|
+
if (error instanceof CliError && error.code === "DEPLOY_BLOCKED")
|
|
112
|
+
throw deployBlocked(error.details?.blockers, "preview", output);
|
|
113
|
+
// An older platform has no deployment route: say so rather than "HTTP_404", and that nothing was started.
|
|
114
|
+
if (error instanceof CliError && error.code === "HTTP_404")
|
|
115
|
+
throw new CliError("DEPLOY_ROUTE_UNAVAILABLE", "This company's Isomorph does not have the kit deployment route yet. No deployment was started.", undefined, "Ask your Isomorph administrator to update the platform.", undefined, { layer: "governance" });
|
|
116
|
+
if (error instanceof CliError && error.code === "DEPLOY_IN_FLIGHT") {
|
|
117
|
+
const inFlight = typeof error.details?.operationRef === "string" ? error.details.operationRef : undefined;
|
|
118
|
+
throw new CliError(error.code, error.message, inFlight, inFlight ? `Follow it with \`${continueCommand(inFlight)}\`; deploy again once it has settled.` : undefined, undefined, { layer: "governance" });
|
|
119
|
+
}
|
|
120
|
+
throw error;
|
|
170
121
|
}
|
|
171
|
-
const operationRef =
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
122
|
+
const { operationRef } = opened;
|
|
123
|
+
await writeDeployNote(root, { schema: "isomorph.deploy-note/1.0", tenantId, operationRef, sha256: archive.digest });
|
|
124
|
+
if (opened.resumed && note?.sha256 !== archive.digest)
|
|
125
|
+
output(`Isomorph is continuing operation ${operationRef}; no new deployment was started.`);
|
|
126
|
+
output(`Isomorph operation saved. Continue with ${continueCommand(operationRef)}.`);
|
|
127
|
+
const fetch = statusFetch(governance, appId, operationRef);
|
|
177
128
|
try {
|
|
178
|
-
let
|
|
179
|
-
if (
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
throw new CliError("APP_IDENTITY_MISSING", "Isomorph did not return the app identity for this operation.", operationRef);
|
|
186
|
-
if (kitAppId && appId !== kitAppId)
|
|
187
|
-
throw new CliError("APP_IDENTITY_MISMATCH", "Isomorph returned a different app identity than the one linked in .isomorph/kit.lock.json.", operationRef);
|
|
188
|
-
pending = { ...pending, appId };
|
|
189
|
-
await writePending(root, pending);
|
|
190
|
-
if (!pending.appSetupConfirmed) {
|
|
191
|
-
// An empty description defers to Isomorph's suggestion; a blank name never reaches here (readAppProfile).
|
|
192
|
-
await confirmProfile(client, operationRef, { displayName: profile.name, ...(profile.description ? { description: profile.description } : {}) }, output);
|
|
193
|
-
await confirmAudience(client, operationRef, profile.audience, output);
|
|
194
|
-
pending = { ...pending, appSetupConfirmed: true };
|
|
195
|
-
await writePending(root, pending);
|
|
196
|
-
}
|
|
197
|
-
const manifest = await createSourceManifest({ tenantId, appId, operationId: operationRef, graphDigest: graph.graphDigest, files });
|
|
198
|
-
const preliminaryArchive = await archiveForManifest(root, manifest);
|
|
199
|
-
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 })) } }));
|
|
200
|
-
if (!prepared.acceptedManifest || !prepared.sourceUpload)
|
|
201
|
-
throw new CliError("UPLOAD_CONTRACT_MISSING", "Isomorph did not return a safe upload contract.", operationRef);
|
|
202
|
-
if (prepared.nextAction?.tool && !matchesTool(prepared.nextAction.tool, "isomorph_complete_source_upload"))
|
|
203
|
-
throw new CliError("UNSUPPORTED_NEXT_STEP", "Isomorph returned an unsupported next step.", operationRef);
|
|
204
|
-
const archive = await archiveForManifest(root, prepared.acceptedManifest);
|
|
205
|
-
// The canonical identity lands in kit.lock so a later `integrations request` reuses it instead
|
|
206
|
-
// of linking a second app — written here, at the first moment it is safe to write it, and never
|
|
207
|
-
// earlier. `.isomorph/kit.lock.json` is one of the app's own deployable files; the package is
|
|
208
|
-
// built from bytes read at the top of this run, and `archiveForManifest` re-reads every file and
|
|
209
|
-
// compares its sha256 before uploading. Recording the id before that read made the CLI refuse
|
|
210
|
-
// its own edit — "The app changed while it was being packaged (.isomorph/kit.lock.json)" —
|
|
211
|
-
// deterministically on the first deployment of every new app, because `recordKitAppId` is a
|
|
212
|
-
// no-op once the id matches, so only a lock that has no id yet changes. Re-running succeeded and
|
|
213
|
-
// hid the cause (2026-09-15). Both reads are done by this line and the archive is in memory, so
|
|
214
|
-
// the app is still linked before anything can fail on the network.
|
|
215
|
-
await recordKitAppId(root, appId, tenantId);
|
|
216
|
-
const parts = await putMultipart(prepared.sourceUpload, archive.body, (sent, total) => output(`Isomorph is uploading the app package (${Math.floor(sent * 100 / total)}%).`));
|
|
217
|
-
pending = { ...pending, uploadCompletion: { intentId: prepared.sourceUpload.intentId, parts, clientSha256: archive.digest } };
|
|
218
|
-
await writePending(root, pending);
|
|
219
|
-
}
|
|
220
|
-
if (!pending.uploaded && pending.uploadCompletion) {
|
|
221
|
-
await client.call("isomorph_complete_source_upload", { operationId: operationRef, ...pending.uploadCompletion });
|
|
222
|
-
pending = { ...pending, uploaded: true };
|
|
223
|
-
await writePending(root, pending);
|
|
129
|
+
let status = opened.status;
|
|
130
|
+
if (opened.upload) {
|
|
131
|
+
const parts = await putMultipart(opened.upload, archive.body, (sent, total) => output(`Isomorph is uploading the app package (${Math.floor(sent * 100 / total)}%).`));
|
|
132
|
+
status = (await governance.completePackage(appId, operationRef, { intentId: opened.upload.intentId, parts, clientSha256: archive.digest })).status;
|
|
133
|
+
// From here the server carries the deployment on by itself: it inspects the
|
|
134
|
+
// package, copies the app to the approved company code system (nothing is
|
|
135
|
+
// deployed or released by that), verifies the saved copy, and deploys.
|
|
224
136
|
output("Isomorph uploaded the secure app package and is inspecting it.");
|
|
225
137
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
await writePending(root, pending);
|
|
138
|
+
else
|
|
139
|
+
output("Isomorph already has this app package and is continuing from where it stood.");
|
|
140
|
+
let summary;
|
|
141
|
+
if (options.waitForDeployment === false) {
|
|
142
|
+
// Not followed: what the server has, and the one command that follows it. Neither a success nor a failure yet.
|
|
143
|
+
summary = { ...outcomeIfSettled(status, operationRef), outcome: "running", nextStep: runningLine(status, 0, operationRef) };
|
|
233
144
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
145
|
+
else {
|
|
146
|
+
try {
|
|
147
|
+
summary = await follow(fetch, operationRef, output, options.waitOptions);
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
if (error instanceof CliError)
|
|
151
|
+
throw error;
|
|
152
|
+
throw new CliError("DEPLOYMENT_STATUS_UNAVAILABLE", `Isomorph could not follow the operation (${error instanceof Error ? error.message.replace(/https?:\/\/\S+|Bearer\s+\S+/gi, "").slice(0, 160) : "unknown error"}). Check again with \`isomorph status\`.`, operationRef);
|
|
153
|
+
}
|
|
154
|
+
output(outcomeLine(summary));
|
|
240
155
|
}
|
|
241
|
-
|
|
242
|
-
throw new CliError("ADMIN_SETUP_REQUIRED", "The company code connection needs one-time administrator setup.", operationRef);
|
|
243
|
-
if (execution.status === "FAILED")
|
|
244
|
-
throw new CliError("FAILED", "Isomorph could not save the app.", operationRef);
|
|
245
|
-
if (execution.status === "RETRYABLE_FAILURE")
|
|
246
|
-
throw new CliError("RETRYABLE_FAILURE", "Isomorph could not complete the save yet. Resume this operation safely.", operationRef);
|
|
247
|
-
const evidence = await waitForSave(client, operationRef, graph, execution, output);
|
|
248
|
-
const report = structured(await client.call("isomorph_build_verification_report", { operationId: operationRef, appId, surface: "codex", graph, sourceControlEvidence: evidence.sourceControlEvidence, runnerEvidence: evidence.runnerEvidence }));
|
|
249
|
-
output("Isomorph verified the saved app.");
|
|
250
|
-
const verification = safeVerificationResult(report, evidence);
|
|
251
|
-
if (options.waitForDeployment === false)
|
|
252
|
-
return { cliVersion: CLI_VERSION, operationRef, result: verification };
|
|
253
|
-
return finishDeployment(client, operationRef, output, options, verification);
|
|
156
|
+
return { cliVersion: CLI_VERSION, operationRef, result: summary };
|
|
254
157
|
}
|
|
255
158
|
catch (error) {
|
|
256
|
-
throw await operationFailure(
|
|
159
|
+
throw await operationFailure(fetch, operationRef, error);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/** The status as `--no-wait` hands it back: a settled outcome is named, an in-flight one is summarised as it stands. */
|
|
163
|
+
function outcomeIfSettled(status, operationRef) {
|
|
164
|
+
try {
|
|
165
|
+
return outcomeFor(status, operationRef);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return summarize(status);
|
|
257
169
|
}
|
|
258
170
|
}
|
|
259
171
|
/** What a builder is told to do when the checks failed here: the detail is in the report; fix, then run again. */
|
|
@@ -330,39 +242,9 @@ async function ensureChecksPassed(root, output, options) {
|
|
|
330
242
|
}
|
|
331
243
|
/** The checks as `isomorph check` runs them, with the bundle this deploy carries. */
|
|
332
244
|
function defaultRunChecks(options) {
|
|
333
|
-
const bundle = options.
|
|
245
|
+
const bundle = options.bundle ?? EMBEDDED_KIT_BUNDLE;
|
|
334
246
|
return (root, output) => runChecks(root, { run: options.checks?.run ?? runCommand, bundle, output });
|
|
335
247
|
}
|
|
336
|
-
async function waitForIntake(client, operationRef) {
|
|
337
|
-
for (let attempt = 0; attempt < 60; attempt += 1) {
|
|
338
|
-
const status = structured(await client.call("isomorph_get_operation_status", { operationId: operationRef, waitSeconds: 3 }));
|
|
339
|
-
if (status.operation?.stage === "awaiting-approval" || status.sourceSave?.status === "QUEUED")
|
|
340
|
-
return;
|
|
341
|
-
if (status.operation?.stage === "failed")
|
|
342
|
-
throw intakeRejection(status, operationRef);
|
|
343
|
-
await new Promise(resolve => setTimeout(resolve, 1_000));
|
|
344
|
-
}
|
|
345
|
-
throw new CliError("INSPECTION_TIMEOUT", "Isomorph is still inspecting the app package. Resume this operation safely.", operationRef);
|
|
346
|
-
}
|
|
347
|
-
/**
|
|
348
|
-
* Intake does not merely refuse a package; it records why — a stable code, one
|
|
349
|
-
* plain-English sentence, and the commands that fix it — on the operation. The
|
|
350
|
-
* stage alone was all this used to read, so every refusal reached the maker as
|
|
351
|
-
* the same sentence and the reason stayed on the server. Say what the server
|
|
352
|
-
* said, and keep the generic line only for an operation that failed without
|
|
353
|
-
* any detail at all.
|
|
354
|
-
*/
|
|
355
|
-
function intakeRejection(status, operationRef) {
|
|
356
|
-
return refusal(pickSourceFailure(status), "PACKAGE_REJECTED", "Isomorph could not safely accept this app package.", operationRef);
|
|
357
|
-
}
|
|
358
|
-
/**
|
|
359
|
-
* The one way this file turns "the server refused" into a failure the builder
|
|
360
|
-
* can act on: the server's own code, sentence and fix where it gave them, the
|
|
361
|
-
* generic pair only where it gave nothing, and the operation reference always.
|
|
362
|
-
*/
|
|
363
|
-
function refusal(failure, fallbackCode, fallbackMessage, operationRef) {
|
|
364
|
-
return new CliError(failure?.code ?? fallbackCode, failure?.message ?? fallbackMessage, operationRef, failure?.remediationHint, undefined, { layer: "governance" });
|
|
365
|
-
}
|
|
366
248
|
/**
|
|
367
249
|
* Why the whole operation stopped. The outer catch used to answer this with one
|
|
368
250
|
* fixed sentence for everything that was not already a `CliError`, which is
|
|
@@ -372,14 +254,14 @@ function refusal(failure, fallbackCode, fallbackMessage, operationRef) {
|
|
|
372
254
|
*
|
|
373
255
|
* 1. A `CliError` already carries a reason; keep it, and only make sure it
|
|
374
256
|
* names the operation, since by here one exists.
|
|
375
|
-
* 2. Otherwise the thrown value's own detail, then the refusal
|
|
376
|
-
* on the operation, and only then the generic line.
|
|
257
|
+
* 2. Otherwise the thrown value's own detail, then the refusal the server
|
|
258
|
+
* recorded on the operation, and only then the generic line.
|
|
377
259
|
*/
|
|
378
|
-
async function operationFailure(
|
|
260
|
+
async function operationFailure(fetch, operationRef, error) {
|
|
379
261
|
if (error instanceof CliError)
|
|
380
262
|
return error.operationRef ? error : withOperation(error, operationRef);
|
|
381
263
|
const detail = errorDetail(error);
|
|
382
|
-
return
|
|
264
|
+
return recordedRefusal(detail ?? await recordedFailure(fetch), "OPERATION_FAILED", "Isomorph could not complete the started operation.", operationRef);
|
|
383
265
|
}
|
|
384
266
|
/**
|
|
385
267
|
* What the thrown value itself says. A refusal reaches us as a bare `Error`
|
|
@@ -404,75 +286,18 @@ function text(value) {
|
|
|
404
286
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
405
287
|
}
|
|
406
288
|
/**
|
|
407
|
-
* The refusal
|
|
408
|
-
* the builder gets must never depend on a second call succeeding, so a
|
|
409
|
-
* that fails simply contributes nothing.
|
|
289
|
+
* The refusal the server recorded on the operation, if any. Best-effort: the
|
|
290
|
+
* reason the builder gets must never depend on a second call succeeding, so a
|
|
291
|
+
* probe that fails simply contributes nothing.
|
|
410
292
|
*/
|
|
411
|
-
async function recordedFailure(
|
|
293
|
+
async function recordedFailure(fetch) {
|
|
412
294
|
try {
|
|
413
|
-
return pickSourceFailure(
|
|
295
|
+
return pickSourceFailure(await fetch(0));
|
|
414
296
|
}
|
|
415
297
|
catch {
|
|
416
298
|
return undefined;
|
|
417
299
|
}
|
|
418
300
|
}
|
|
419
|
-
async function execute(client, operationRef, appId, graph, approval) {
|
|
420
|
-
const args = { operationId: operationRef, appId, surface: "codex", graph, action: "save_baseline", mode: "EXECUTE" };
|
|
421
|
-
if (approval)
|
|
422
|
-
args.approval = approval;
|
|
423
|
-
return structured(await client.call("isomorph_execute_source_control", args)).sourceControlExecution ?? {};
|
|
424
|
-
}
|
|
425
|
-
async function waitForSave(client, operationRef, graph, first, output) {
|
|
426
|
-
let waitSeconds = boundedWait(first.nextAction?.waitSeconds);
|
|
427
|
-
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
428
|
-
const status = structured(await client.call("isomorph_get_operation_status", { operationId: operationRef, waitSeconds }));
|
|
429
|
-
const terminal = status.sourceSave?.status;
|
|
430
|
-
if (terminal === "SUCCEEDED" || terminal === "DUPLICATE") {
|
|
431
|
-
const sourceControlEvidence = status.sourceControlEvidence ?? status.sourceSave?.sourceControlEvidence;
|
|
432
|
-
const runnerEvidence = status.runnerEvidence ?? status.sourceSave?.runnerEvidence;
|
|
433
|
-
if (!sourceControlEvidence || !runnerEvidence)
|
|
434
|
-
throw new CliError("EVIDENCE_MISSING", "Isomorph did not return managed save evidence.", operationRef);
|
|
435
|
-
return { sourceControlEvidence, runnerEvidence };
|
|
436
|
-
}
|
|
437
|
-
if (terminal === "FAILED")
|
|
438
|
-
throw new CliError("FAILED", "Isomorph could not save the app.", operationRef);
|
|
439
|
-
if (terminal === "RETRYABLE_FAILURE")
|
|
440
|
-
throw new CliError("RETRYABLE_FAILURE", "Isomorph could not complete the save yet. Resume this operation safely.", operationRef);
|
|
441
|
-
waitSeconds = boundedWait(status.nextAction?.waitSeconds ?? status.waitSeconds);
|
|
442
|
-
output("Isomorph is still saving the app.");
|
|
443
|
-
}
|
|
444
|
-
throw new CliError("RETRYABLE_FAILURE", "Isomorph could not finish the save within the safe retry window.", operationRef);
|
|
445
|
-
}
|
|
446
|
-
function boundedWait(value) {
|
|
447
|
-
return Math.min(15, Math.max(0, Number.isFinite(value) ? Math.floor(value) : 15));
|
|
448
|
-
}
|
|
449
|
-
/** Isomorph publishes and accepts exactly one tool spelling, `isomorph_<verb>`; a next step is compared as it is. */
|
|
450
|
-
function matchesTool(actual, expected) {
|
|
451
|
-
return actual === expected;
|
|
452
|
-
}
|
|
453
|
-
function safeVerificationResult(value, statusEvidence) {
|
|
454
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
455
|
-
return { verification: "completed" };
|
|
456
|
-
const record = value;
|
|
457
|
-
const decision = record.decision && typeof record.decision === "object" && !Array.isArray(record.decision)
|
|
458
|
-
? record.decision
|
|
459
|
-
: undefined;
|
|
460
|
-
return {
|
|
461
|
-
verification: typeof decision?.status === "string"
|
|
462
|
-
? decision.status
|
|
463
|
-
: typeof record.status === "string"
|
|
464
|
-
? record.status
|
|
465
|
-
: "completed",
|
|
466
|
-
...(typeof decision?.assuranceLevel === "string" ? { assuranceLevel: decision.assuranceLevel } : {}),
|
|
467
|
-
...(typeof record.protectedUrl === "string" ? { protectedUrl: record.protectedUrl } : {}),
|
|
468
|
-
...((record.sourceControlEvidence ?? statusEvidence?.sourceControlEvidence) && typeof (record.sourceControlEvidence ?? statusEvidence?.sourceControlEvidence) === "object" && !Array.isArray(record.sourceControlEvidence ?? statusEvidence?.sourceControlEvidence)
|
|
469
|
-
? { sourceControlEvidence: record.sourceControlEvidence ?? statusEvidence?.sourceControlEvidence }
|
|
470
|
-
: {}),
|
|
471
|
-
...((record.runnerEvidence ?? statusEvidence?.runnerEvidence) && typeof (record.runnerEvidence ?? statusEvidence?.runnerEvidence) === "object" && !Array.isArray(record.runnerEvidence ?? statusEvidence?.runnerEvidence)
|
|
472
|
-
? { runnerEvidence: record.runnerEvidence ?? statusEvidence?.runnerEvidence }
|
|
473
|
-
: {})
|
|
474
|
-
};
|
|
475
|
-
}
|
|
476
301
|
function outcomeLine(summary) {
|
|
477
302
|
switch (summary.outcome) {
|
|
478
303
|
case "live": return `Isomorph deployed the app. Protected preview: ${summary.deployment?.protectedUrl ?? ""}`.trim();
|
|
@@ -495,57 +320,30 @@ export async function appCallsAi(root) {
|
|
|
495
320
|
const callsites = report?.gate?.inventory?.aiCallsites;
|
|
496
321
|
return Array.isArray(callsites) && callsites.length > 0;
|
|
497
322
|
}
|
|
498
|
-
async function finishDeployment(client, operationRef, output, options, verification = {}) {
|
|
499
|
-
let summary;
|
|
500
|
-
try {
|
|
501
|
-
summary = options.waitForDeployment === false ? outcomeFor(await fetchStatus(client, operationRef), operationRef) : await follow(client, operationRef, output, options.waitOptions);
|
|
502
|
-
}
|
|
503
|
-
catch (error) {
|
|
504
|
-
if (error instanceof CliError)
|
|
505
|
-
throw withOperation(error, operationRef);
|
|
506
|
-
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);
|
|
507
|
-
}
|
|
508
|
-
output(outcomeLine(summary));
|
|
509
|
-
return { cliVersion: CLI_VERSION, operationRef, result: { ...verification, ...summary } };
|
|
510
|
-
}
|
|
511
323
|
/** The three values as the command prints them before it starts. */
|
|
512
324
|
export function describeProfile(profile) {
|
|
513
|
-
return [`App name: ${profile.name}`, `Description: ${profile.description || "(none yet
|
|
514
|
-
}
|
|
515
|
-
function operationFinished(status) {
|
|
516
|
-
if (status.production)
|
|
517
|
-
return ["LIVE", "FAILED", "MANUAL_RECOVERY"].includes(status.production.state);
|
|
518
|
-
if (status.deployment)
|
|
519
|
-
return ["LIVE", "FAILED", "STALE", "RETRYABLE_FAILURE"].includes(status.deployment.state);
|
|
520
|
-
return status.operation?.stage === "failed";
|
|
325
|
+
return [`App name: ${profile.name}`, `Description: ${profile.description || "(none yet)"}`, `Audience: ${profile.audience.length ? profile.audience.join(", ") : "only you"}`];
|
|
521
326
|
}
|
|
522
|
-
|
|
523
|
-
|
|
327
|
+
/** The last deployment this folder opened for this company, or nothing: a note that cannot be read is no note. */
|
|
328
|
+
export async function readDeployNote(root, tenantId) {
|
|
524
329
|
try {
|
|
525
|
-
|
|
330
|
+
const record = JSON.parse(await readFile(kitPaths(root).deployNote, "utf8"));
|
|
331
|
+
return record.schema === "isomorph.deploy-note/1.0" && record.tenantId === tenantId && typeof record.operationRef === "string" && typeof record.sha256 === "string" ? record : undefined;
|
|
526
332
|
}
|
|
527
|
-
catch
|
|
528
|
-
|
|
529
|
-
return undefined;
|
|
530
|
-
throw error;
|
|
333
|
+
catch {
|
|
334
|
+
return undefined;
|
|
531
335
|
}
|
|
532
|
-
const record = JSON.parse(raw);
|
|
533
|
-
if (!record.operationRef || !record.graph || typeof record.uploaded !== "boolean" || !record.sourceDigest)
|
|
534
|
-
throw new CliError("OPERATION_RECORD_INVALID", "The saved deployment reference cannot be read. Recover the operation from Isomorph before starting another.");
|
|
535
|
-
return record.tenantId === tenantId && record.endpoint === endpoint ? record : undefined;
|
|
536
336
|
}
|
|
537
|
-
async function
|
|
538
|
-
const path =
|
|
337
|
+
async function writeDeployNote(root, note) {
|
|
338
|
+
const path = kitPaths(root).deployNote;
|
|
539
339
|
try {
|
|
540
340
|
await mkdir(dirname(path), { recursive: true });
|
|
541
341
|
const temporary = `${path}.${process.pid}.tmp`;
|
|
542
|
-
await writeFile(temporary, JSON.stringify(
|
|
342
|
+
await writeFile(temporary, `${JSON.stringify(note, null, 2)}\n`, { mode: 0o600 });
|
|
543
343
|
await rename(temporary, path);
|
|
544
344
|
}
|
|
545
|
-
catch {
|
|
546
|
-
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);
|
|
547
|
-
}
|
|
345
|
+
catch { /* The server owns resume; the note only saves the builder typing the reference. */ }
|
|
548
346
|
}
|
|
549
347
|
function withOperation(error, operationRef) {
|
|
550
|
-
return new CliError(error.code, error.message, operationRef, error.remediationHint, error.result, { layer: error.layer, ...(error.paths ? { paths: error.paths } : {}) });
|
|
348
|
+
return new CliError(error.code, error.message, operationRef, error.remediationHint, error.result, { layer: error.layer, ...(error.paths ? { paths: error.paths } : {}), ...(error.details ? { details: error.details } : {}) });
|
|
551
349
|
}
|
|
@@ -32,7 +32,7 @@ CREATE POLICY notes_owner ON notes
|
|
|
32
32
|
|
|
33
33
|
Shared-read, owner-write: the same, plus \`CREATE POLICY posts_read ON posts FOR SELECT USING (true);\`.
|
|
34
34
|
|
|
35
|
-
\`.isomorph/checks/\` is generated by
|
|
35
|
+
\`.isomorph/checks/\` is generated by every check and not committed; your own checks go in \`tests/*.mjs\`. A refusal for uncovered surface names the file to write.
|
|
36
36
|
|
|
37
37
|
## Files and realtime
|
|
38
38
|
|
|
@@ -99,7 +99,7 @@ AI goes through \`isomorph.ai\` only: one \`isomorph.ai.chat({ messages, maxToke
|
|
|
99
99
|
const reply = await isomorph.ai.chat({ messages: [{ role: "user", content: \`Summarise these notes in three lines:\\n\${notes.map(n => n.title).join("\\n")}\` }], maxTokens: 200 });
|
|
100
100
|
\`\`\`
|
|
101
101
|
|
|
102
|
-
The starter calls no AI; add the one call when the person asks.
|
|
102
|
+
The starter calls no AI; add the one call when the person asks. The check's generated ai journey makes a real governed call locally through the development route with your \`isomorph login\`; in the pipeline it is answered by a canned completion and reported as not tested.`,
|
|
103
103
|
jobs: `# Scheduled jobs
|
|
104
104
|
|
|
105
105
|
Scheduled work lives only in \`jobs/<name>.ts\`: export one literal UTC cron as \`schedule\` and one default async handler. Never use \`setInterval\`, an effect or a browser timer as a scheduler; Isomorph runs the same declaration automatically after deployment, and the deployed Kubernetes schedule owns the real clock. Test it immediately: start \`isomorph dev\`, then \`isomorph jobs run <name> --app-root . --scheduled-at <matching-UTC-time> --json\` — local data and safe fixtures, nothing sent. Use \`--real\` only when the person explicitly asks to test the company action now and the exact action has development approval.
|