@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.
@@ -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/local/check-report.json; fix what it reports, then run `isomorph check --app-root .` again.";
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/local/check-report.json`; a source
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, readAppProfile, readDeclaration, readKitLock, sourceDigest } from "./kit.js";
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
- const profile = await readAppProfile(root);
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; `archiveForManifest`
86
- // re-reads every file and compares its sha256, so a tree that moved under us
87
- // is refused here rather than uploaded. Its digest is the operation's
88
- // identity on the server — the same package resumes the same operation.
89
- const manifest = await createSourceManifest({ tenantId, appId, operationId: LOCAL_MANIFEST_OPERATION, graphDigest: graph.graphDigest, files });
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/local/check-report.json` with the digest of
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/local/check-report.json`,
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 { declarationDigest, kitPaths } from "./kit.js";
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` rewrites it as the app's
38
- * calls change — so adding a company system needs no restart. Before this the
39
- * session filed once at start, nothing watched the file, and a builder's new
40
- * connection was never requested (2026-09-17 round). `poll` is one tick; a
41
- * filing that failed is retried on the next tick with the same digest.
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 declarationDigest(root);
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 declarationDigest(root);
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
- /** Links the app once (stable idempotency key) and records the appId in kit.lock. */
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 linked = await client.link({ displayName: basename(root), idempotencyKey: linkIdempotencyKey(tenantId, root) });
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(local, "check-report.json"), deployNote: join(local, "deploy.json") };
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 real description from the start: the person changes it, but a first
151
- // deploy is never refused for a field they have not been shown yet.
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}: an app built on Isomorph.`, audience: [] };
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), so any
205
- * edit changes it — and, per file, the hash of its contents, so a stale check
206
- * can say WHICH path moved rather than only that something did. The boundary
207
- * leaves `.isomorph/checks/` out (analyzer.ts): the gate regenerates it on
208
- * every check, and a regeneration is not a change to the app.
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 hash = createHash("sha256");
214
- const entries = {};
215
- for (const path of files) {
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
@@ -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
- if (isShellContextFile(file.basename))
260
- pushMatches(content, SHELL_EXPANSION_PATTERN, names);
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] ?? "");
@@ -18,15 +18,17 @@ export function isSourceIntakeExcludedPath(path) {
18
18
  || /(^|\/)\.github\/workflows\//i.test(path);
19
19
  }
20
20
  /**
21
- * The development kit commits exactly these files under `.isomorph`: the
22
- * integrations declaration, the kit lock and the app profile. Everything else
23
- * there stays local — local state, and the generated `checks/` and
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.8.2",
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
  ],