@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.
- package/dist/packages/harbour-cli/src/check.js +2 -2
- package/dist/packages/harbour-cli/src/deploy.js +33 -8
- package/dist/packages/harbour-cli/src/kit.js +13 -16
- 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
|
@@ -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,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
|
|
85
|
-
// re-reads every file and compares its
|
|
86
|
-
//
|
|
87
|
-
// identity on the server — the same
|
|
88
|
-
|
|
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/
|
|
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/
|
|
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(
|
|
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
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
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
|
|
263
|
-
const entries =
|
|
264
|
-
|
|
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
|
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
|
],
|