@isomorph.ai/cli 0.8.3 → 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.
@@ -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,6 +1,7 @@
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";
@@ -80,12 +81,14 @@ async function runDeploy(rootArg, governance, output, tenantId, options) {
80
81
  // it runs, so the tree is scanned only after it.
81
82
  await ensureChecksPassed(root, output, options);
82
83
  const { graph, files } = await readAppTree(root);
84
+ const report = await packagedReport(root, files);
83
85
  output(`Isomorph found ${files.length} app files.`);
84
- // The package: built once, from the bytes read above; `archiveForManifest`
85
- // re-reads every file and compares its sha256, so a tree that moved under us
86
- // is refused here rather than uploaded. Its digest is the operation's
87
- // identity on the server — the same package resumes the same operation.
88
- 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] });
89
92
  const archive = await archiveForManifest(root, manifest);
90
93
  const note = await readDeployNote(root, tenantId);
91
94
  if (note?.sha256 === archive.digest)
@@ -101,7 +104,7 @@ async function runDeploy(rootArg, governance, output, tenantId, options) {
101
104
  environment: "preview",
102
105
  app: { name: profile.name, description: profile.description, audience: profile.audience },
103
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 })) } },
104
- 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
105
108
  };
106
109
  let opened;
107
110
  try {
@@ -194,7 +197,7 @@ function skippedGates(report) {
194
197
  * Makes sure the checks have passed *for this tree*, running them when they
195
198
  * have not.
196
199
  *
197
- * `isomorph check` writes `.isomorph/local/check-report.json` with the digest of
200
+ * `isomorph check` writes `.isomorph/check-report.json` with the digest of
198
201
  * the source it ran against. A report is trusted when three things are true:
199
202
  * the checks ran, they ran against these bytes, and the ones that matter
200
203
  * actually ran rather than being skipped. Then nothing is run again — the gate
@@ -240,6 +243,28 @@ async function ensureChecksPassed(root, output, options) {
240
243
  if (skipped.length)
241
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 });
242
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
+ }
243
268
  /** The checks as `isomorph check` runs them, with the bundle this deploy carries. */
244
269
  function defaultRunChecks(options) {
245
270
  const bundle = options.bundle ?? EMBEDDED_KIT_BUNDLE;
@@ -309,7 +334,7 @@ function outcomeLine(summary) {
309
334
  }
310
335
  /**
311
336
  * Whether the app really calls governed AI: the gate's inventory of
312
- * `isomorph.ai.*` call sites (`.isomorph/local/check-report.json`,
337
+ * `isomorph.ai.*` call sites (`.isomorph/check-report.json`,
313
338
  * `gate.inventory.aiCallsites`). Not the capability word — a starter that
314
339
  * merely carried an `ai()` accessor was derived as `ai` and would have been
315
340
  * refused `AI_NOT_READY` at a company without AI for an app that never asks
@@ -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,7 +145,7 @@ 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
  }
149
150
  /** The description `init` writes: recognisable later, so a profile nobody has reviewed is never sent to IT. */
150
151
  const STARTER_DESCRIPTION_SUFFIX = ": an app built on Isomorph.";
@@ -250,24 +251,20 @@ export function linkIdempotencyKey(tenantId, root) {
250
251
  return createHash("sha256").update(`${tenantId}${resolve(root)}`).digest("hex");
251
252
  }
252
253
  /**
253
- * sha256 over the tracked app files (same boundary as deploy), so any
254
- * edit changes it — and, per file, the hash of its contents, so a stale check
255
- * can say WHICH path moved rather than only that something did. The boundary
256
- * leaves `.isomorph/checks/` out (analyzer.ts): the gate regenerates it on
257
- * 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.
258
261
  */
259
262
  export async function sourceDigest(root) {
260
263
  const graph = await scanWorkspace(root, { sourceBoundary: "root" });
261
264
  const files = [...graph.deploymentScope.includedFiles].sort();
262
- const hash = createHash("sha256");
263
- const entries = {};
264
- for (const path of files) {
265
- const content = await readFile(join(root, path));
266
- hash.update(path).update("\0");
267
- hash.update(content).update("\0");
268
- entries[path] = createHash("sha256").update(content).digest("hex");
269
- }
270
- 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 };
271
268
  }
272
269
  /**
273
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.3",
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
  ],