@isomorph.ai/cli 0.8.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/packages/harbour-cli/src/agent-setup.js +1 -1
- package/dist/packages/harbour-cli/src/check.js +2 -2
- package/dist/packages/harbour-cli/src/deploy.js +40 -16
- package/dist/packages/harbour-cli/src/dev.js +15 -10
- package/dist/packages/harbour-cli/src/integrations.js +21 -9
- package/dist/packages/harbour-cli/src/kit.js +65 -19
- package/dist/packages/harbour-cli/src/starter.js +1 -1
- package/dist/src/analyzer.js +27 -5
- package/dist/src/secret-paths.js +6 -4
- package/dist/src/source-digest.js +16 -0
- package/package.json +2 -1
|
@@ -108,7 +108,7 @@ Rules live beside this file: \`core.md\` before the first edit; \`integrations.m
|
|
|
108
108
|
|
|
109
109
|
## Getting ready
|
|
110
110
|
|
|
111
|
-
- No \`.isomorph/\` yet: \`isomorph init --app-root .\` (empty folder → starter; an existing app → three choices to relay). Node 22+, no Docker.
|
|
111
|
+
- No \`.isomorph/\` yet: \`isomorph init --app-root .\` (empty folder → starter; an existing app → three choices to relay). Node 22+, no Docker. Then write the app's \`name\` and \`description\` into \`.isomorph/app.json\` from what they asked and tell them both: IT sees the app under exactly these, once, on its first approval.
|
|
112
112
|
- Sign-in, only when a command answers \`CONFIG_REQUIRED\` or \`AUTH_REQUIRED\` (most machines already are): \`isomorph connect <work-email>\`, then \`isomorph login\` — the browser opens and the person finishes there (their one step; say so).
|
|
113
113
|
|
|
114
114
|
## Intent → command
|
|
@@ -27,13 +27,13 @@ export function checksFailedMessage(report) {
|
|
|
27
27
|
/** The bound on the reason carried into the refusal; the rest stays in the report. */
|
|
28
28
|
const DETAIL_CAP = 200;
|
|
29
29
|
/** Where the rest of it is, and the one command that re-runs them. */
|
|
30
|
-
export const CHECKS_FAILED_HINT = "Full detail is in .isomorph/
|
|
30
|
+
export const CHECKS_FAILED_HINT = "Full detail is in .isomorph/check-report.json; fix what it reports, then run `isomorph check --app-root .` again.";
|
|
31
31
|
/** The name the gate reports operation coverage under — the pipeline's rule for it. */
|
|
32
32
|
export const FLOW_CHECK = "flow.check-failed";
|
|
33
33
|
/** The CLI's own check over `jobs/`: a company operation a job calls that would run as a person. */
|
|
34
34
|
export const JOBS_CHECK = "jobs.identity";
|
|
35
35
|
/**
|
|
36
|
-
* Runs the kit checks and writes `.isomorph/
|
|
36
|
+
* Runs the kit checks and writes `.isomorph/check-report.json`; a source
|
|
37
37
|
* edit changes sourceDigest and so invalidates the previous report. The app's
|
|
38
38
|
* own toolchain first (typecheck, build), then the kit gate — the pipeline's
|
|
39
39
|
* gate, run by the pinned native gateway in this app's local session — whose
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { basename, dirname, resolve } from "node:path";
|
|
2
2
|
import { readFile, mkdir, rename, writeFile } from "node:fs/promises";
|
|
3
3
|
import { createSourceManifest } from "../../../src/source-intake.js";
|
|
4
|
+
import { sourceDigestOf } from "../../../src/source-digest.js";
|
|
4
5
|
import { archiveForManifest, putMultipart } from "./upload.js";
|
|
5
6
|
import { CliError } from "./output.js";
|
|
6
7
|
import { continueCommand, follow, outcomeFor, pickSourceFailure, recordedRefusal, runningLine, statusFetch, summarize } from "./operations.js";
|
|
7
8
|
import { CLI_VERSION } from "./version.js";
|
|
8
9
|
import { assertSafeAppRoot, readAppTree } from "./package.js";
|
|
9
10
|
import { deployBlocked, ensureLinkedApp, kitLockBody } from "./integrations.js";
|
|
10
|
-
import { assertLockCompany, describeSourceChange, kitPaths,
|
|
11
|
+
import { assertLockCompany, describeSourceChange, kitPaths, readReviewedAppProfile, readDeclaration, readKitLock, sourceDigest } from "./kit.js";
|
|
11
12
|
import { CHECKS_FAILED_HINT, checksFailedMessage, FLOW_CHECK, readReport, runChecks } from "./check.js";
|
|
12
13
|
import { runCommand } from "./local-runtime.js";
|
|
13
14
|
import { EMBEDDED_KIT_BUNDLE } from "./kit-bundle.js";
|
|
@@ -56,15 +57,14 @@ async function runDeploy(rootArg, governance, output, tenantId, options) {
|
|
|
56
57
|
assertLockCompany(lock, tenantId);
|
|
57
58
|
// The three values the person confirmed, printed so the transcript shows what
|
|
58
59
|
// was recorded; recorded on the operation before anything is uploaded.
|
|
59
|
-
|
|
60
|
+
// Refused here, in the first second, and by the same rule as the first access
|
|
61
|
+
// request (`APP_PROFILE_UNREVIEWED`): a deploy with the starter profile used
|
|
62
|
+
// to run the whole gate and open an operation before the profile was refused
|
|
63
|
+
// (live, 2026-09-16). The deploy route records all three values, so it
|
|
64
|
+
// refuses a blank one too; this only says so before the checks spend a minute.
|
|
65
|
+
const profile = await readReviewedAppProfile(root);
|
|
60
66
|
for (const line of describeProfile(profile))
|
|
61
67
|
output(line);
|
|
62
|
-
// Refused here, in the first second: a deploy with no description used to run
|
|
63
|
-
// the whole gate and open an operation before the profile was refused (live,
|
|
64
|
-
// 2026-09-16). The deploy route records all three values, so it refuses one
|
|
65
|
-
// that is blank too; this only says so before the checks spend a minute.
|
|
66
|
-
if (!profile.name.trim() || !profile.description.trim())
|
|
67
|
-
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.");
|
|
68
68
|
const declaration = await readDeclaration(root);
|
|
69
69
|
// Link first: the app's identity lands in `.isomorph/kit.lock.json`, which is
|
|
70
70
|
// one of the deployable files, so a report pinned after the link stays fresh
|
|
@@ -81,12 +81,14 @@ async function runDeploy(rootArg, governance, output, tenantId, options) {
|
|
|
81
81
|
// it runs, so the tree is scanned only after it.
|
|
82
82
|
await ensureChecksPassed(root, output, options);
|
|
83
83
|
const { graph, files } = await readAppTree(root);
|
|
84
|
+
const report = await packagedReport(root, files);
|
|
84
85
|
output(`Isomorph found ${files.length} app files.`);
|
|
85
|
-
// The package: built once, from the bytes read above
|
|
86
|
-
// re-reads every file and compares its
|
|
87
|
-
//
|
|
88
|
-
// identity on the server — the same
|
|
89
|
-
|
|
86
|
+
// The package: built once, from the bytes read above plus the report those
|
|
87
|
+
// bytes passed; `archiveForManifest` re-reads every file and compares its
|
|
88
|
+
// sha256, so a tree that moved under us is refused here rather than
|
|
89
|
+
// uploaded. Its digest is the operation's identity on the server — the same
|
|
90
|
+
// package resumes the same operation.
|
|
91
|
+
const manifest = await createSourceManifest({ tenantId, appId, operationId: LOCAL_MANIFEST_OPERATION, graphDigest: graph.graphDigest, files: [...files, report.file] });
|
|
90
92
|
const archive = await archiveForManifest(root, manifest);
|
|
91
93
|
const note = await readDeployNote(root, tenantId);
|
|
92
94
|
if (note?.sha256 === archive.digest)
|
|
@@ -102,7 +104,7 @@ async function runDeploy(rootArg, governance, output, tenantId, options) {
|
|
|
102
104
|
environment: "preview",
|
|
103
105
|
app: { name: profile.name, description: profile.description, audience: profile.audience },
|
|
104
106
|
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 })) } },
|
|
105
|
-
graph, cliVersion: CLI_VERSION, kitBundleVersion: bundle.kitVersion, declaration, callsAi: await appCallsAi(root), kitLock: kitLockBody(lock.bundle)
|
|
107
|
+
graph, cliVersion: CLI_VERSION, kitBundleVersion: bundle.kitVersion, declaration, callsAi: await appCallsAi(root), kitLock: kitLockBody(lock.bundle), checkReport: report.summary
|
|
106
108
|
};
|
|
107
109
|
let opened;
|
|
108
110
|
try {
|
|
@@ -195,7 +197,7 @@ function skippedGates(report) {
|
|
|
195
197
|
* Makes sure the checks have passed *for this tree*, running them when they
|
|
196
198
|
* have not.
|
|
197
199
|
*
|
|
198
|
-
* `isomorph check` writes `.isomorph/
|
|
200
|
+
* `isomorph check` writes `.isomorph/check-report.json` with the digest of
|
|
199
201
|
* the source it ran against. A report is trusted when three things are true:
|
|
200
202
|
* the checks ran, they ran against these bytes, and the ones that matter
|
|
201
203
|
* actually ran rather than being skipped. Then nothing is run again — the gate
|
|
@@ -241,6 +243,28 @@ async function ensureChecksPassed(root, output, options) {
|
|
|
241
243
|
if (skipped.length)
|
|
242
244
|
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 });
|
|
243
245
|
}
|
|
246
|
+
/** The report's path inside the package: beside the lock, where intake looks for it. */
|
|
247
|
+
const REPORT_PACKAGE_PATH = ".isomorph/check-report.json";
|
|
248
|
+
/**
|
|
249
|
+
* The report `ensureChecksPassed` trusted, read once as the bytes the package
|
|
250
|
+
* carries and the three values the deploy route reads. Intake recomputes the
|
|
251
|
+
* tree's digest and refuses a package whose report is for other bytes, so the
|
|
252
|
+
* pair is held to that rule here first, against the files going into the
|
|
253
|
+
* archive rather than a second scan.
|
|
254
|
+
*/
|
|
255
|
+
async function packagedReport(root, files) {
|
|
256
|
+
let content;
|
|
257
|
+
try {
|
|
258
|
+
content = new Uint8Array(await readFile(kitPaths(root).report));
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
throw new CliError("CHECKS_NOT_RUN", `Isomorph could not read ${REPORT_PACKAGE_PATH}.`, undefined, "Run `isomorph check --app-root .`, then run this again.");
|
|
262
|
+
}
|
|
263
|
+
const report = JSON.parse(new TextDecoder().decode(content));
|
|
264
|
+
if (report.passed !== true || report.sourceDigest !== sourceDigestOf(files))
|
|
265
|
+
throw new CliError("CHECKS_STALE", "The app changed while it was being packaged, so its check report is not for this code.", undefined, "Run the same deploy command again: it checks the app as it is now.");
|
|
266
|
+
return { file: { path: REPORT_PACKAGE_PATH, content }, summary: { sourceDigest: report.sourceDigest, passed: report.passed, createdAt: report.createdAt } };
|
|
267
|
+
}
|
|
244
268
|
/** The checks as `isomorph check` runs them, with the bundle this deploy carries. */
|
|
245
269
|
function defaultRunChecks(options) {
|
|
246
270
|
const bundle = options.bundle ?? EMBEDDED_KIT_BUNDLE;
|
|
@@ -310,7 +334,7 @@ function outcomeLine(summary) {
|
|
|
310
334
|
}
|
|
311
335
|
/**
|
|
312
336
|
* Whether the app really calls governed AI: the gate's inventory of
|
|
313
|
-
* `isomorph.ai.*` call sites (`.isomorph/
|
|
337
|
+
* `isomorph.ai.*` call sites (`.isomorph/check-report.json`,
|
|
314
338
|
* `gate.inventory.aiCallsites`). Not the capability word — a starter that
|
|
315
339
|
* merely carried an `ai()` accessor was derived as `ai` and would have been
|
|
316
340
|
* refused `AI_NOT_READY` at a company without AI for an app that never asks
|
|
@@ -6,7 +6,7 @@ import { createForwarder } from "./forwarder.js";
|
|
|
6
6
|
import { readKitLock } from "./kit.js";
|
|
7
7
|
import { GovernanceClient, ensureLinkedApp, fileDeclaredRequests, IDENTITY_WORDS, renderGrantGroup } from "./integrations.js";
|
|
8
8
|
import { acquireDevLock, allocatePorts, assertMigrationNames, installDependencies, LocalRuntime, nodePackageCommand, pidAlive, readDevLock, recordDevChildren, releaseDevLock, runCommand } from "./local-runtime.js";
|
|
9
|
-
import {
|
|
9
|
+
import { accessDigest, kitPaths } from "./kit.js";
|
|
10
10
|
import { CliError, safeError } from "./output.js";
|
|
11
11
|
/**
|
|
12
12
|
* Signed in at startup: link the app and file the access request for every
|
|
@@ -29,16 +29,21 @@ export async function companyAccessAtStartup(root, client, tenantId, bundle) {
|
|
|
29
29
|
return { appId: filed.appId, lines };
|
|
30
30
|
}
|
|
31
31
|
catch (error) {
|
|
32
|
+
// Not a fault: the ask waits on the person. Said in full, since this line is the only place the builder learns why IT has nothing yet; the watcher files once app.json changes.
|
|
33
|
+
if (error instanceof CliError && error.code === "APP_PROFILE_UNREVIEWED")
|
|
34
|
+
return { lines: [`Company access not requested yet: ${error.message} ${error.remediationHint ?? ""}`.trim()], failed: true };
|
|
32
35
|
return { lines: [`Company access could not be checked (${safeError(error).message}); the app runs without it until the next start.`], failed: true };
|
|
33
36
|
}
|
|
34
37
|
}
|
|
35
38
|
/**
|
|
36
|
-
* Files the declared requests again whenever `.isomorph/integrations.json`
|
|
37
|
-
* changes while `isomorph dev` runs — `isomorph check`
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
39
|
+
* Files the declared requests again whenever `.isomorph/integrations.json` or
|
|
40
|
+
* `.isomorph/app.json` changes while `isomorph dev` runs — `isomorph check`
|
|
41
|
+
* rewrites the first as the app's calls change, the builder edits the second
|
|
42
|
+
* when the person names the app — so adding a company system, or reviewing the
|
|
43
|
+
* profile the first request waits on, needs no restart. Before this the session
|
|
44
|
+
* filed once at start, nothing watched the file, and a builder's new connection
|
|
45
|
+
* was never requested (2026-09-17 round). `poll` is one tick; a filing that
|
|
46
|
+
* failed is retried on the next tick with the same digest.
|
|
42
47
|
*/
|
|
43
48
|
export function declarationWatcher(root, refile, output, filed) {
|
|
44
49
|
let known = filed;
|
|
@@ -49,12 +54,12 @@ export function declarationWatcher(root, refile, output, filed) {
|
|
|
49
54
|
return;
|
|
50
55
|
busy = true;
|
|
51
56
|
try {
|
|
52
|
-
const digest = await
|
|
57
|
+
const digest = await accessDigest(root);
|
|
53
58
|
if (digest === known)
|
|
54
59
|
return;
|
|
55
60
|
const result = await refile();
|
|
56
61
|
if (result.lines.length)
|
|
57
|
-
output([".isomorph/integrations.json changed: company access requested again.", ...result.lines].map(line => ` ${line}`).join("\n"));
|
|
62
|
+
output([".isomorph/integrations.json or app.json changed: company access requested again.", ...result.lines].map(line => ` ${line}`).join("\n"));
|
|
58
63
|
if (!result.failed)
|
|
59
64
|
known = digest;
|
|
60
65
|
}
|
|
@@ -158,7 +163,7 @@ export async function startDev(root, options) {
|
|
|
158
163
|
].join("\n"));
|
|
159
164
|
// Signed in: link now and file the app-wide requests — after the link is out, so governance's round-trips never hold it — and say where each connection stands.
|
|
160
165
|
if (governance && company) {
|
|
161
|
-
const filed = await
|
|
166
|
+
const filed = await accessDigest(root);
|
|
162
167
|
const access = await companyAccessAtStartup(root, governance, company.tenantId, options.bundle);
|
|
163
168
|
lockAppId = access.appId ?? lockAppId;
|
|
164
169
|
const lines = [...(access.appId && !linkedAtStart ? [`Linked app: ${access.appId}`] : []), ...access.lines];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
|
-
import { linkIdempotencyKey, readDeclaration, readKitLock, requestResourceName, requestResources, writeKitLock, newKitLock } from "./kit.js";
|
|
2
|
+
import { linkIdempotencyKey, readDeclaration, readKitLock, readReviewedAppProfile, requestResourceName, requestResources, writeKitLock, newKitLock } from "./kit.js";
|
|
3
3
|
import { CliError } from "./output.js";
|
|
4
4
|
import { CLI_VERSION } from "./version.js";
|
|
5
5
|
/** The lock's bundle as the preflight body carries it. */
|
|
@@ -18,7 +18,7 @@ export class GovernanceClient {
|
|
|
18
18
|
link(input) {
|
|
19
19
|
return this.call("POST", "/v1/development/apps/link", { tenantId: this.tenantId, ...input });
|
|
20
20
|
}
|
|
21
|
-
/** One app-wide request per (connection, identity): no environment and no expiry — IT approves once for every lane and sets any expiry itself. */
|
|
21
|
+
/** One app-wide request per (connection, identity): no environment and no expiry — IT approves once for every lane and sets any expiry itself. `app` is the reviewed name and description IT sees the ask under; governance records them before the request. */
|
|
22
22
|
request(appId, input) {
|
|
23
23
|
return this.call("POST", `/v1/development/apps/${encodeURIComponent(appId)}/integration-requests`, { tenantId: this.tenantId, ...input });
|
|
24
24
|
}
|
|
@@ -119,12 +119,18 @@ export function transportFailure(error) {
|
|
|
119
119
|
const text = words(cause) || (Array.isArray(cause?.errors) ? cause.errors.map(words).filter(Boolean).join("; ") : "") || String(cause);
|
|
120
120
|
return typeof cause?.code === "string" && !text.includes(cause.code) ? `${text} (${cause.code})` : text;
|
|
121
121
|
}
|
|
122
|
-
/**
|
|
122
|
+
/**
|
|
123
|
+
* Links the app once (stable idempotency key) and records the appId in kit.lock.
|
|
124
|
+
* The link's name is provisional — IT sees nothing of an app until its first
|
|
125
|
+
* access request, which carries the reviewed profile — so a profile not yet
|
|
126
|
+
* reviewed (or absent) links under the folder name and is renamed by that request.
|
|
127
|
+
*/
|
|
123
128
|
export async function ensureLinkedApp(root, client, tenantId, bundle) {
|
|
124
129
|
const lock = (await readKitLock(root)) ?? newKitLock(bundle, tenantId);
|
|
125
130
|
if (lock.appId)
|
|
126
131
|
return lock.appId;
|
|
127
|
-
const
|
|
132
|
+
const displayName = await readReviewedAppProfile(root).then(profile => profile.name, () => basename(root));
|
|
133
|
+
const linked = await client.link({ displayName, idempotencyKey: linkIdempotencyKey(tenantId, root) });
|
|
128
134
|
if (!linked.appId)
|
|
129
135
|
throw new CliError("LINK_FAILED", "Isomorph did not return an app identity for this app.");
|
|
130
136
|
await writeKitLock(root, { ...lock, appId: linked.appId, tenantId });
|
|
@@ -135,11 +141,11 @@ export const IDENTITY_WORDS = { app: "as the company bot", user: "as the signed-
|
|
|
135
141
|
/** A refusal about the leg rather than the ask — signed out, unreachable, an answer with no code — is nobody's ask and stops the run. */
|
|
136
142
|
const legFailure = (code) => code === "AUTH_REQUIRED" || code === "GOVERNANCE_UNREACHABLE" || code.startsWith("HTTP_");
|
|
137
143
|
/** One request per identity mode of the connection. A refusal of an ask is returned beside the others, not thrown, so a caller can report every connection at once. */
|
|
138
|
-
async function submitRequests(client, appId, declaration, root, connection, reason, only) {
|
|
144
|
+
async function submitRequests(client, appId, declaration, root, connection, app, reason, only) {
|
|
139
145
|
return Promise.all(requestScope(declaration, connection, only).map(async (part) => {
|
|
140
146
|
const ask = { connection, ...part };
|
|
141
147
|
try {
|
|
142
|
-
return { ...ask, outcome: await client.request(appId, { connection, identityMode: part.identityMode, operations: part.operations, resources: part.resources, ...(part.presentation ? { presentation: part.presentation } : {}), ...(reason?.trim() ? { reason: reason.trim() } : {}) }) };
|
|
148
|
+
return { ...ask, outcome: await client.request(appId, { connection, identityMode: part.identityMode, operations: part.operations, resources: part.resources, ...(part.presentation ? { presentation: part.presentation } : {}), ...(reason?.trim() ? { reason: reason.trim() } : {}), app }) };
|
|
143
149
|
}
|
|
144
150
|
catch (error) {
|
|
145
151
|
const refusal = unregisteredResourceGuidance(error, declaration, connection, root);
|
|
@@ -149,6 +155,8 @@ async function submitRequests(client, appId, declaration, root, connection, reas
|
|
|
149
155
|
}
|
|
150
156
|
}));
|
|
151
157
|
}
|
|
158
|
+
/** The two profile values an access request carries. */
|
|
159
|
+
const reviewedProfileBody = (profile) => ({ name: profile.name, description: profile.description });
|
|
152
160
|
/** The filed asks with every refusal thrown, for the commands that cannot go on past one. */
|
|
153
161
|
function settled(requests) {
|
|
154
162
|
return requests.map(item => { if (item.error)
|
|
@@ -163,15 +171,18 @@ function settled(requests) {
|
|
|
163
171
|
* one it is still deciding, so a repeat opens nothing. Links the app first
|
|
164
172
|
* (the same key as `integrations request`) and reads the lanes back, grouped.
|
|
165
173
|
* An app with no declaration or no connections is left alone: nothing to ask,
|
|
166
|
-
* so governance is not called.
|
|
174
|
+
* so governance is not called. A profile nobody has reviewed is refused
|
|
175
|
+
* (`APP_PROFILE_UNREVIEWED`) before anything is filed: the request is the
|
|
176
|
+
* first thing IT sees of the app, under that name and description.
|
|
167
177
|
*/
|
|
168
178
|
export async function fileDeclaredRequests(root, client, tenantId, bundle) {
|
|
169
179
|
const declaration = await readDeclaration(root);
|
|
170
180
|
const connections = Object.keys(declaration.connections);
|
|
171
181
|
if (!connections.length)
|
|
172
182
|
return undefined;
|
|
183
|
+
const app = reviewedProfileBody(await readReviewedAppProfile(root));
|
|
173
184
|
const appId = await ensureLinkedApp(root, client, tenantId, bundle);
|
|
174
|
-
const requests = (await Promise.all(connections.map(connection => submitRequests(client, appId, declaration, root, connection)))).flat();
|
|
185
|
+
const requests = (await Promise.all(connections.map(connection => submitRequests(client, appId, declaration, root, connection, app)))).flat();
|
|
175
186
|
return { appId, requests, groups: groupGrants((await client.list(appId)).grants) };
|
|
176
187
|
}
|
|
177
188
|
/**
|
|
@@ -181,8 +192,9 @@ export async function fileDeclaredRequests(root, client, tenantId, bundle) {
|
|
|
181
192
|
*/
|
|
182
193
|
export async function requestIntegrations(root, client, tenantId, bundle, options) {
|
|
183
194
|
const declaration = await readDeclaration(root);
|
|
195
|
+
const app = reviewedProfileBody(await readReviewedAppProfile(root));
|
|
184
196
|
const appId = await ensureLinkedApp(root, client, tenantId, bundle);
|
|
185
|
-
const submitted = settled(await submitRequests(client, appId, declaration, root, options.connection, options.reason, options.operations)).map(item => ({ ...item, state: item.outcome.state }));
|
|
197
|
+
const submitted = settled(await submitRequests(client, appId, declaration, root, options.connection, app, options.reason, options.operations)).map(item => ({ ...item, state: item.outcome.state }));
|
|
186
198
|
const sleep = options.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
|
|
187
199
|
const pollMs = options.pollMs ?? 3_000;
|
|
188
200
|
let grants = [];
|
|
@@ -3,6 +3,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { basename, dirname, join, parse, resolve } from "node:path";
|
|
5
5
|
import { scanWorkspace } from "../../../src/analyzer.js";
|
|
6
|
+
import { sourceDigestOf } from "../../../src/source-digest.js";
|
|
6
7
|
import { isKitBundle } from "./kit-bundle.js";
|
|
7
8
|
import { CliError } from "./output.js";
|
|
8
9
|
/** Explicit reads `isomorph check --integrations` may run; every other operation is a send and never runs during checks. */
|
|
@@ -131,7 +132,7 @@ export function newKitLock(bundle, tenantId = "", appId = "") {
|
|
|
131
132
|
return { schema: "isomorph.kit-lock/1.0", appId, tenantId, bundle, createdAt: now, updatedAt: now };
|
|
132
133
|
}
|
|
133
134
|
// ---- Paths and identity --------------------------------------------------------
|
|
134
|
-
/** Where a kit app keeps its declaration, lock and app profile (committed), and its generated checks and local state (ignored). */
|
|
135
|
+
/** Where a kit app keeps its declaration, lock and app profile (committed), the check report `deploy` packages, and its generated checks and local state (ignored). */
|
|
135
136
|
export const KIT_DIRECTORY = ".isomorph";
|
|
136
137
|
export function appRoot(rootArg) {
|
|
137
138
|
if (!rootArg)
|
|
@@ -144,13 +145,62 @@ export function appRoot(rootArg) {
|
|
|
144
145
|
export function kitPaths(root) {
|
|
145
146
|
const kit = join(root, KIT_DIRECTORY);
|
|
146
147
|
const local = join(kit, "local");
|
|
147
|
-
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(
|
|
148
|
+
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(kit, "check-report.json"), deployNote: join(local, "deploy.json") };
|
|
148
149
|
}
|
|
150
|
+
/** The description `init` writes: recognisable later, so a profile nobody has reviewed is never sent to IT. */
|
|
151
|
+
const STARTER_DESCRIPTION_SUFFIX = ": an app built on Isomorph.";
|
|
149
152
|
export function defaultAppProfile(root) {
|
|
150
|
-
// A
|
|
151
|
-
//
|
|
153
|
+
// A recognisable starter: `profileReviewProblem` refuses it before the first
|
|
154
|
+
// access request, so the person is shown the name and description exactly
|
|
155
|
+
// once, before IT sees either, and never again after.
|
|
152
156
|
const name = basename(resolve(root));
|
|
153
|
-
return { schema: "isomorph.app/1.0", name, description: `${name}
|
|
157
|
+
return { schema: "isomorph.app/1.0", name, description: `${name}${STARTER_DESCRIPTION_SUFFIX}`, audience: [] };
|
|
158
|
+
}
|
|
159
|
+
/** Governance's bounds for the profile (maker-app-actions `validateAppProfileInput`), applied here so the refusal is local and names the field. */
|
|
160
|
+
const PROFILE_NAME_LENGTH = { min: 3, max: 80 };
|
|
161
|
+
const PROFILE_DESCRIPTION_LENGTH = { min: 20, max: 240 };
|
|
162
|
+
/**
|
|
163
|
+
* Why the profile cannot go to IT yet, or `undefined` when it can. The first
|
|
164
|
+
* access request carries the app's name and description into IT's queue, and
|
|
165
|
+
* a name IT has seen cannot be unseen: before 0.8.3 that request went out with
|
|
166
|
+
* the folder name (`my-slack-thing-v2`) and no description, and the real name
|
|
167
|
+
* only arrived with `deploy` — relabelling the ask under the admin (2026-09-18).
|
|
168
|
+
* So the starter values `init` wrote are refused until the builder has shown
|
|
169
|
+
* them to the person and written what they said; a reviewed profile passes
|
|
170
|
+
* every later run unchanged.
|
|
171
|
+
*/
|
|
172
|
+
export function profileReviewProblem(profile, root) {
|
|
173
|
+
const name = profile.name.trim();
|
|
174
|
+
const description = profile.description.trim();
|
|
175
|
+
if (!name)
|
|
176
|
+
return "`name` is empty";
|
|
177
|
+
if (name === basename(resolve(root)))
|
|
178
|
+
return "`name` is still the folder name";
|
|
179
|
+
if (!description)
|
|
180
|
+
return "`description` is empty";
|
|
181
|
+
if (description.endsWith(STARTER_DESCRIPTION_SUFFIX))
|
|
182
|
+
return "`description` is still the starter text";
|
|
183
|
+
if (name.length < PROFILE_NAME_LENGTH.min || name.length > PROFILE_NAME_LENGTH.max)
|
|
184
|
+
return `\`name\` must be ${PROFILE_NAME_LENGTH.min}-${PROFILE_NAME_LENGTH.max} characters`;
|
|
185
|
+
if (description.length < PROFILE_DESCRIPTION_LENGTH.min || description.length > PROFILE_DESCRIPTION_LENGTH.max)
|
|
186
|
+
return `\`description\` must be ${PROFILE_DESCRIPTION_LENGTH.min}-${PROFILE_DESCRIPTION_LENGTH.max} characters`;
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
/** The profile as the first access request may carry it, or the one refusal that says what to review. */
|
|
190
|
+
export async function readReviewedAppProfile(root) {
|
|
191
|
+
const profile = await readAppProfile(root);
|
|
192
|
+
const problem = profileReviewProblem(profile, root);
|
|
193
|
+
if (problem)
|
|
194
|
+
throw new CliError("APP_PROFILE_UNREVIEWED", `IT will see this app by its name and description, and .isomorph/app.json has not been reviewed yet: ${problem}.`, undefined, "Show the person the `name` and `description` in .isomorph/app.json, write what they say (a name for colleagues, one sentence on what the app does), then run this again. This is asked once: a reviewed profile is never asked about again.");
|
|
195
|
+
return profile;
|
|
196
|
+
}
|
|
197
|
+
/** What changes the app's standing with the company: the declaration and the profile the first request carries. */
|
|
198
|
+
export async function accessDigest(root) {
|
|
199
|
+
const paths = kitPaths(root);
|
|
200
|
+
const hash = createHash("sha256");
|
|
201
|
+
for (const path of [paths.declaration, paths.app])
|
|
202
|
+
hash.update(await readFile(path).catch(() => Buffer.alloc(0))).update("\0");
|
|
203
|
+
return `sha256:${hash.digest("hex")}`;
|
|
154
204
|
}
|
|
155
205
|
/** `.isomorph/app.json` as `init` writes it. */
|
|
156
206
|
export function renderAppProfile(profile) {
|
|
@@ -201,24 +251,20 @@ export function linkIdempotencyKey(tenantId, root) {
|
|
|
201
251
|
return createHash("sha256").update(`${tenantId}${resolve(root)}`).digest("hex");
|
|
202
252
|
}
|
|
203
253
|
/**
|
|
204
|
-
* sha256 over the tracked app files (same boundary as deploy
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
254
|
+
* sha256 over the tracked app files (same boundary as deploy; `sourceDigestOf`
|
|
255
|
+
* is the one function intake recomputes it with), so any edit changes it —
|
|
256
|
+
* and, per file, the hash of its contents, so a stale check can say WHICH
|
|
257
|
+
* path moved rather than only that something did. The boundary leaves
|
|
258
|
+
* `.isomorph/checks/` and the report itself out (analyzer.ts): the gate
|
|
259
|
+
* regenerates the one on every check, and writing the other is not a change
|
|
260
|
+
* to the app.
|
|
209
261
|
*/
|
|
210
262
|
export async function sourceDigest(root) {
|
|
211
263
|
const graph = await scanWorkspace(root, { sourceBoundary: "root" });
|
|
212
264
|
const files = [...graph.deploymentScope.includedFiles].sort();
|
|
213
|
-
const
|
|
214
|
-
const entries =
|
|
215
|
-
|
|
216
|
-
const content = await readFile(join(root, path));
|
|
217
|
-
hash.update(path).update("\0");
|
|
218
|
-
hash.update(content).update("\0");
|
|
219
|
-
entries[path] = createHash("sha256").update(content).digest("hex");
|
|
220
|
-
}
|
|
221
|
-
return { digest: `sha256:${hash.digest("hex")}`, files, entries };
|
|
265
|
+
const contents = await Promise.all(files.map(async (path) => ({ path, content: new Uint8Array(await readFile(join(root, path))) })));
|
|
266
|
+
const entries = Object.fromEntries(contents.map(file => [file.path, createHash("sha256").update(file.content).digest("hex")]));
|
|
267
|
+
return { digest: sourceDigestOf(contents), files, entries };
|
|
222
268
|
}
|
|
223
269
|
/**
|
|
224
270
|
* What moved between the tree the checks ran on and the tree being deployed.
|
|
@@ -126,7 +126,7 @@ async function appendManaged(root, file, block, result, separator, start, end) {
|
|
|
126
126
|
const exists = (path) => stat(path).then(() => true, () => false);
|
|
127
127
|
const readJson = (path) => readFile(path, "utf8").then(text => JSON.parse(text), () => undefined);
|
|
128
128
|
// ---- Templates -----------------------------------------------------------------
|
|
129
|
-
const GITIGNORE_LINES = ["node_modules/", "dist/", ".isomorph/local/", ".isomorph/checks/"].join("\n");
|
|
129
|
+
const GITIGNORE_LINES = ["node_modules/", "dist/", ".isomorph/local/", ".isomorph/checks/", ".isomorph/check-report.json"].join("\n");
|
|
130
130
|
/**
|
|
131
131
|
* The per-app block: it says where the rules are and when to read each, and nothing
|
|
132
132
|
* else — the rules themselves live in the modules `agent-setup` writes beside the
|
package/dist/src/analyzer.js
CHANGED
|
@@ -250,19 +250,41 @@ function pushMatches(content, pattern, names) {
|
|
|
250
250
|
names.push(match[1] ?? match[2] ?? "");
|
|
251
251
|
}
|
|
252
252
|
}
|
|
253
|
+
/** A read that only exists on a comment line is not a read: `// stripeSecretKey:
|
|
254
|
+
* process.env.STRIPE_SECRET_KEY,` asked a builder for a key on 20 Sept 2026. */
|
|
255
|
+
const COMMENT_LINE = /^\s*(?:\/\/|#|\*|\/\*|<!--)/;
|
|
256
|
+
/** `const { SMTP_HOST, SMTP_PASS } = process.env` reads every destructured name. */
|
|
257
|
+
const ENV_DESTRUCTURE = /\{([^}]*)\}\s*=\s*process\.env\b/g;
|
|
258
|
+
/** `NAME=` at the start of a shell line declares a local variable; `$NAME`
|
|
259
|
+
* later in that file is not an environment read. */
|
|
260
|
+
const SHELL_LOCAL_ASSIGNMENT = /^\s*(?:export\s+)?([A-Z0-9_]{3,})=/gm;
|
|
261
|
+
function uncommentedLines(content) {
|
|
262
|
+
return content.split(/\r?\n/).filter(line => !COMMENT_LINE.test(line)).join("\n");
|
|
263
|
+
}
|
|
253
264
|
function detectEnvNames(files) {
|
|
254
265
|
const names = [];
|
|
255
266
|
for (const file of files) {
|
|
256
|
-
const content = file.content ?? "";
|
|
267
|
+
const content = uncommentedLines(file.content ?? "");
|
|
257
268
|
for (const pattern of ENV_ACCESS_PATTERNS)
|
|
258
269
|
pushMatches(content, pattern, names);
|
|
259
|
-
|
|
260
|
-
|
|
270
|
+
for (const match of content.matchAll(ENV_DESTRUCTURE)) {
|
|
271
|
+
for (const part of (match[1] ?? "").split(",")) {
|
|
272
|
+
const name = /^\s*([A-Z0-9_]{3,})\s*(?::|=|$)/.exec(part)?.[1];
|
|
273
|
+
if (name)
|
|
274
|
+
names.push(name);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (isShellContextFile(file.basename)) {
|
|
278
|
+
const locals = new Set([...content.matchAll(SHELL_LOCAL_ASSIGNMENT)].map(match => match[1] ?? ""));
|
|
279
|
+
const expanded = [];
|
|
280
|
+
pushMatches(content, SHELL_EXPANSION_PATTERN, expanded);
|
|
281
|
+
names.push(...expanded.filter(name => !locals.has(name)));
|
|
282
|
+
}
|
|
261
283
|
// package.json scripts run in a shell; expansion applies to the script
|
|
262
284
|
// strings alone, never to the raw JSON (dependency names, config values).
|
|
263
285
|
if (file.basename === "package.json") {
|
|
264
286
|
try {
|
|
265
|
-
const scripts = JSON.parse(content).scripts ?? {};
|
|
287
|
+
const scripts = JSON.parse(file.content ?? "").scripts ?? {};
|
|
266
288
|
for (const value of Object.values(scripts)) {
|
|
267
289
|
if (typeof value === "string")
|
|
268
290
|
pushMatches(value, SHELL_EXPANSION_PATTERN, names);
|
|
@@ -271,7 +293,7 @@ function detectEnvNames(files) {
|
|
|
271
293
|
catch { /* Invalid manifests contribute no names. */ }
|
|
272
294
|
}
|
|
273
295
|
if (isEnvTemplateFile(file.basename)) {
|
|
274
|
-
for (const line of content.split(/\r?\n/)) {
|
|
296
|
+
for (const line of (file.content ?? "").split(/\r?\n/)) {
|
|
275
297
|
const envMatch = /^([A-Z0-9_]{3,})=/.exec(line.trim());
|
|
276
298
|
if (envMatch)
|
|
277
299
|
names.push(envMatch[1] ?? "");
|
package/dist/src/secret-paths.js
CHANGED
|
@@ -18,15 +18,17 @@ export function isSourceIntakeExcludedPath(path) {
|
|
|
18
18
|
|| /(^|\/)\.github\/workflows\//i.test(path);
|
|
19
19
|
}
|
|
20
20
|
/**
|
|
21
|
-
* The development kit
|
|
22
|
-
* integrations declaration, the kit lock
|
|
23
|
-
*
|
|
21
|
+
* The development kit ships exactly these files under `.isomorph`: the
|
|
22
|
+
* integrations declaration, the kit lock, the app profile, and the check
|
|
23
|
+
* report `isomorph check` wrote for the tree being deployed (packaged by
|
|
24
|
+
* `deploy`, never scanned into the tree it digests). Everything else there
|
|
25
|
+
* stays local — local state, and the generated `checks/` and
|
|
24
26
|
* `ai-inventory.json`, which the pipeline's kit lane regenerates from the
|
|
25
27
|
* source it builds (data plane ADR 0014); a builder's own checks are
|
|
26
28
|
* `tests/*.mjs` at the app root, ordinary source.
|
|
27
29
|
*/
|
|
28
30
|
export function isKitCommittedPath(path) {
|
|
29
|
-
return /(^|\/)\.isomorph\/(?:integrations\.json|kit\.lock\.json|app\.json)$/.test(path);
|
|
31
|
+
return /(^|\/)\.isomorph\/(?:integrations\.json|kit\.lock\.json|app\.json|check-report\.json)$/.test(path);
|
|
30
32
|
}
|
|
31
33
|
/**
|
|
32
34
|
* Dependency and internal directories the intake never stages: `node_modules`,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* The digest `isomorph check` pins in its report and `isomorph deploy` and
|
|
4
|
+
* source intake recompute: sha256 over the app's files sorted by path, each as
|
|
5
|
+
* `path\0content\0`. One function on both sides of the upload, so a report
|
|
6
|
+
* that matched on the laptop matches at intake for the same bytes, and the
|
|
7
|
+
* order the files arrive in never matters.
|
|
8
|
+
*/
|
|
9
|
+
export function sourceDigestOf(files) {
|
|
10
|
+
const hash = createHash("sha256");
|
|
11
|
+
for (const file of [...files].sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))) {
|
|
12
|
+
hash.update(file.path).update("\0");
|
|
13
|
+
hash.update(file.content).update("\0");
|
|
14
|
+
}
|
|
15
|
+
return `sha256:${hash.digest("hex")}`;
|
|
16
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@isomorph.ai/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Isomorph development kit CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"dist/src/contracts.js",
|
|
20
20
|
"dist/src/digest.js",
|
|
21
21
|
"dist/src/secret-paths.js",
|
|
22
|
+
"dist/src/source-digest.js",
|
|
22
23
|
"dist/src/source-intake.js",
|
|
23
24
|
"package.json"
|
|
24
25
|
],
|