@isomorph.ai/cli 0.3.4 → 0.4.0-rc.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 +12 -14
- package/dist/packages/harbour-cli/src/agent-setup.js +25 -125
- package/dist/packages/harbour-cli/src/auth.js +1 -1
- package/dist/packages/harbour-cli/src/check.js +7 -34
- package/dist/packages/harbour-cli/src/cli.js +46 -100
- package/dist/packages/harbour-cli/src/config.js +51 -3
- package/dist/packages/harbour-cli/src/{productionise.js → deploy.js} +130 -183
- package/dist/packages/harbour-cli/src/dev.js +81 -2
- package/dist/packages/harbour-cli/src/guide.js +31 -45
- package/dist/packages/harbour-cli/src/integrations.js +47 -74
- package/dist/packages/harbour-cli/src/kit-bundle.js +0 -3
- package/dist/packages/harbour-cli/src/kit-bundle.manifest.js +9 -9
- package/dist/packages/harbour-cli/src/kit.js +43 -16
- package/dist/packages/harbour-cli/src/local-runtime.js +23 -1
- package/dist/packages/harbour-cli/src/operations.js +11 -90
- package/dist/packages/harbour-cli/src/output.js +24 -41
- package/dist/packages/harbour-cli/src/remote-mcp-client.js +4 -4
- package/dist/packages/harbour-cli/src/starter.js +21 -201
- package/dist/packages/harbour-cli/src/upload.js +1 -1
- package/dist/packages/harbour-cli/src/version.js +1 -1
- package/dist/src/analyzer.js +1 -1
- package/dist/src/secret-paths.js +1 -1
- package/package.json +2 -2
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { basename, dirname, parse, resolve } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { readFile, mkdir, rename, writeFile } from "node:fs/promises";
|
|
4
4
|
import { scanWorkspace } from "../../../src/analyzer.js";
|
|
5
5
|
import { createSourceManifest } from "../../../src/source-intake.js";
|
|
6
6
|
import { structured } from "./remote-mcp-client.js";
|
|
7
7
|
import { archiveForManifest, putMultipart } from "./upload.js";
|
|
8
8
|
import { CliError } from "./output.js";
|
|
9
|
-
import { confirmAudience, confirmProfile, follow,
|
|
9
|
+
import { confirmAudience, confirmProfile, follow, pickSourceFailure, fetchStatus, outcomeFor } from "./operations.js";
|
|
10
10
|
import { CLI_VERSION } from "./version.js";
|
|
11
11
|
import { isProhibitedSecretPath } from "../../../src/secret-paths.js";
|
|
12
|
-
import {
|
|
13
|
-
import { describeSourceChange,
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
import { assertDeployReady, ensureLinkedApp } from "./integrations.js";
|
|
13
|
+
import { describeSourceChange, readAppProfile, readKitLock, recordKitAppId, sourceDigest } from "./kit.js";
|
|
14
|
+
import { CHECKS_FAILED_HINT, checksFailedMessage, FLOW_CHECK, readReport, runChecks } from "./check.js";
|
|
15
|
+
import { runCommand } from "./local-runtime.js";
|
|
16
|
+
import { EMBEDDED_KIT_BUNDLE } from "./kit-bundle.js";
|
|
17
|
+
/**
|
|
18
|
+
* The save attestation governance records (`isomorph.stage-approval/1.0`). The
|
|
19
|
+
* CLI is run by an agent, so no terminal prompt stands between the command and
|
|
20
|
+
* this reply: the person's confirmation is the `.isomorph/app.json` review the
|
|
21
|
+
* agent shows them before running `deploy`, and the wire schema is unchanged.
|
|
22
|
+
*/
|
|
17
23
|
export const SAVE_APPROVAL = "Approved.";
|
|
18
|
-
export async function
|
|
19
|
-
const deployment = await
|
|
24
|
+
export async function deploy(rootArg, client, output, tenantId, options = {}) {
|
|
25
|
+
const deployment = await runDeploy(rootArg, client, output, tenantId, options);
|
|
20
26
|
await reportSession(rootArg, deployment.operationRef, options);
|
|
21
27
|
return deployment;
|
|
22
28
|
}
|
|
@@ -42,11 +48,20 @@ async function reportSession(rootArg, operationRef, options) {
|
|
|
42
48
|
}
|
|
43
49
|
catch { /* never surfaced: the deployment is what the builder asked for. */ }
|
|
44
50
|
}
|
|
45
|
-
async function
|
|
51
|
+
async function runDeploy(rootArg, client, output, tenantId, options = {}) {
|
|
46
52
|
const root = resolve(rootArg);
|
|
47
53
|
output(`Isomorph is checking ${basename(root)}.`);
|
|
48
54
|
if (root === parse(root).root || root === resolve(homedir()))
|
|
49
55
|
throw new CliError("PREFLIGHT_APP_ROOT", "The selected app boundary is unsafe.");
|
|
56
|
+
// Only a kit app deploys from here: the checks, the gate and the profile all
|
|
57
|
+
// live in `.isomorph/`. Anything else deploys from the console.
|
|
58
|
+
if (!(await readKitLock(root)))
|
|
59
|
+
throw new CliError("KIT_REQUIRED", "This folder is not an Isomorph app: it has no .isomorph/kit.lock.json.", undefined, "Run `isomorph init --app-root .` first.");
|
|
60
|
+
// The three values the person confirmed, printed so the transcript shows what
|
|
61
|
+
// was recorded; recorded on the operation before anything is uploaded.
|
|
62
|
+
const profile = await readAppProfile(root);
|
|
63
|
+
for (const line of describeProfile(profile))
|
|
64
|
+
output(line);
|
|
50
65
|
let pending = await readPending(root, tenantId, client.endpoint);
|
|
51
66
|
let prior;
|
|
52
67
|
if (pending) {
|
|
@@ -72,7 +87,7 @@ async function deploy(rootArg, client, output, tenantId, includePaths = [], opti
|
|
|
72
87
|
throw new CliError("RECOVERY_UNAVAILABLE", "This company needs Isomorph's deployment recovery update before shipping. No new deployment was started.");
|
|
73
88
|
if (recovered.operation?.operationId && !operationFinished(recovered)) {
|
|
74
89
|
prior = recovered;
|
|
75
|
-
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"
|
|
90
|
+
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 };
|
|
76
91
|
await writePending(root, pending);
|
|
77
92
|
}
|
|
78
93
|
}
|
|
@@ -86,55 +101,34 @@ async function deploy(rootArg, client, output, tenantId, includePaths = [], opti
|
|
|
86
101
|
if (pending && !pending.uploaded && pending.sourceDigest !== (await sourceDigest(root)).digest) {
|
|
87
102
|
throw new CliError("UPLOAD_SOURCE_CHANGED", "The app changed before its upload finished. Restore the submitted files before continuing this operation.", pending.operationRef);
|
|
88
103
|
}
|
|
89
|
-
|
|
90
|
-
|
|
104
|
+
// Link first: the app's identity lands in `.isomorph/kit.lock.json`, which is
|
|
105
|
+
// one of the deployable files, so a report pinned after the link stays fresh
|
|
106
|
+
// through the pre-flight and a refusal there does not cost a second gate run.
|
|
107
|
+
if (!pending?.uploaded && options.integrations)
|
|
108
|
+
await ensureLinkedApp(root, options.integrations.governance, tenantId, options.integrations.bundle);
|
|
109
|
+
// The app's own checks are the cheapest gate Isomorph has and the most
|
|
110
|
+
// expensive one to discover in the cloud. Measured: a builder shipped without
|
|
111
|
+
// running `isomorph check`, the retained checks no longer matched the code,
|
|
112
|
+
// and the pipeline's `flow` gate refused it twice — 2m23s and 2m12s per
|
|
113
|
+
// discovery — for a mismatch `isomorph check` names locally in seconds. A
|
|
114
|
+
// report that passed for these exact bytes is trusted as it stands; anything
|
|
115
|
+
// else runs the checks here, once, before the tree is read into memory and
|
|
116
|
+
// long before any grant lookup, operation or upload. The gate writes into
|
|
117
|
+
// `.isomorph/` as it runs, so the tree is scanned only after it.
|
|
118
|
+
if (!pending?.uploaded)
|
|
119
|
+
await ensureChecksPassed(root, output, options);
|
|
120
|
+
const graph = pending?.graph ?? await scanWorkspace(root, { sourceBoundary: "root" });
|
|
91
121
|
if (!graph.deploymentScope.includedFiles.length)
|
|
92
122
|
throw new CliError("PREFLIGHT_EMPTY", "The selected app boundary contains no eligible files.");
|
|
93
123
|
if (graph.deploymentScope.includedFiles.some(isProhibitedSecretPath))
|
|
94
124
|
throw new CliError("PREFLIGHT_SECRET_PATH", "The selected app boundary contains a prohibited secret file.");
|
|
95
|
-
|
|
96
|
-
// most expensive one to discover in the cloud. Measured: a builder shipped
|
|
97
|
-
// without running `isomorph check`, the retained checks no longer matched the
|
|
98
|
-
// code, and the pipeline's `flow` gate refused it twice — 2m23s and 2m12s per
|
|
99
|
-
// discovery — for a mismatch `isomorph check` names locally in about five
|
|
100
|
-
// seconds. Refuse here, before the tree is even read into memory, and long
|
|
101
|
-
// before any grant lookup, database replay, operation or upload.
|
|
102
|
-
if (!pending?.uploaded)
|
|
103
|
-
await assertChecksPassedForTree(root, output);
|
|
104
|
-
const readTree = async () => pending?.uploaded ? [] : await Promise.all(graph.deploymentScope.includedFiles.map(async (path) => ({ path, content: new Uint8Array(await readFile(resolve(root, path))) })));
|
|
105
|
-
let files = await readTree();
|
|
125
|
+
const files = pending?.uploaded ? [] : await Promise.all(graph.deploymentScope.includedFiles.map(async (path) => ({ path, content: new Uint8Array(await readFile(resolve(root, path))) })));
|
|
106
126
|
output(`Isomorph found ${graph.deploymentScope.includedFiles.length} app files.`);
|
|
107
|
-
// A declared connection IT has not approved
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
if (!pending?.uploaded && options.integrations && kitAppId && await appCallsAi(root))
|
|
113
|
-
await assertAiReady(kitAppId, "preview", options.integrations.governance, output);
|
|
114
|
-
// A kit app the pipeline's gate would refuse (a table without RLS, an
|
|
115
|
-
// operation no journey exercises, a cross-user leak) is refused here,
|
|
116
|
-
// before the save, by the same gate with the same wording.
|
|
117
|
-
const gate = options.kitGate === false ? undefined : options.kitGate ?? (options.integrations ? { bundle: options.integrations.bundle } : undefined);
|
|
118
|
-
if (gate && !pending?.uploaded) {
|
|
119
|
-
await preflightKitGate(root, gate.bundle, output, gate.run);
|
|
120
|
-
// The gate AUTHORS `.isomorph/checks/*.mjs` — the retained journeys the cloud replays. On an app
|
|
121
|
-
// that has never run `isomorph check` on its own they did not exist when the tree was scanned
|
|
122
|
-
// above, so the package shipped without them and the cloud gate, which has no journeys to run,
|
|
123
|
-
// refused every write the source performs as unexercised. Locally the same gate had just
|
|
124
|
-
// generated and passed them, so the app looked ready and the refusal named `src/App.tsx` rather
|
|
125
|
-
// than the missing files (2026-09-15). Re-read the tree the gate leaves behind.
|
|
126
|
-
if (!pending) {
|
|
127
|
-
graph = await scan();
|
|
128
|
-
files = await readTree();
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
// The journeys are the app's proof for the cloud gate; a package without the ones on disk is
|
|
132
|
-
// refused there, minutes later, in terms that point at the app instead of the package.
|
|
133
|
-
const packaged = new Set(graph.deploymentScope.includedFiles);
|
|
134
|
-
const unpackaged = (await retainedChecks(root)).filter(name => !packaged.has(`.isomorph/checks/${name}`));
|
|
135
|
-
if (!pending?.uploaded && unpackaged.length) {
|
|
136
|
-
throw new CliError("CHECKS_NOT_PACKAGED", `The app package does not carry its retained check${unpackaged.length === 1 ? "" : "s"} (${nameList(unpackaged)}), so the deployment would have nothing to replay.`, undefined, "Run `isomorph check --app-root .`, then run this again.");
|
|
137
|
-
}
|
|
127
|
+
// A declared connection IT has not approved, a company whose AI setup is not
|
|
128
|
+
// ready for an app that calls it, or a CLI the platform no longer accepts would
|
|
129
|
+
// only park the deployment after the save: ask governance once, here, before
|
|
130
|
+
// any operation exists, and refuse with every blocker at once.
|
|
131
|
+
const kitAppId = pending?.appId ?? (options.integrations ? await assertDeployReady(root, options.integrations.governance, tenantId, options.integrations.bundle, "preview", output, await appCallsAi(root)) : options.appId);
|
|
138
132
|
// A `CliError` is Isomorph's own answer (`AUTH_REQUIRED` after two 401s, a
|
|
139
133
|
// JSON-RPC refusal) and reaches the builder as it is; only a plain Error from
|
|
140
134
|
// the transport — Isomorph out of reach — is "not started". Reporting a
|
|
@@ -149,13 +143,16 @@ async function deploy(rootArg, client, output, tenantId, includePaths = [], opti
|
|
|
149
143
|
}
|
|
150
144
|
let start;
|
|
151
145
|
const linked = kitAppId ? { appId: kitAppId } : {};
|
|
146
|
+
// TODO(kit-band-a): every call below sends `surface: "codex"`. `src/mcp.ts` validates `surface`
|
|
147
|
+
// against its SURFACES enum (codex, claude_code, lovable, replit, v0, other), which has no "cli"
|
|
148
|
+
// member yet; send "cli" once the server accepts it.
|
|
152
149
|
try {
|
|
153
150
|
start = pending ? { operation: { operationId: pending.operationRef } } : structured(await client.call("isomorph_start_productionization", { appPath: basename(root), appName: basename(root), surface: "codex", ...linked }));
|
|
154
151
|
}
|
|
155
152
|
catch (error) {
|
|
156
153
|
if (error instanceof CliError)
|
|
157
154
|
throw error;
|
|
158
|
-
throw new CliError("START_UNCONFIRMED", "Isomorph did not confirm the start response. Run
|
|
155
|
+
throw new CliError("START_UNCONFIRMED", "Isomorph did not confirm the start response. Run deploy again to recover the app operation before starting another deployment.");
|
|
159
156
|
}
|
|
160
157
|
const operationRef = start.operation?.operationId;
|
|
161
158
|
if (!operationRef)
|
|
@@ -176,9 +173,10 @@ async function deploy(rootArg, client, output, tenantId, includePaths = [], opti
|
|
|
176
173
|
throw new CliError("APP_IDENTITY_MISMATCH", "Isomorph returned a different app identity than the one linked in .isomorph/kit.lock.json.", operationRef);
|
|
177
174
|
pending = { ...pending, appId };
|
|
178
175
|
await writePending(root, pending);
|
|
179
|
-
if (
|
|
180
|
-
|
|
181
|
-
await
|
|
176
|
+
if (!pending.appSetupConfirmed) {
|
|
177
|
+
// An empty description defers to Isomorph's suggestion; a blank name never reaches here (readAppProfile).
|
|
178
|
+
await confirmProfile(client, operationRef, { displayName: profile.name, ...(profile.description ? { description: profile.description } : {}) }, output);
|
|
179
|
+
await confirmAudience(client, operationRef, profile.audience, output);
|
|
182
180
|
pending = { ...pending, appSetupConfirmed: true };
|
|
183
181
|
await writePending(root, pending);
|
|
184
182
|
}
|
|
@@ -223,11 +221,8 @@ async function deploy(rootArg, client, output, tenantId, includePaths = [], opti
|
|
|
223
221
|
await waitForIntake(client, operationRef);
|
|
224
222
|
let execution = prior?.sourceSave?.status && prior.sourceSave.status !== "AWAITING_APPROVAL" ? { status: prior.sourceSave.status } : await execute(client, operationRef, appId, graph);
|
|
225
223
|
if (execution.status === "APPROVAL_REQUIRED" || execution.approvalRequiredBeforeExternalAction === true) {
|
|
226
|
-
output(`
|
|
227
|
-
|
|
228
|
-
if (approval !== SAVE_APPROVAL)
|
|
229
|
-
throw new CliError("APPROVAL_NOT_GRANTED", "The Isomorph save was not approved. Approve copying the app files with --confirm-save instead.", operationRef);
|
|
230
|
-
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."] });
|
|
224
|
+
output(`Isomorph is copying ${graph.deploymentScope.includedFiles.length} app files to the approved company code system. Nothing is deployed or released by that.`);
|
|
225
|
+
execution = await execute(client, operationRef, appId, graph, { schema: "isomorph.stage-approval/1.0", step: "save_source_baseline", approved: true, approvedByUser: true, userApprovalText: SAVE_APPROVAL, userVisibleProgress: SAVE_APPROVAL, nextStepSummary: "Isomorph can safely save the app.", dataOrActions: ["Save the app in the company code system."] });
|
|
231
226
|
}
|
|
232
227
|
if (execution.status === "ADMIN_SETUP_REQUIRED")
|
|
233
228
|
throw new CliError("ADMIN_SETUP_REQUIRED", "The company code connection needs one-time administrator setup.", operationRef);
|
|
@@ -247,14 +242,8 @@ async function deploy(rootArg, client, output, tenantId, includePaths = [], opti
|
|
|
247
242
|
throw await operationFailure(client, operationRef, error);
|
|
248
243
|
}
|
|
249
244
|
}
|
|
250
|
-
/**
|
|
251
|
-
|
|
252
|
-
* exercises the retained journeys and the pipeline's operation-coverage gate
|
|
253
|
-
* against the running app, so both commands are named: the app has to be up
|
|
254
|
-
* before the checks that matter can run at all.
|
|
255
|
-
*/
|
|
256
|
-
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.";
|
|
257
|
-
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.";
|
|
245
|
+
/** What a builder is told to do when the checks failed here: the detail is in the report; fix, then run again. */
|
|
246
|
+
const FIX_THE_CHECKS = "Fix what the checks reported, then run this again.";
|
|
258
247
|
/**
|
|
259
248
|
* The checks the deployment pipeline replays in the cloud: each retained
|
|
260
249
|
* journey under its own `journey:<file>` name and the operation-coverage gate
|
|
@@ -265,71 +254,70 @@ const FIX_THE_CHECKS = "Fix what the checks reported (the detail is in .isomorph
|
|
|
265
254
|
function isReplayedGate(name) {
|
|
266
255
|
return name === FLOW_CHECK || name.startsWith("journey:");
|
|
267
256
|
}
|
|
268
|
-
/**
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
257
|
+
/**
|
|
258
|
+
* The gates that matter, as a report records them: the replayed gates that did
|
|
259
|
+
* not pass (`not_run` is a skip, never a pass), or none.
|
|
260
|
+
*/
|
|
261
|
+
function skippedGates(report) {
|
|
262
|
+
const checks = Array.isArray(report.checks) ? report.checks : [];
|
|
263
|
+
const notRun = checks.filter(check => isReplayedGate(check.name) && check.status !== "pass").map(check => check.name);
|
|
264
|
+
const flowRan = checks.some(check => check.name === FLOW_CHECK && check.status === "pass");
|
|
265
|
+
return notRun.length || flowRan ? notRun : [FLOW_CHECK];
|
|
275
266
|
}
|
|
276
267
|
/**
|
|
277
|
-
*
|
|
268
|
+
* Makes sure the checks have passed *for this tree*, running them when they
|
|
269
|
+
* have not.
|
|
278
270
|
*
|
|
279
271
|
* `isomorph check` writes `.isomorph/local/check-report.json` with the digest of
|
|
280
|
-
* the source it ran against
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
* performs any operation. Gating it here would only refuse, with a fix it
|
|
288
|
-
* cannot carry out, every app that never adopted the kit.
|
|
272
|
+
* the source it ran against. A report is trusted when three things are true:
|
|
273
|
+
* the checks ran, they ran against these bytes, and the ones that matter
|
|
274
|
+
* actually ran rather than being skipped. Then nothing is run again — the gate
|
|
275
|
+
* takes 30–60 s and its answer for the same bytes is the same. Any other state
|
|
276
|
+
* (no report, a different tree, a skipped gate) runs the same checks `isomorph
|
|
277
|
+
* check` runs and carries on in this invocation; a report that ran and failed
|
|
278
|
+
* is refused as it stands, since running it again changes nothing.
|
|
289
279
|
*
|
|
290
280
|
* The digest is recomputed by the same `sourceDigest` `isomorph check` used, so
|
|
291
281
|
* a match means one function saw the same bytes twice and no parallel
|
|
292
|
-
* implementation can drift into refusing a checked tree.
|
|
293
|
-
* boundary, so a `--include` submission is compared against the tree the checks
|
|
294
|
-
* actually ran on: conservative where the two differ, never permissive.
|
|
282
|
+
* implementation can drift into refusing a checked tree.
|
|
295
283
|
*/
|
|
296
|
-
async function
|
|
297
|
-
const
|
|
298
|
-
if (!retained.length)
|
|
299
|
-
return;
|
|
300
|
-
const plural = retained.length === 1 ? "" : "s";
|
|
301
|
-
const report = await readReport(root);
|
|
302
|
-
if (!report)
|
|
303
|
-
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);
|
|
284
|
+
async function ensureChecksPassed(root, output, options) {
|
|
285
|
+
const previous = await readReport(root);
|
|
304
286
|
const tree = await sourceDigest(root);
|
|
305
|
-
if (
|
|
306
|
-
|
|
287
|
+
if (previous && previous.sourceDigest === tree.digest) {
|
|
288
|
+
const checks = Array.isArray(previous.checks) ? previous.checks : [];
|
|
289
|
+
const failed = checks.filter(check => check.status === "fail").map(check => check.name);
|
|
290
|
+
if (failed.length || previous.passed !== true)
|
|
291
|
+
throw new CliError("CHECKS_FAILED", `The app's own checks last ran and did not pass${failed.length ? ` (${failed.length} check${failed.length === 1 ? "" : "s"})` : ""}. Isomorph does not deploy an app whose checks are failing; the deployment pipeline would refuse it too.`, undefined, FIX_THE_CHECKS, undefined, { paths: failed });
|
|
292
|
+
const skipped = skippedGates(previous);
|
|
293
|
+
if (!skipped.length) {
|
|
294
|
+
output(`The app's own checks passed for this exact code (last run ${previous.createdAt}).`);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
output(`The last check run skipped ${skipped.join(", ")}; running the checks again.`);
|
|
298
|
+
}
|
|
299
|
+
else if (previous) {
|
|
300
|
+
// Name the paths. Without them the line is true but unactionable: a
|
|
307
301
|
// builder whose agent wrote scratch files into a gitignored folder inside
|
|
308
302
|
// the app root hit this three times before finding the folder, because
|
|
309
303
|
// everything inside the root is inside the deployment boundary whether git
|
|
310
|
-
// tracks it or not.
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
// DETAIL_BOUND. That bound keeps both ENDS and elides the middle, precisely
|
|
314
|
-
// because a filename once sat at index 266 and was never printed — so a
|
|
315
|
-
// path placed mid-sentence is put in the one position the bounding deletes.
|
|
316
|
-
const moved = describeSourceChange(report.sourceFiles, tree.entries);
|
|
317
|
-
throw new CliError("CHECKS_STALE", `The app's code has changed since its checks last ran${moved ? ` (${moved})` : ""}. Isomorph does not deploy an app whose checks have not passed for this exact code; everything inside the app folder counts, including files git ignores.`, undefined, RUN_THE_CHECKS);
|
|
318
|
-
}
|
|
319
|
-
const checks = Array.isArray(report.checks) ? report.checks : [];
|
|
320
|
-
const failed = checks.filter(check => check.status === "fail").map(check => check.name);
|
|
321
|
-
if (failed.length || report.passed !== true)
|
|
322
|
-
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);
|
|
323
|
-
// "Skipped" is not "passed". A report whose journeys and coverage gate never
|
|
324
|
-
// ran says nothing about whether the app works, and accepting it would hand
|
|
325
|
-
// the discovery straight back to the pipeline.
|
|
326
|
-
const notRun = checks.filter(check => isReplayedGate(check.name) && check.status !== "pass").map(check => check.name);
|
|
327
|
-
const flowRan = checks.some(check => check.name === FLOW_CHECK && check.status === "pass");
|
|
328
|
-
if (notRun.length || !flowRan) {
|
|
329
|
-
const names = notRun.length ? notRun : [FLOW_CHECK];
|
|
330
|
-
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);
|
|
304
|
+
// tracks it or not.
|
|
305
|
+
const moved = describeSourceChange(previous.sourceFiles, tree.entries);
|
|
306
|
+
output(`The app's code has changed since its checks last ran${moved ? ` (${moved})` : ""}; running the checks first.`);
|
|
331
307
|
}
|
|
332
|
-
|
|
308
|
+
else
|
|
309
|
+
output("The app's checks have not run for this code yet; running them first.");
|
|
310
|
+
const report = await (options.checks?.runChecks ?? defaultRunChecks(options))(root, output);
|
|
311
|
+
if (!report.passed)
|
|
312
|
+
throw new CliError("CHECKS_FAILED", checksFailedMessage(report), undefined, CHECKS_FAILED_HINT, undefined, { paths: report.checks.filter(check => check.status === "fail").map(check => check.name) });
|
|
313
|
+
const skipped = skippedGates(report);
|
|
314
|
+
if (skipped.length)
|
|
315
|
+
throw new CliError("CHECKS_NOT_RUN", `The checks skipped ${skipped.length} gate${skipped.length === 1 ? "" : "s"} instead of passing ${skipped.length === 1 ? "it" : "them"}. The deployment pipeline runs the same ones in the cloud, so skipping them here only moves the failure.`, undefined, "Start `isomorph dev --app-root .`, run `isomorph check --app-root .`, then run this again.", undefined, { paths: skipped });
|
|
316
|
+
}
|
|
317
|
+
/** The checks as `isomorph check` runs them, with the bundle this deploy carries. */
|
|
318
|
+
function defaultRunChecks(options) {
|
|
319
|
+
const bundle = options.integrations?.bundle ?? EMBEDDED_KIT_BUNDLE;
|
|
320
|
+
return (root, output) => runChecks(root, { run: options.checks?.run ?? runCommand, bundle, output });
|
|
333
321
|
}
|
|
334
322
|
async function waitForIntake(client, operationRef) {
|
|
335
323
|
for (let attempt = 0; attempt < 60; attempt += 1) {
|
|
@@ -359,7 +347,7 @@ function intakeRejection(status, operationRef) {
|
|
|
359
347
|
* generic pair only where it gave nothing, and the operation reference always.
|
|
360
348
|
*/
|
|
361
349
|
function refusal(failure, fallbackCode, fallbackMessage, operationRef) {
|
|
362
|
-
return new CliError(failure?.code ?? fallbackCode, failure?.message ?? fallbackMessage, operationRef, failure?.remediationHint);
|
|
350
|
+
return new CliError(failure?.code ?? fallbackCode, failure?.message ?? fallbackMessage, operationRef, failure?.remediationHint, undefined, { layer: "governance" });
|
|
363
351
|
}
|
|
364
352
|
/**
|
|
365
353
|
* Why the whole operation stopped. The outer catch used to answer this with one
|
|
@@ -370,24 +358,14 @@ function refusal(failure, fallbackCode, fallbackMessage, operationRef) {
|
|
|
370
358
|
*
|
|
371
359
|
* 1. A `CliError` already carries a reason; keep it, and only make sure it
|
|
372
360
|
* names the operation, since by here one exists.
|
|
373
|
-
* 2.
|
|
374
|
-
* or secrets — say what is pending and which commands supply it.
|
|
375
|
-
* 3. Otherwise the thrown value's own detail, then the refusal intake recorded
|
|
361
|
+
* 2. Otherwise the thrown value's own detail, then the refusal intake recorded
|
|
376
362
|
* on the operation, and only then the generic line.
|
|
377
363
|
*/
|
|
378
364
|
async function operationFailure(client, operationRef, error) {
|
|
379
365
|
if (error instanceof CliError)
|
|
380
|
-
return error.operationRef ? error :
|
|
366
|
+
return error.operationRef ? error : withOperation(error, operationRef);
|
|
381
367
|
const detail = errorDetail(error);
|
|
382
|
-
|
|
383
|
-
// A pending-setup operation is not a failed operation: nothing is wrong with
|
|
384
|
-
// it and re-running `productionise` after the setup commands carries it on.
|
|
385
|
-
// It is still a failed *command* — no app was saved, verified or deployed —
|
|
386
|
-
// so it keeps a non-zero exit rather than reporting a success that did not
|
|
387
|
-
// happen, and carries the real next step instead of a bare code.
|
|
388
|
-
if (state.pending.length)
|
|
389
|
-
return new CliError(detail?.code ?? "SETUP_REQUIRED", state.plainEnglish ?? pendingSentence(state.pending), operationRef, setupRemediation(operationRef));
|
|
390
|
-
return refusal(detail ?? state.failure, "OPERATION_FAILED", "Isomorph could not complete the started operation.", operationRef);
|
|
368
|
+
return refusal(detail ?? await recordedFailure(client, operationRef), "OPERATION_FAILED", "Isomorph could not complete the started operation.", operationRef);
|
|
391
369
|
}
|
|
392
370
|
/**
|
|
393
371
|
* What the thrown value itself says. A refusal reaches us as a bare `Error`
|
|
@@ -412,42 +390,18 @@ function text(value) {
|
|
|
412
390
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
413
391
|
}
|
|
414
392
|
/**
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
393
|
+
* The refusal intake recorded on the operation, if any. Best-effort: the reason
|
|
394
|
+
* the builder gets must never depend on a second call succeeding, so a probe
|
|
395
|
+
* that fails simply contributes nothing.
|
|
418
396
|
*/
|
|
419
|
-
async function
|
|
420
|
-
let failure;
|
|
397
|
+
async function recordedFailure(client, operationRef) {
|
|
421
398
|
try {
|
|
422
|
-
|
|
423
|
-
}
|
|
424
|
-
catch { /* no status: fall back to what the error said */ }
|
|
425
|
-
try {
|
|
426
|
-
const setup = await getAppSetup(client, operationRef);
|
|
427
|
-
return { pending: setup.pending ?? [], ...(setup.nextAction?.plainEnglish ? { plainEnglish: setup.nextAction.plainEnglish } : {}), ...(failure ? { failure } : {}) };
|
|
399
|
+
return pickSourceFailure(structured(await client.call("isomorph_get_operation_status", { operationId: operationRef, waitSeconds: 0 })));
|
|
428
400
|
}
|
|
429
401
|
catch {
|
|
430
|
-
return
|
|
402
|
+
return undefined;
|
|
431
403
|
}
|
|
432
404
|
}
|
|
433
|
-
/** The console's post-save steps, in the words the CLI already uses for them. */
|
|
434
|
-
const SETUP_STEPS = {
|
|
435
|
-
confirm_app_profile: "its name and description confirmed",
|
|
436
|
-
confirm_app_audience: "its audience confirmed",
|
|
437
|
-
provide_secrets: "the secrets it asked for"
|
|
438
|
-
};
|
|
439
|
-
function pendingSentence(pending) {
|
|
440
|
-
return `The app still needs: ${pending.map(step => SETUP_STEPS[step] ?? step).join("; ")}.`;
|
|
441
|
-
}
|
|
442
|
-
/**
|
|
443
|
-
* `isomorph retry` is what the help offers for resuming an operation, but it
|
|
444
|
-
* restarts a deployment — on an operation still waiting for its setup there is
|
|
445
|
-
* no deployment to restart, and running `productionise` again is what carries
|
|
446
|
-
* it on. Name the commands that actually work, and say which one does not.
|
|
447
|
-
*/
|
|
448
|
-
function setupRemediation(operationRef) {
|
|
449
|
-
return `Run \`isomorph profile\` and \`isomorph audience\`, then \`isomorph productionise\` again — not \`isomorph retry\`. Full list: \`isomorph setup --operation ${operationRef}\`.`;
|
|
450
|
-
}
|
|
451
405
|
async function execute(client, operationRef, appId, graph, approval) {
|
|
452
406
|
const args = { operationId: operationRef, appId, surface: "codex", graph, action: "save_baseline", mode: "EXECUTE" };
|
|
453
407
|
if (approval)
|
|
@@ -478,9 +432,9 @@ async function waitForSave(client, operationRef, graph, first, output) {
|
|
|
478
432
|
function boundedWait(value) {
|
|
479
433
|
return Math.min(15, Math.max(0, Number.isFinite(value) ? Math.floor(value) : 15));
|
|
480
434
|
}
|
|
481
|
-
/** Isomorph accepts
|
|
435
|
+
/** Isomorph publishes and accepts exactly one tool spelling, `isomorph_<verb>`; a next step is compared as it is. */
|
|
482
436
|
function matchesTool(actual, expected) {
|
|
483
|
-
return actual
|
|
437
|
+
return actual === expected;
|
|
484
438
|
}
|
|
485
439
|
function safeVerificationResult(value, statusEvidence) {
|
|
486
440
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
@@ -538,18 +492,11 @@ async function finishDeployment(client, operationRef, output, options, verificat
|
|
|
538
492
|
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);
|
|
539
493
|
}
|
|
540
494
|
output(outcomeLine(summary));
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
}
|
|
547
|
-
catch {
|
|
548
|
-
setup = undefined;
|
|
549
|
-
}
|
|
550
|
-
if (setup?.pending?.length)
|
|
551
|
-
output(`${setup.nextAction?.plainEnglish ?? "The app still needs setup."} Run \`isomorph setup --operation ${operationRef}\`.`);
|
|
552
|
-
return { cliVersion: CLI_VERSION, operationRef, result: { ...verification, ...summary, ...(setup ? { setup: { pending: setup.pending ?? [], ...(setup.nextAction?.plainEnglish ? { plainEnglish: setup.nextAction.plainEnglish } : {}) } } : {}) } };
|
|
495
|
+
return { cliVersion: CLI_VERSION, operationRef, result: { ...verification, ...summary } };
|
|
496
|
+
}
|
|
497
|
+
/** The three values as the command prints them before it starts. */
|
|
498
|
+
export function describeProfile(profile) {
|
|
499
|
+
return [`App name: ${profile.name}`, `Description: ${profile.description || "(none yet; Isomorph suggests one)"}`, `Audience: ${profile.audience.length ? profile.audience.join(", ") : "only you"}`];
|
|
553
500
|
}
|
|
554
501
|
function operationFinished(status) {
|
|
555
502
|
if (status.production)
|
|
@@ -586,5 +533,5 @@ async function writePending(root, record) {
|
|
|
586
533
|
}
|
|
587
534
|
}
|
|
588
535
|
function withOperation(error, operationRef) {
|
|
589
|
-
return new CliError(error.code, error.message, operationRef, error.remediationHint, error.result);
|
|
536
|
+
return new CliError(error.code, error.message, operationRef, error.remediationHint, error.result, { layer: error.layer, ...(error.paths ? { paths: error.paths } : {}) });
|
|
590
537
|
}
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { closeSync, openSync } from "node:fs";
|
|
3
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
2
5
|
import { createForwarder } from "./forwarder.js";
|
|
3
6
|
import { readKitLock } from "./kit.js";
|
|
4
7
|
import { GovernanceClient, ensureLinkedApp, fileDeclaredRequests, IDENTITY_WORDS, renderGrantGroup } from "./integrations.js";
|
|
5
|
-
import { acquireDevLock, allocatePorts, ensureSdk, LocalRuntime, nodePackageCommand, releaseDevLock, runCommand } from "./local-runtime.js";
|
|
8
|
+
import { acquireDevLock, allocatePorts, ensureSdk, LocalRuntime, nodePackageCommand, pidAlive, readDevLock, releaseDevLock, runCommand } from "./local-runtime.js";
|
|
9
|
+
import { kitPaths } from "./kit.js";
|
|
6
10
|
import { CliError, safeError } from "./output.js";
|
|
7
11
|
/**
|
|
8
12
|
* Signed in at startup: link the app and file the access request for every
|
|
9
13
|
* declared connection — one per connection, approved by IT once for every
|
|
10
|
-
* environment; the same filing `
|
|
14
|
+
* environment; the same filing `deploy` does — so IT's queue holds the
|
|
11
15
|
* ask from the first run and no command stands between the builder and the
|
|
12
16
|
* approval. Returns the banner lines saying where each connection stands.
|
|
13
17
|
* Never throws: `isomorph dev` runs without company access.
|
|
@@ -123,3 +127,78 @@ export async function startDev(root, options) {
|
|
|
123
127
|
throw error instanceof CliError ? error : new CliError("DEV_FAILED", error instanceof Error ? error.message : "isomorph dev could not start.");
|
|
124
128
|
}
|
|
125
129
|
}
|
|
130
|
+
/** Where a detached `isomorph dev` writes what the foreground one would have printed. */
|
|
131
|
+
export function devLogPath(root) { return join(kitPaths(root).local, "dev.log"); }
|
|
132
|
+
/**
|
|
133
|
+
* `isomorph dev --detach`: starts the services in a child that outlives this
|
|
134
|
+
* process, waits until the app answers on its origin, and returns `{origin,
|
|
135
|
+
* pid}` so the caller can print it and exit. Every bench run before this
|
|
136
|
+
* showed the same 3–5 turn stall — start dev in the background, sleep, tail a
|
|
137
|
+
* log to find the port — for information `dev.lock` already held.
|
|
138
|
+
*
|
|
139
|
+
* The child is the ordinary foreground `dev` (same code path, same lock, same
|
|
140
|
+
* `isomorph stop`); it only has its stdio pointed at `.isomorph/local/dev.log`
|
|
141
|
+
* and is unref'd so this process can exit. An app already running is answered
|
|
142
|
+
* from its lock rather than started twice. The origin is "up" when
|
|
143
|
+
* `/_harbour/identity/current` answers: the forwarder is listening and the
|
|
144
|
+
* gateway behind it has the session identity, which is what the app's first
|
|
145
|
+
* request needs.
|
|
146
|
+
*/
|
|
147
|
+
export async function detachDev(root, options) {
|
|
148
|
+
const readLock = options.readLock ?? readDevLock;
|
|
149
|
+
const isAlive = options.isAlive ?? pidAlive;
|
|
150
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
151
|
+
const sleep = options.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
|
|
152
|
+
const appId = async () => (await readKitLock(root).catch(() => undefined))?.appId || undefined;
|
|
153
|
+
const answered = async (origin) => { try {
|
|
154
|
+
return (await fetchImpl(`${origin}/_harbour/identity/current`)).status < 500;
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return false;
|
|
158
|
+
} };
|
|
159
|
+
const running = await readLock(root);
|
|
160
|
+
if (running && isAlive(running.pid) && await answered(running.origin)) {
|
|
161
|
+
options.output(`Isomorph dev is already running: ${running.origin} (pid ${running.pid}).`);
|
|
162
|
+
const linked = await appId();
|
|
163
|
+
return { origin: running.origin, pid: running.pid, ...(linked ? { appId: linked } : {}) };
|
|
164
|
+
}
|
|
165
|
+
await mkdir(kitPaths(root).local, { recursive: true });
|
|
166
|
+
const logFd = openSync(devLogPath(root), "w");
|
|
167
|
+
let exited;
|
|
168
|
+
let child;
|
|
169
|
+
try {
|
|
170
|
+
child = (options.spawnDev ?? spawnForegroundDev(options.reset ?? false))(root, logFd);
|
|
171
|
+
child.on("exit", code => { exited = code ?? -1; });
|
|
172
|
+
child.unref();
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
closeSync(logFd);
|
|
176
|
+
}
|
|
177
|
+
options.output(`Starting Isomorph dev in the background (pid ${child.pid ?? "?"}); its output goes to .isomorph/local/dev.log.`);
|
|
178
|
+
const deadline = Date.now() + (options.timeoutMs ?? 5 * 60_000);
|
|
179
|
+
while (Date.now() < deadline) {
|
|
180
|
+
const lock = await readLock(root);
|
|
181
|
+
if (lock && (lock.pid === child.pid || isAlive(lock.pid)) && await answered(lock.origin)) {
|
|
182
|
+
const linked = await appId();
|
|
183
|
+
return { origin: lock.origin, pid: lock.pid, ...(linked ? { appId: linked } : {}) };
|
|
184
|
+
}
|
|
185
|
+
if (exited !== undefined)
|
|
186
|
+
throw new CliError("DEV_FAILED", `isomorph dev stopped before the app answered (exit ${exited}). ${await logTail(root)}`, undefined, "Read .isomorph/local/dev.log, fix what it reports, then run this again.");
|
|
187
|
+
await sleep(options.pollMs ?? 500);
|
|
188
|
+
}
|
|
189
|
+
throw new CliError("DEV_FAILED", "isomorph dev did not answer within the wait; it may still be starting.", undefined, `Read .isomorph/local/dev.log; run \`isomorph stop --app-root .\` and then this again.`);
|
|
190
|
+
}
|
|
191
|
+
/** The foreground `dev` of this same CLI, detached, with its output in the log file. */
|
|
192
|
+
function spawnForegroundDev(reset) {
|
|
193
|
+
return (root, logFd) => {
|
|
194
|
+
const child = spawn(process.execPath, [...process.execArgv, process.argv[1], "dev", "--app-root", root, ...(reset ? ["--reset"] : [])], { cwd: root, detached: true, stdio: ["ignore", logFd, logFd], windowsHide: true, env: process.env });
|
|
195
|
+
child.on("error", () => undefined);
|
|
196
|
+
return child;
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
/** The last few lines of the detached child's log, for a refusal that has nothing else to say. */
|
|
200
|
+
async function logTail(root) {
|
|
201
|
+
const text = await readFile(devLogPath(root), "utf8").catch(() => "");
|
|
202
|
+
const lines = text.trim().split("\n").filter(Boolean).slice(-3).join(" | ");
|
|
203
|
+
return lines ? `Last lines: ${lines}` : "";
|
|
204
|
+
}
|