@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
|
@@ -4,14 +4,14 @@ import { CliError } from "./output.js";
|
|
|
4
4
|
import { CLI_VERSION } from "./version.js";
|
|
5
5
|
export class GovernanceClient {
|
|
6
6
|
apiUrl;
|
|
7
|
-
token;
|
|
8
7
|
tenantId;
|
|
9
8
|
fetchImpl;
|
|
9
|
+
token;
|
|
10
10
|
constructor(apiUrl, token, tenantId, fetchImpl = fetch) {
|
|
11
11
|
this.apiUrl = apiUrl;
|
|
12
|
-
this.token = token;
|
|
13
12
|
this.tenantId = tenantId;
|
|
14
13
|
this.fetchImpl = fetchImpl;
|
|
14
|
+
this.token = typeof token === "string" ? async () => token : token;
|
|
15
15
|
}
|
|
16
16
|
link(input) {
|
|
17
17
|
return this.call("POST", "/v1/development/apps/link", { tenantId: this.tenantId, ...input });
|
|
@@ -35,6 +35,8 @@ export class GovernanceClient {
|
|
|
35
35
|
* environment's lane (governance files the requests itself), the company's AI
|
|
36
36
|
* setup when the app calls governed AI, and the CLI version. One call, one
|
|
37
37
|
* refusal shape, instead of the sequential integrations → AI → pipeline cycles.
|
|
38
|
+
* `deploy` no longer calls this — its deploy route runs the same preflight
|
|
39
|
+
* before opening the operation; `promote` still asks it for production.
|
|
38
40
|
*/
|
|
39
41
|
deployPreflight(appId, body) {
|
|
40
42
|
return this.call("POST", `/v1/development/apps/${encodeURIComponent(appId)}/deploy-preflight`, body);
|
|
@@ -46,18 +48,59 @@ export class GovernanceClient {
|
|
|
46
48
|
submitFeedback(appId, input) {
|
|
47
49
|
return this.call("POST", `/v1/development/apps/${encodeURIComponent(appId)}/feedback`, input);
|
|
48
50
|
}
|
|
51
|
+
// The kit's one deploy transport (governance ADR 0020): open or resume, hand
|
|
52
|
+
// over the package, then read the same status the console reads.
|
|
53
|
+
/** Opens the app's deployment, or resumes the one already open for this package (same sha256). 409 `DEPLOY_BLOCKED` carries every blocker in `details`; 409 `DEPLOY_IN_FLIGHT` names the operation still deploying another package. */
|
|
54
|
+
async startDeployment(appId, body) {
|
|
55
|
+
const { status, data } = await this.exchange("POST", deployments(appId), body);
|
|
56
|
+
return { operationRef: data.operationRef, upload: data.upload, status: data.status, resumed: status !== 201 };
|
|
57
|
+
}
|
|
58
|
+
/** The package is uploaded: from here the server carries the deployment on by itself (intake, the managed save, verification). */
|
|
59
|
+
completePackage(appId, operationRef, body) {
|
|
60
|
+
return this.call("POST", `${deployments(appId)}/${encodeURIComponent(operationRef)}/package`, body);
|
|
61
|
+
}
|
|
62
|
+
/** The operation's status, long-polled up to `waitSeconds` (0–15) and answered early on change. */
|
|
63
|
+
deploymentStatus(appId, operationRef, waitSeconds = 0) {
|
|
64
|
+
return this.call("GET", `${deployments(appId)}/${encodeURIComponent(operationRef)}?wait=${Math.min(15, Math.max(0, Math.floor(waitSeconds)))}`);
|
|
65
|
+
}
|
|
66
|
+
promoteDeployment(appId, operationRef, body) {
|
|
67
|
+
return this.call("POST", `${deployments(appId)}/${encodeURIComponent(operationRef)}/promote`, body);
|
|
68
|
+
}
|
|
69
|
+
retryDeployment(appId, operationRef) {
|
|
70
|
+
return this.call("POST", `${deployments(appId)}/${encodeURIComponent(operationRef)}/retry`, {});
|
|
71
|
+
}
|
|
49
72
|
async call(method, path, body) {
|
|
73
|
+
return (await this.exchange(method, path, body)).data;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* One request, with governance's refusal carried whole: its code (or category,
|
|
77
|
+
* or the HTTP status), its sentence, and its `details` for the caller. A 401 is
|
|
78
|
+
* retried once with a re-resolved token — a sibling process may have rotated
|
|
79
|
+
* the pair — and a second one is a sign-in refusal. A transport failure and a
|
|
80
|
+
* 5xx are named as such (`GOVERNANCE_UNREACHABLE`, `HTTP_5xx`) so a long poll
|
|
81
|
+
* can tell a transient fault from a refusal; see `waitForSettled`.
|
|
82
|
+
*/
|
|
83
|
+
async exchange(method, path, body) {
|
|
50
84
|
const url = `${this.apiUrl.replace(/\/$/, "")}${path}`;
|
|
51
|
-
|
|
52
|
-
|
|
85
|
+
let response = await this.send(url, method, body);
|
|
86
|
+
if (response.status === 401)
|
|
87
|
+
response = await this.send(url, method, body);
|
|
53
88
|
const parsed = await response.json().catch(() => ({}));
|
|
54
89
|
if (response.status === 401)
|
|
55
90
|
throw new CliError("AUTH_REQUIRED", "Please sign in to Isomorph with `isomorph login`.", undefined, undefined, undefined, { layer: "governance" });
|
|
56
|
-
if (!response.ok)
|
|
57
|
-
|
|
58
|
-
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
const { code, remediationHint, ...details } = parsed.error?.details ?? {};
|
|
93
|
+
throw new CliError(code ?? parsed.error?.category ?? `HTTP_${response.status}`, parsed.error?.message || "Isomorph governance rejected the request.", undefined, typeof remediationHint === "string" && remediationHint.trim() ? remediationHint.trim() : undefined, undefined, { layer: "governance", ...(Object.keys(details).length ? { details } : {}) });
|
|
94
|
+
}
|
|
95
|
+
return { status: response.status, data: (parsed.data ?? parsed) };
|
|
96
|
+
}
|
|
97
|
+
async send(url, method, body) {
|
|
98
|
+
const token = await this.token();
|
|
99
|
+
return this.fetchImpl(url, { method, headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), "x-harbour-tenant": this.tenantId, accept: "application/json", ...(body ? { "content-type": "application/json" } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}), redirect: "error" })
|
|
100
|
+
.catch((error) => { throw new CliError("GOVERNANCE_UNREACHABLE", `Isomorph governance at ${new URL(url).host} could not be reached: ${transportFailure(error)}.`); });
|
|
59
101
|
}
|
|
60
102
|
}
|
|
103
|
+
const deployments = (appId) => `/v1/development/apps/${encodeURIComponent(appId)}/deployments`;
|
|
61
104
|
/**
|
|
62
105
|
* What actually failed under a `fetch` rejection. undici reports every transport
|
|
63
106
|
* failure as `TypeError: fetch failed` and keeps the reason in `cause`: `getaddrinfo
|
|
@@ -314,18 +357,31 @@ export async function assertDeployReady(root, client, tenantId, bundle, environm
|
|
|
314
357
|
throw new CliError("DECLARATION_INVALID", `.isomorph/integrations.json is invalid: ${errors[0]}`);
|
|
315
358
|
const appId = await ensureLinkedApp(root, client, tenantId, bundle);
|
|
316
359
|
const preflight = await client.deployPreflight(appId, { environment, callsAi, declaration, cliVersion: CLI_VERSION });
|
|
317
|
-
|
|
318
|
-
if (preflight.ready && !blockers.length) {
|
|
360
|
+
if (preflight.ready && !blockersOf(preflight.blockers).length) {
|
|
319
361
|
output(`Isomorph confirmed the app can deploy to ${environment}.`);
|
|
320
362
|
return appId;
|
|
321
363
|
}
|
|
364
|
+
throw deployBlocked(preflight.blockers, environment, output);
|
|
365
|
+
}
|
|
366
|
+
/** The blockers as governance listed them; anything that is not a sentence is dropped rather than printed as `undefined`. */
|
|
367
|
+
function blockersOf(value) {
|
|
368
|
+
return Array.isArray(value) ? value.filter(blocker => blocker && typeof blocker.sentence === "string") : [];
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* The one refusal for a blocked deploy, wherever the preflight ran — `promote`'s
|
|
372
|
+
* own call or the deploy route's 409 `DEPLOY_BLOCKED`: every sentence printed,
|
|
373
|
+
* the fixes joined as the hint, and the code readers already know when the
|
|
374
|
+
* blockers are all of one kind.
|
|
375
|
+
*/
|
|
376
|
+
export function deployBlocked(value, environment, output) {
|
|
377
|
+
const blockers = blockersOf(value);
|
|
322
378
|
output(`Isomorph cannot deploy to ${environment} yet:`);
|
|
323
379
|
for (const blocker of blockers)
|
|
324
380
|
output(` ${blocker.sentence}`);
|
|
325
381
|
const kinds = new Set(blockers.map(blocker => blocker.kind));
|
|
326
382
|
const code = kinds.size === 1 ? BLOCKER_CODES[[...kinds][0]] ?? "DEPLOY_BLOCKED" : "DEPLOY_BLOCKED";
|
|
327
383
|
const fixes = [...new Set(blockers.map(blocker => blocker.fix).filter(Boolean))].join(" ");
|
|
328
|
-
|
|
384
|
+
return new CliError(code, blockers.map(blocker => blocker.sentence).join(" ") || `Isomorph cannot deploy this app to ${environment} yet.`, undefined, fixes || undefined, undefined, { layer: "governance" });
|
|
329
385
|
}
|
|
330
386
|
/** The code readers already know for a refusal that is all of one kind. */
|
|
331
387
|
const BLOCKER_CODES = { integration: "INTEGRATIONS_NOT_READY", ai: "AI_NOT_READY", cli: "CLI_UPGRADE_REQUIRED" };
|
|
@@ -1,28 +1,28 @@
|
|
|
1
1
|
export const PUBLISHED_KIT_BUNDLE = {
|
|
2
2
|
"schema": "isomorph.kit-bundle/1.0",
|
|
3
|
-
"kitVersion": "0.
|
|
3
|
+
"kitVersion": "0.5.1",
|
|
4
4
|
"sdk": {
|
|
5
5
|
"package": "@isomorph.ai/app-sdk",
|
|
6
|
-
"version": "1.2.
|
|
7
|
-
"tarballSha256": "
|
|
8
|
-
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:
|
|
6
|
+
"version": "1.2.1",
|
|
7
|
+
"tarballSha256": "607e3896aa4116d82d0473677d38a68fecd4855a250cd44e038780290c335c67",
|
|
8
|
+
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:607e3896aa4116d82d0473677d38a68fecd4855a250cd44e038780290c335c67"
|
|
9
9
|
},
|
|
10
10
|
"images": {
|
|
11
|
-
"appGateway": "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:
|
|
12
|
-
"sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:
|
|
11
|
+
"appGateway": "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:558e3aa74f315a8bf2ae18f1344be3b8fad0203286f43d14d411a4a1f3e15dc0",
|
|
12
|
+
"sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:578cc8d98ca687dc79d8567f46abc636db2df0b3d8a01ccf38b30d5932c43353"
|
|
13
13
|
},
|
|
14
14
|
"nativeRuntime": {
|
|
15
15
|
"darwinArm64": {
|
|
16
|
-
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:
|
|
17
|
-
"sha256": "
|
|
16
|
+
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:e36aeea224867ebd7ae4a609284393460f707eaf6c50cb5f8ea1596926bae04b",
|
|
17
|
+
"sha256": "e36aeea224867ebd7ae4a609284393460f707eaf6c50cb5f8ea1596926bae04b"
|
|
18
18
|
},
|
|
19
19
|
"linuxX64": {
|
|
20
|
-
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:
|
|
21
|
-
"sha256": "
|
|
20
|
+
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:0673c75610f7fc8a5f1dbc1ec39570eee2278301430e7bbd4b4323319e7a1e6e",
|
|
21
|
+
"sha256": "0673c75610f7fc8a5f1dbc1ec39570eee2278301430e7bbd4b4323319e7a1e6e"
|
|
22
22
|
},
|
|
23
23
|
"windowsX64": {
|
|
24
|
-
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:
|
|
25
|
-
"sha256": "
|
|
24
|
+
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:8c1fc2e83d6b4cfd056b5252b9676a32e1e49e0596bb072f2c33faef63781aab",
|
|
25
|
+
"sha256": "8c1fc2e83d6b4cfd056b5252b9676a32e1e49e0596bb072f2c33faef63781aab"
|
|
26
26
|
}
|
|
27
27
|
},
|
|
28
28
|
"brief": {
|
|
@@ -98,7 +98,7 @@ export function newKitLock(bundle, tenantId = "", appId = "") {
|
|
|
98
98
|
return { schema: "isomorph.kit-lock/1.0", appId, tenantId, bundle, createdAt: now, updatedAt: now };
|
|
99
99
|
}
|
|
100
100
|
// ---- Paths and identity --------------------------------------------------------
|
|
101
|
-
/** Where a kit app keeps its declaration, lock
|
|
101
|
+
/** Where a kit app keeps its declaration, lock and app profile (committed), and its generated checks and local state (ignored). */
|
|
102
102
|
export const KIT_DIRECTORY = ".isomorph";
|
|
103
103
|
export function appRoot(rootArg) {
|
|
104
104
|
if (!rootArg)
|
|
@@ -111,7 +111,7 @@ export function appRoot(rootArg) {
|
|
|
111
111
|
export function kitPaths(root) {
|
|
112
112
|
const kit = join(root, KIT_DIRECTORY);
|
|
113
113
|
const local = join(kit, "local");
|
|
114
|
-
return { kit, local, declaration: join(kit, "integrations.json"), app: join(kit, "app.json"), lock: join(kit, "kit.lock.json"),
|
|
114
|
+
return { kit, local, declaration: join(kit, "integrations.json"), app: join(kit, "app.json"), lock: join(kit, "kit.lock.json"), devLock: join(local, "dev.lock"), state: join(local, "state"), report: join(local, "check-report.json"), deployNote: join(local, "deploy.json") };
|
|
115
115
|
}
|
|
116
116
|
export function defaultAppProfile(root) {
|
|
117
117
|
// A real description from the start: the person changes it, but a first
|
|
@@ -169,7 +169,9 @@ export function linkIdempotencyKey(tenantId, root) {
|
|
|
169
169
|
/**
|
|
170
170
|
* sha256 over the tracked app files (same boundary as deploy), so any
|
|
171
171
|
* edit changes it — and, per file, the hash of its contents, so a stale check
|
|
172
|
-
* can say WHICH path moved rather than only that something did.
|
|
172
|
+
* can say WHICH path moved rather than only that something did. The boundary
|
|
173
|
+
* leaves `.isomorph/checks/` out (analyzer.ts): the gate regenerates it on
|
|
174
|
+
* every check, and a regeneration is not a change to the app.
|
|
173
175
|
*/
|
|
174
176
|
export async function sourceDigest(root) {
|
|
175
177
|
const graph = await scanWorkspace(root, { sourceBoundary: "root" });
|
|
@@ -370,19 +370,20 @@ export class LocalRuntime {
|
|
|
370
370
|
})();
|
|
371
371
|
}
|
|
372
372
|
/**
|
|
373
|
-
* Starts the kit gate: the pinned gateway
|
|
374
|
-
* one-shot
|
|
375
|
-
*
|
|
376
|
-
* session's PostgreSQL and bucket named for its scratch database and files
|
|
377
|
-
*
|
|
378
|
-
* (check.ts) and stops it
|
|
373
|
+
* Starts the kit gate: the pinned native gateway's `gate` subcommand as a
|
|
374
|
+
* one-shot process of this project, the app as its workspace (worked on
|
|
375
|
+
* through a mirror, never written), its control listener on `port`, the
|
|
376
|
+
* session's PostgreSQL and bucket named for its scratch database and files,
|
|
377
|
+
* and this node (`HARBOUR_GATE_NODE`) to run the journeys with. Resolves to
|
|
378
|
+
* the gate id; the caller polls the control API (check.ts) and stops it
|
|
379
|
+
* with stopGate.
|
|
379
380
|
*/
|
|
380
|
-
async startGate(bundle, port
|
|
381
|
+
async startGate(bundle, port) {
|
|
381
382
|
void bundle;
|
|
382
383
|
if (!this.ports)
|
|
383
384
|
throw new CliError("LOCAL_RUNTIME_FAILED", "The local runtime was not prepared.");
|
|
384
385
|
const id = `gate-${Date.now()}`;
|
|
385
|
-
const child = spawn(this.binary, ["gate"], { cwd: this.root, env: { ...process.env, HARBOUR_GATE_WORKSPACE: this.root, HARBOUR_GATE_LISTEN: `127.0.0.1:${port}`,
|
|
386
|
+
const child = spawn(this.binary, ["gate"], { cwd: this.root, env: { ...process.env, HARBOUR_GATE_WORKSPACE: this.root, HARBOUR_GATE_LISTEN: `127.0.0.1:${port}`, HARBOUR_GATE_NODE: process.execPath, HARBOUR_GATE_DATABASE_URL: `postgresql://${LOCAL.dbUser}:${LOCAL.dbPassword}@127.0.0.1:${this.ports.postgres}/${LOCAL.database}?sslmode=disable`, HARBOUR_GATE_S3_BUCKET: LOCAL.bucket, HARBOUR_GATE_FILES_DIRECTORY: join(kitPaths(this.root).state, "gate-files") }, stdio: ["ignore", "ignore", "pipe"] });
|
|
386
387
|
this.gates.set(id, child);
|
|
387
388
|
child.stderr?.on("data", chunk => this.gateOutput.set(id, `${this.gateOutput.get(id) ?? ""}${chunk}`.slice(-4000)));
|
|
388
389
|
return id;
|
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
import { structured } from "./remote-mcp-client.js";
|
|
2
1
|
import { CliError } from "./output.js";
|
|
2
|
+
/** The status reader for one operation, bound to the deployment route. */
|
|
3
|
+
export function statusFetch(governance, appId, operationRef) {
|
|
4
|
+
return waitSeconds => governance.deploymentStatus(appId, operationRef, waitSeconds);
|
|
5
|
+
}
|
|
3
6
|
const DEPLOYMENT_TERMINAL = new Set(["LIVE", "FAILED", "STALE", "RETRYABLE_FAILURE"]);
|
|
4
7
|
const PRODUCTION_TERMINAL = new Set(["LIVE", "FAILED", "MANUAL_RECOVERY"]);
|
|
8
|
+
/** A save that has ended without a saved copy: nothing more happens to it without the builder acting. */
|
|
9
|
+
const SAVE_FAILED = new Set(["FAILED", "RETRYABLE_FAILURE"]);
|
|
10
|
+
/** The stages between the package hand-over and a deployment attempt, during which the server is at work with nothing for the builder to do. */
|
|
11
|
+
const SERVER_AT_WORK = new Set(["inspection", "awaiting-approval", "verification"]);
|
|
5
12
|
export function summarize(status) {
|
|
6
13
|
return {
|
|
7
14
|
...(status.operation?.stage ? { stage: status.operation.stage } : {}),
|
|
@@ -10,9 +17,12 @@ export function summarize(status) {
|
|
|
10
17
|
...(status.production ? { production: pickProduction(status.production) } : {}),
|
|
11
18
|
...(status.waiting ? { waiting: { stage: status.waiting.stage, plainEnglish: status.waiting.plainEnglish } } : {}),
|
|
12
19
|
...(status.nextAction?.plainEnglish ? { nextStep: status.nextAction.plainEnglish } : {}),
|
|
13
|
-
...(status.nextTool ? { nextTool: status.nextTool } : {})
|
|
20
|
+
...(status.nextTool ? { nextTool: status.nextTool } : {}),
|
|
21
|
+
...(isRecord(status.sourceControlEvidence) ? { sourceControlEvidence: status.sourceControlEvidence } : {}),
|
|
22
|
+
...(isRecord(status.runnerEvidence) ? { runnerEvidence: status.runnerEvidence } : {})
|
|
14
23
|
};
|
|
15
24
|
}
|
|
25
|
+
const isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
16
26
|
function pickDeployment(value) {
|
|
17
27
|
return {
|
|
18
28
|
state: value.state,
|
|
@@ -63,9 +73,13 @@ function pickProduction(value) {
|
|
|
63
73
|
...(value.failure ? { failure: pickFailure(value.failure) } : {})
|
|
64
74
|
};
|
|
65
75
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
76
|
+
/**
|
|
77
|
+
* The one way a refusal the server recorded on the operation becomes a failure
|
|
78
|
+
* the builder can act on: the server's own code, sentence and fix where it gave
|
|
79
|
+
* them, the generic pair only where it gave nothing, and the operation always.
|
|
80
|
+
*/
|
|
81
|
+
export function recordedRefusal(failure, fallbackCode, fallbackMessage, operationRef, result) {
|
|
82
|
+
return new CliError(failure?.code ?? fallbackCode, failure?.message ?? fallbackMessage, operationRef, failure?.remediationHint, result, { layer: "governance" });
|
|
69
83
|
}
|
|
70
84
|
/** True when nothing more will change without a person acting. */
|
|
71
85
|
export function isSettled(status) {
|
|
@@ -75,12 +89,29 @@ export function isSettled(status) {
|
|
|
75
89
|
return PRODUCTION_TERMINAL.has(status.production.state);
|
|
76
90
|
if (status.deployment)
|
|
77
91
|
return DEPLOYMENT_TERMINAL.has(status.deployment.state);
|
|
78
|
-
return
|
|
92
|
+
return status.operation?.stage === "failed" || SAVE_FAILED.has(status.sourceSave?.status ?? "");
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Whether the server is still at work before any deployment attempt exists:
|
|
96
|
+
* inspecting the package, saving it, verifying the saved copy. The CLI used to
|
|
97
|
+
* drive these as its own calls and waited on each; now the server takes them
|
|
98
|
+
* on its own, so a watch treats them as work in flight — bounded by the wait
|
|
99
|
+
* ceiling, never by the short grace window that follows a verified save.
|
|
100
|
+
*/
|
|
101
|
+
function saveInFlight(status) {
|
|
102
|
+
const save = status.sourceSave?.status ?? "";
|
|
103
|
+
return SERVER_AT_WORK.has(status.operation?.stage ?? "") || save === "AWAITING_APPROVAL" || save === "QUEUED" || save === "RUNNING";
|
|
79
104
|
}
|
|
80
105
|
/** What the wait was before `--max-wait` existed: a caller that never passes it waits exactly as long as it did. */
|
|
81
106
|
const DEFAULT_MAX_WAIT_MS = 30 * 60_000;
|
|
82
107
|
/** A line at least this often while nothing changes, so a wrapper with its own idle timeout (an agent's shell tool) keeps seeing output; see `follow`. */
|
|
83
108
|
const HEARTBEAT_MS = 60_000;
|
|
109
|
+
/** A status read that failed for a reason a later read may not: the network, or governance answering 5xx. A refusal (sign-in, not found) is not one. */
|
|
110
|
+
function transient(error) {
|
|
111
|
+
if (!(error instanceof CliError))
|
|
112
|
+
return true;
|
|
113
|
+
return error.code === "GOVERNANCE_UNREACHABLE" || /^HTTP_5\d\d$/.test(error.code);
|
|
114
|
+
}
|
|
84
115
|
/**
|
|
85
116
|
* Follow the operation until the preview deployment (or an in-flight production
|
|
86
117
|
* promotion) settles. Isomorph long-polls up to 15 s per call and returns early
|
|
@@ -97,7 +128,7 @@ const HEARTBEAT_MS = 60_000;
|
|
|
97
128
|
* left of the bound, so `--max-wait N` answers within N seconds — an agent's
|
|
98
129
|
* tool with a total cap of N sees the answer instead of killing the command.
|
|
99
130
|
*/
|
|
100
|
-
export async function waitForSettled(
|
|
131
|
+
export async function waitForSettled(fetch, operationRef, output, options = {}) {
|
|
101
132
|
const now = options.now ?? Date.now;
|
|
102
133
|
const sleep = options.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
|
|
103
134
|
const maxWaitMs = options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
|
|
@@ -117,7 +148,7 @@ export async function waitForSettled(client, operationRef, output, options = {})
|
|
|
117
148
|
return last;
|
|
118
149
|
let status;
|
|
119
150
|
try {
|
|
120
|
-
status = await
|
|
151
|
+
status = await fetch(Math.min(15, Math.max(0, Math.floor(remainingMs / 1000))));
|
|
121
152
|
consecutiveFailures = 0;
|
|
122
153
|
}
|
|
123
154
|
catch (error) {
|
|
@@ -125,7 +156,7 @@ export async function waitForSettled(client, operationRef, output, options = {})
|
|
|
125
156
|
// transiently (gateway timeout, dropped connection). The operation is
|
|
126
157
|
// unaffected by our polling, so keep watching; give up only after a run
|
|
127
158
|
// of failures, and say what the last one was instead of a generic error.
|
|
128
|
-
if (error
|
|
159
|
+
if (!transient(error))
|
|
129
160
|
throw error; // e.g. AUTH_REQUIRED: retrying cannot help.
|
|
130
161
|
consecutiveFailures += 1;
|
|
131
162
|
if (consecutiveFailures >= 5)
|
|
@@ -138,7 +169,7 @@ export async function waitForSettled(client, operationRef, output, options = {})
|
|
|
138
169
|
if (isSettled(status))
|
|
139
170
|
return status;
|
|
140
171
|
const at = now();
|
|
141
|
-
bound = status.deployment || status.production ? maxWaitMs : graceMs;
|
|
172
|
+
bound = status.deployment || status.production || saveInFlight(status) ? maxWaitMs : graceMs;
|
|
142
173
|
const line = progressLine(status);
|
|
143
174
|
if (line !== lastLine) {
|
|
144
175
|
output(line);
|
|
@@ -156,15 +187,26 @@ export async function waitForSettled(client, operationRef, output, options = {})
|
|
|
156
187
|
}
|
|
157
188
|
}
|
|
158
189
|
function safeMessage(error) {
|
|
159
|
-
const raw = error instanceof Error ? error.message : String(error);
|
|
190
|
+
const raw = error instanceof CliError ? `${error.code}: ${error.message}` : error instanceof Error ? error.message : String(error);
|
|
160
191
|
return raw.replace(/https?:\/\/\S+|Bearer\s+\S+/gi, "").replace(/\s+/g, " ").trim().slice(0, 160) || "no details";
|
|
161
192
|
}
|
|
162
193
|
function activity(status) {
|
|
163
|
-
|
|
194
|
+
if (status.production)
|
|
195
|
+
return "promoting the app to production";
|
|
196
|
+
if (status.deployment)
|
|
197
|
+
return "deploying the app";
|
|
198
|
+
return saveInFlight(status) ? "saving the app" : "preparing the deployment";
|
|
164
199
|
}
|
|
200
|
+
/** What the server is doing right now, as one line; printed when it changes. */
|
|
165
201
|
function progressLine(status) {
|
|
166
202
|
const stage = status.production ?? status.deployment;
|
|
167
|
-
|
|
203
|
+
if (stage)
|
|
204
|
+
return `Isomorph is ${activity(status)}${stage.message ? ` (${stage.message})` : ""}.`;
|
|
205
|
+
switch (status.operation?.stage) {
|
|
206
|
+
case "inspection": return "Isomorph is inspecting the app package.";
|
|
207
|
+
case "verification": return "Isomorph is verifying the saved app.";
|
|
208
|
+
default: return saveInFlight(status) ? "Isomorph is saving the app to the approved company code system. Nothing is deployed or released by that." : "Isomorph saved the app and is preparing the deployment.";
|
|
209
|
+
}
|
|
168
210
|
}
|
|
169
211
|
/**
|
|
170
212
|
* The one sentence for a watch that has not ended: the heartbeat prints it as
|
|
@@ -172,7 +214,7 @@ function progressLine(status) {
|
|
|
172
214
|
* wrapper that stops watching, or an agent whose tool did, runs the command
|
|
173
215
|
* it names and continues the same operation — it never starts another.
|
|
174
216
|
*/
|
|
175
|
-
function runningLine(status, elapsedMs, operationRef) {
|
|
217
|
+
export function runningLine(status, elapsedMs, operationRef) {
|
|
176
218
|
const seconds = Math.round(elapsedMs / 1000);
|
|
177
219
|
return `Isomorph is still ${activity(status)} (${Math.floor(seconds / 60)}m${seconds % 60}s). Safe to stop watching: \`${continueCommand(operationRef)}\` picks it up; do not start another.`;
|
|
178
220
|
}
|
|
@@ -180,10 +222,11 @@ function runningLine(status, elapsedMs, operationRef) {
|
|
|
180
222
|
* The one command that resumes a watch, as the CLI's help and the agent guide
|
|
181
223
|
* print it too. Bounded at two minutes so a wrapper with an idle timeout gets
|
|
182
224
|
* an answer and runs it again, rather than cutting a 30-minute wait off and
|
|
183
|
-
* learning nothing.
|
|
225
|
+
* learning nothing. It names the app folder because the deployment routes are
|
|
226
|
+
* addressed by the app: `status` reads the app's identity from `.isomorph/`.
|
|
184
227
|
*/
|
|
185
228
|
export function continueCommand(operationRef) {
|
|
186
|
-
return `isomorph status --operation ${operationRef} --wait --max-wait 120 --json`;
|
|
229
|
+
return `isomorph status --app-root . --operation ${operationRef} --wait --max-wait 120 --json`;
|
|
187
230
|
}
|
|
188
231
|
/** The exit-relevant outcome of a status — success, a step the maker must take, an operation still in flight — as the summary with its `outcome` named; a failure is thrown. */
|
|
189
232
|
export function outcomeFor(status, operationRef) {
|
|
@@ -210,14 +253,31 @@ export function outcomeFor(status, operationRef) {
|
|
|
210
253
|
}
|
|
211
254
|
if (status.deployment)
|
|
212
255
|
return { ...summary, outcome: "running" };
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
256
|
+
// Before any deployment, the server can refuse the package (intake) or fail to
|
|
257
|
+
// save it. Intake does not merely refuse: it records why — a stable code, one
|
|
258
|
+
// plain-English sentence, and the commands that fix it — on the operation. Say
|
|
259
|
+
// what the server said, and keep the generic line only for an operation that
|
|
260
|
+
// failed without any detail at all.
|
|
261
|
+
if (status.operation?.stage === "failed")
|
|
262
|
+
throw recordedRefusal(pickSourceFailure(status), "PACKAGE_REJECTED", "Isomorph could not safely accept this app package.", operationRef, summary);
|
|
218
263
|
const save = status.sourceSave?.status;
|
|
219
|
-
if (save
|
|
264
|
+
if (save === "FAILED")
|
|
265
|
+
throw new CliError("FAILED", "Isomorph could not save the app.", operationRef, undefined, summary);
|
|
266
|
+
if (save === "RETRYABLE_FAILURE")
|
|
267
|
+
throw new CliError("RETRYABLE_FAILURE", "Isomorph could not complete the save yet. Resume this operation safely.", operationRef, undefined, summary);
|
|
268
|
+
// The server inspecting, saving or verifying is work in flight, like a deployment.
|
|
269
|
+
if (saveInFlight(status))
|
|
270
|
+
return { ...summary, outcome: "running" };
|
|
271
|
+
// No deployment yet has three different causes, and each names its own next
|
|
272
|
+
// step: a package the server has not received, a save in a state it does not
|
|
273
|
+
// carry on from by itself — resumed by running `deploy` again, observed
|
|
274
|
+
// 2026-09-14 (fourier "Dad Jokes"): a builder read the administrator sentence
|
|
275
|
+
// for a save that was simply resumable — and only a SUCCEEDED save with no
|
|
276
|
+
// deployment behind it is the company-setup case.
|
|
277
|
+
if (save && save !== "SUCCEEDED" && save !== "DUPLICATE")
|
|
220
278
|
return { ...summary, outcome: "no_deployment", nextStep: summary.nextStep ?? `Isomorph has not finished saving the app (${save}). Run \`isomorph deploy\` again for this app: it resumes the same save and deploys; nothing is deployed twice.` };
|
|
279
|
+
if (!save && status.operation?.stage !== "complete")
|
|
280
|
+
return { ...summary, outcome: "no_deployment", nextStep: summary.nextStep ?? "Isomorph has not received this app's package yet. Run `isomorph deploy` again for this app: it continues the same operation; nothing is deployed twice." };
|
|
221
281
|
return { ...summary, outcome: "no_deployment", nextStep: summary.nextStep ?? "Isomorph saved the app but has not started a deployment for this company yet. Ask your Isomorph administrator to connect deployment." };
|
|
222
282
|
}
|
|
223
283
|
/**
|
|
@@ -232,76 +292,46 @@ export function outcomeFor(status, operationRef) {
|
|
|
232
292
|
* human by email and never reached the agent. The heartbeat, the bounded
|
|
233
293
|
* `--max-wait`, and this outcome each close one part of that.
|
|
234
294
|
*/
|
|
235
|
-
export async function follow(
|
|
295
|
+
export async function follow(fetch, operationRef, output, options = {}) {
|
|
236
296
|
const now = options.now ?? Date.now;
|
|
237
297
|
const startedAt = now();
|
|
238
|
-
const status = await waitForSettled(
|
|
298
|
+
const status = await waitForSettled(fetch, operationRef, output, options);
|
|
239
299
|
const summary = outcomeFor(status, operationRef);
|
|
240
300
|
if (summary.outcome !== "running")
|
|
241
301
|
return summary;
|
|
242
302
|
const elapsedMs = now() - startedAt;
|
|
243
303
|
return { ...summary, elapsedSeconds: Math.round(elapsedMs / 1000), nextStep: runningLine(status, elapsedMs, operationRef) };
|
|
244
304
|
}
|
|
245
|
-
export async function retryDeployment(
|
|
246
|
-
await
|
|
247
|
-
|
|
248
|
-
output(result.retry?.replayed ? "Isomorph had already accepted this retry." : "Isomorph accepted the deployment retry.");
|
|
305
|
+
export async function retryDeployment(governance, appId, operationRef, output, options = {}) {
|
|
306
|
+
const result = await governance.retryDeployment(appId, operationRef);
|
|
307
|
+
output(result.replayed ? "Isomorph had already accepted this retry." : "Isomorph accepted the deployment retry.");
|
|
249
308
|
if (options.wait === false)
|
|
250
|
-
return summarize(
|
|
251
|
-
return follow(
|
|
309
|
+
return summarize(result.status);
|
|
310
|
+
return follow(statusFetch(governance, appId, operationRef), operationRef, output, options.waitOptions);
|
|
252
311
|
}
|
|
253
|
-
/**
|
|
254
|
-
* What governance records as the promotion attestation. `--confirm-tested` is
|
|
255
|
-
* the one flag that means "the person tried the preview"; nothing is prompted
|
|
256
|
-
* for, because the CLI is run by an agent and the person is in the browser.
|
|
257
|
-
*/
|
|
258
|
-
export const PROMOTION_CONFIRMATION = "Tested.";
|
|
259
312
|
/**
|
|
260
313
|
* Promotion mirrors the console's "I tested this version" button: the maker
|
|
261
|
-
* must have opened the live preview and say so
|
|
262
|
-
*
|
|
263
|
-
*
|
|
314
|
+
* must have opened the live preview and say so. `--confirm-tested` is the one
|
|
315
|
+
* flag that means "the person tried the preview"; nothing is prompted for,
|
|
316
|
+
* because the CLI is run by an agent and the person is in the browser. The
|
|
317
|
+
* server pins the preview version it promotes from the operation's own live
|
|
318
|
+
* deployment and mints the attestation for the signed-in builder.
|
|
264
319
|
*/
|
|
265
|
-
export async function promoteToProduction(
|
|
266
|
-
const
|
|
320
|
+
export async function promoteToProduction(governance, appId, operationRef, app, output, options = {}) {
|
|
321
|
+
const fetch = statusFetch(governance, appId, operationRef);
|
|
322
|
+
const status = await fetch(0);
|
|
267
323
|
if (status.production && !PRODUCTION_TERMINAL.has(status.production.state)) {
|
|
268
324
|
output("Isomorph is already promoting this app to production.");
|
|
269
|
-
return options.wait === false ? summarize(status) : follow(
|
|
325
|
+
return options.wait === false ? summarize(status) : follow(fetch, operationRef, output, options.waitOptions);
|
|
270
326
|
}
|
|
271
327
|
const deployment = status.deployment;
|
|
272
328
|
if (deployment?.state !== "LIVE" || !deployment.versionId || !deployment.protectedUrl)
|
|
273
329
|
throw new CliError("LIVE_PREVIEW_REQUIRED", "The preview must be live before it can be promoted. Check `isomorph status` first.", operationRef);
|
|
274
330
|
if (!options.confirmedTested)
|
|
275
331
|
throw new CliError("PROMOTION_NOT_CONFIRMED", `The preview has not been confirmed as tested: ${deployment.protectedUrl}`, operationRef, "Once the person has opened the preview and it works, run this again with --confirm-tested.");
|
|
276
|
-
const result =
|
|
277
|
-
operationId: operationRef,
|
|
278
|
-
expectedPreviewVersionId: deployment.versionId,
|
|
279
|
-
confirmation: { schema: "isomorph.stage-approval/1.0", step: "confirm_preview_tested_and_promote", approved: true, approvedByUser: true, userApprovalText: PROMOTION_CONFIRMATION }
|
|
280
|
-
}));
|
|
332
|
+
const result = await governance.promoteDeployment(appId, operationRef, { tested: true, app });
|
|
281
333
|
output("Isomorph accepted the production promotion.");
|
|
282
334
|
if (options.wait === false)
|
|
283
|
-
return
|
|
284
|
-
return follow(
|
|
285
|
-
}
|
|
286
|
-
export async function getAppSetup(client, operationRef) {
|
|
287
|
-
await client.initialize();
|
|
288
|
-
return structured(await client.call("isomorph_get_app_setup", { operationId: operationRef }));
|
|
289
|
-
}
|
|
290
|
-
export async function confirmProfile(client, operationRef, input, output) {
|
|
291
|
-
const setup = await getAppSetup(client, operationRef);
|
|
292
|
-
const displayName = input.displayName ?? setup.profile?.suggested?.displayName ?? setup.profile?.displayName;
|
|
293
|
-
const description = input.description ?? setup.profile?.suggested?.description ?? setup.profile?.description;
|
|
294
|
-
if (!displayName || !description)
|
|
295
|
-
throw new CliError("PROFILE_INCOMPLETE", "The app needs a name and a description in .isomorph/app.json; Isomorph had no suggestion to fall back on.", operationRef);
|
|
296
|
-
if (!setup.profile?.profileVersion)
|
|
297
|
-
throw new CliError("PROFILE_VERSION_MISSING", "Isomorph did not return the app details version.", operationRef);
|
|
298
|
-
await client.call("isomorph_confirm_app_profile", { operationId: operationRef, displayName, description, expectedVersion: setup.profile.profileVersion });
|
|
299
|
-
output(`Isomorph recorded the app's name and description: ${displayName}.`);
|
|
300
|
-
return getAppSetup(client, operationRef);
|
|
301
|
-
}
|
|
302
|
-
export async function confirmAudience(client, operationRef, emails, output) {
|
|
303
|
-
await client.initialize();
|
|
304
|
-
await client.call("isomorph_confirm_app_audience", { operationId: operationRef, emails });
|
|
305
|
-
output(emails.length ? `Isomorph recorded the app's audience: ${emails.length} colleague${emails.length === 1 ? "" : "s"}.` : "Isomorph recorded that only you can open the app for now.");
|
|
306
|
-
return getAppSetup(client, operationRef);
|
|
335
|
+
return summarize(result.status);
|
|
336
|
+
return follow(fetch, operationRef, output, options.waitOptions);
|
|
307
337
|
}
|
|
@@ -24,7 +24,9 @@ export function safeError(error) {
|
|
|
24
24
|
// Paths and identifiers are a list field, never prose: `redact` bounds prose and
|
|
25
25
|
// would elide the one token the reader needs. They are carried whole, always.
|
|
26
26
|
const paths = error instanceof CliError && error.paths?.length ? error.paths : undefined;
|
|
27
|
-
|
|
27
|
+
// Structured, so it is neither bounded nor redacted: a command's choices are its commands, and a refusal's blockers are what the reader acts on.
|
|
28
|
+
const details = error instanceof CliError && error.details && Object.keys(error.details).length ? error.details : undefined;
|
|
29
|
+
return { code, layer, message: redact(raw) || NO_DETAIL, ...(hint ? { remediationHint: hint } : {}), ...(paths ? { paths } : {}), ...(details ? { details } : {}) };
|
|
28
30
|
}
|
|
29
31
|
/** Anything that must never reach a terminal, a log or an envelope. */
|
|
30
32
|
const SECRETS = /https?:\/\/\S+|Bearer\s+\S+|(?:x-harbour|authorization|content-type)[^\n]*/gi;
|
|
@@ -127,7 +129,11 @@ export function renderFailure(envelope) {
|
|
|
127
129
|
const stages = ["production", "deployment"].filter(kind => result[kind]?.failure && result[kind]?.versionId).map(kind => `\n${stageLine(kind, result[kind])}`);
|
|
128
130
|
// One path per line, verbatim: never through `said`, which would bound them.
|
|
129
131
|
const paths = (error?.paths ?? []).map(path => `\n ${path}`);
|
|
130
|
-
|
|
132
|
+
// A refusal's choices are commands: one per line, verbatim, however long the app root is.
|
|
133
|
+
const choices = (Array.isArray(error?.details?.choices) ? error.details.choices : [])
|
|
134
|
+
.filter(choice => typeof choice.command === "string")
|
|
135
|
+
.map(choice => `\n ${choice.command}${typeof choice.description === "string" ? ` (${choice.description})` : ""}`);
|
|
136
|
+
return `${error?.message ?? NO_DETAIL}${error?.remediationHint ? ` ${error.remediationHint}` : ""}${paths.join("")}${choices.join("")}${stages.join("")}\n`;
|
|
131
137
|
}
|
|
132
138
|
/**
|
|
133
139
|
* Server-supplied text on its way to a terminal, given exactly the treatment
|
|
@@ -243,6 +249,8 @@ export class CliError extends Error {
|
|
|
243
249
|
layer;
|
|
244
250
|
/** Paths and identifiers the message refers to, carried whole: the envelope never bounds them. */
|
|
245
251
|
paths;
|
|
252
|
+
/** The structured half of a governance refusal (`error.details` on the wire, minus its `code`): what a caller reads to act on the refusal, never printed. */
|
|
253
|
+
details;
|
|
246
254
|
/** `result` becomes the envelope's `result`: what the command knew about the operation when it failed. */
|
|
247
255
|
constructor(code, message, operationRef, remediationHint, result, options = {}) {
|
|
248
256
|
super(message);
|
|
@@ -252,5 +260,6 @@ export class CliError extends Error {
|
|
|
252
260
|
this.result = result;
|
|
253
261
|
this.layer = options.layer ?? layerFor(code);
|
|
254
262
|
this.paths = options.paths?.length ? [...options.paths] : undefined;
|
|
263
|
+
this.details = options.details;
|
|
255
264
|
}
|
|
256
265
|
}
|