@isomorph.ai/cli 0.3.3 → 0.3.4-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,28 @@ import { CliError } from "./output.js";
7
7
  import { DEPENDENT_READ_OPERATIONS, READ_OPERATIONS, kitPaths, readDeclaration, readKitLock, resourceNames, sourceDigest } from "./kit.js";
8
8
  import { LocalRuntime, allocatePorts, ensureSdk, freePort, nodePackageCommand, readDevLock, runCommand, runningOrigin } from "./local-runtime.js";
9
9
  import { CLI_VERSION } from "./version.js";
10
+ /**
11
+ * What actually failed, in the refusal itself.
12
+ *
13
+ * The report is already in hand at the call site, and a refusal that spends its
14
+ * message on a file path costs the reader a whole turn to learn anything —
15
+ * `productionise` already names the failing checks when it refuses to deploy
16
+ * (`nameList(failed)`), and `check` did not. The detail of the first failure is
17
+ * carried too, capped, because "which check" is a smaller answer than "why".
18
+ */
19
+ export function checksFailedMessage(report) {
20
+ const failed = report.checks.filter(check => check.status === "fail");
21
+ if (failed.length === 0)
22
+ return "One or more checks failed.";
23
+ const names = failed.map(check => check.name).join(", ");
24
+ const first = failed.find(check => check.detail)?.detail?.replace(/\s+/g, " ").trim();
25
+ const detail = first ? ` ${first.length > DETAIL_CAP ? `${first.slice(0, DETAIL_CAP - 1)}\u2026` : first}` : "";
26
+ return `${failed.length === 1 ? "Check" : "Checks"} failed: ${names}.${detail}`;
27
+ }
28
+ /** The bound on the reason carried into the refusal; the rest stays in the report. */
29
+ const DETAIL_CAP = 200;
30
+ /** Where the rest of it is, and the one command that re-runs them. */
31
+ 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.";
10
32
  /** The name the gate reports operation coverage under — the pipeline's rule for it. */
11
33
  export const FLOW_CHECK = "flow.check-failed";
12
34
  /**
@@ -78,6 +100,7 @@ export async function runChecks(root, options) {
78
100
  schema: "isomorph.check-report/1.0",
79
101
  createdAt: new Date().toISOString(),
80
102
  sourceDigest: source.digest,
103
+ sourceFiles: source.entries,
81
104
  toolchain: { node: process.version, cliVersion: CLI_VERSION, bundle: { kitVersion: bundle.kitVersion, sdkTarballSha256: bundle.sdk.tarballSha256, appGateway: bundle.images.appGateway, sessionFixture: bundle.images.sessionFixture, briefFingerprint: bundle.brief.fingerprint } },
82
105
  checks,
83
106
  gate,
@@ -12,7 +12,7 @@ import { initKit } from "./starter.js";
12
12
  import { agentPaths, agentSetup, cliVersionLines } from "./agent-setup.js";
13
13
  import { startDev } from "./dev.js";
14
14
  import { ensureSdk, LocalRuntime, readDevLock, releaseDevLock, runCommand } from "./local-runtime.js";
15
- import { runChecks } from "./check.js";
15
+ import { CHECKS_FAILED_HINT, checksFailedMessage, runChecks } from "./check.js";
16
16
  import { assertAiReady, companySystemsLine, GovernanceClient, groupGrants, IDENTITY_WORDS, integrationsCatalog, integrationsStatus, renderGrantGroup, renderIntegrationsCatalog, requestIntegrations } from "./integrations.js";
17
17
  import { runJob } from "./jobs.js";
18
18
  const args = process.argv.slice(2);
@@ -112,8 +112,12 @@ const exitAfterWriting = (code, out, err = "") => { process.stderr.write(err, ()
112
112
  const USAGE_REFUSALS = ["INTEGRATIONS_NOT_READY", "AI_NOT_READY", "NOT_A_MEMBER", "TENANT_AMBIGUOUS", "NOT_A_START_LINK", "PLATFORM_UNREACHABLE"];
113
113
  if (command === "--version" || command === "version") {
114
114
  process.stdout.write(`${CLI_VERSION}\n`);
115
+ // `-h` was matched anywhere in argv while `--help` was only recognised as the
116
+ // first token, so `isomorph productionise --help` fell through to argument
117
+ // validation and exited 2 with the usage block as an error. Both spellings now
118
+ // mean the same thing wherever they appear.
115
119
  }
116
- else if (!command || command === "help" || command === "--help" || args.includes("-h")) {
120
+ else if (!command || command === "help" || command === "--help" || args.includes("-h") || args.includes("--help")) {
117
121
  process.stdout.write(usage);
118
122
  }
119
123
  else if (command === "agent-setup") {
@@ -202,8 +206,12 @@ else {
202
206
  governance = new GovernanceClient(config.apiUrl, token, config.tenantId);
203
207
  }
204
208
  const report = await runChecks(target, { run: runCommand, bundle, output: progress, governance });
205
- if (!report.passed)
206
- exitAfterWriting(1, json ? `${JSON.stringify({ ...summaryEnvelope(report), status: "FAILED", error: { code: "CHECKS_FAILED", message: "One or more checks failed; see .isomorph/local/check-report.json." } })}\n` : "Checks failed; see .isomorph/local/check-report.json.\n");
209
+ if (!report.passed) {
210
+ const message = checksFailedMessage(report);
211
+ exitAfterWriting(1, json
212
+ ? `${JSON.stringify({ ...summaryEnvelope(report), status: "FAILED", error: { code: "CHECKS_FAILED", message, remediationHint: CHECKS_FAILED_HINT } })}\n`
213
+ : `${message}\n${CHECKS_FAILED_HINT}\n`);
214
+ }
207
215
  else
208
216
  emit(summaryEnvelope(report));
209
217
  }
@@ -136,14 +136,45 @@ export function projectName(root, suffix = "") {
136
136
  export function linkIdempotencyKey(tenantId, root) {
137
137
  return createHash("sha256").update(`${tenantId}${resolve(root)}`).digest("hex");
138
138
  }
139
- /** sha256 over the tracked app files (same boundary as productionise), so any edit changes it. */
139
+ /**
140
+ * sha256 over the tracked app files (same boundary as productionise), so any
141
+ * edit changes it — and, per file, the hash of its contents, so a stale check
142
+ * can say WHICH path moved rather than only that something did.
143
+ */
140
144
  export async function sourceDigest(root) {
141
145
  const graph = await scanWorkspace(root, { sourceBoundary: "root" });
142
146
  const files = [...graph.deploymentScope.includedFiles].sort();
143
147
  const hash = createHash("sha256");
148
+ const entries = {};
144
149
  for (const path of files) {
150
+ const content = await readFile(join(root, path));
145
151
  hash.update(path).update("\0");
146
- hash.update(await readFile(join(root, path))).update("\0");
152
+ hash.update(content).update("\0");
153
+ entries[path] = createHash("sha256").update(content).digest("hex");
147
154
  }
148
- return { digest: `sha256:${hash.digest("hex")}`, files };
155
+ return { digest: `sha256:${hash.digest("hex")}`, files, entries };
156
+ }
157
+ /**
158
+ * What moved between the tree the checks ran on and the tree being deployed.
159
+ *
160
+ * A builder hit this three times in one session: their agent wrote scratch files
161
+ * into a gitignored folder inside the app root, which is still inside the
162
+ * deployment boundary, so every write invalidated the checks. The refusal named
163
+ * the code but not the path, so nothing pointed at the folder. Naming the paths
164
+ * is the whole fix.
165
+ */
166
+ export function describeSourceChange(before, after) {
167
+ if (!before)
168
+ return "";
169
+ const added = Object.keys(after).filter(path => !(path in before)).sort();
170
+ const removed = Object.keys(before).filter(path => !(path in after)).sort();
171
+ const changed = Object.keys(after).filter(path => path in before && before[path] !== after[path]).sort();
172
+ const some = (paths, verb) => {
173
+ if (!paths.length)
174
+ return "";
175
+ const shown = paths.slice(0, 3).join(", ");
176
+ const rest = paths.length - 3;
177
+ return `${verb} ${shown}${rest > 0 ? ` and ${rest} more` : ""}`;
178
+ };
179
+ return [some(added, "added"), some(changed, "changed"), some(removed, "removed")].filter(Boolean).join("; ");
149
180
  }
@@ -10,7 +10,7 @@ import { confirmAudience, confirmProfile, follow, getAppSetup, pickSourceFailure
10
10
  import { CLI_VERSION } from "./version.js";
11
11
  import { isProhibitedSecretPath } from "../../../src/secret-paths.js";
12
12
  import { assertAiReady, assertPreviewIntegrationsReady } from "./integrations.js";
13
- import { kitPaths, readKitLock, recordKitAppId, sourceDigest } from "./kit.js";
13
+ import { describeSourceChange, kitPaths, readKitLock, recordKitAppId, sourceDigest } from "./kit.js";
14
14
  import { FLOW_CHECK, preflightKitGate, readReport } from "./check.js";
15
15
  /** What the maker types at the save prompt; `--confirm-save` supplies this same literal, so the
16
16
  * `isomorph.stage-approval/1.0` attestation governance receives is unchanged, byte for byte. */
@@ -302,8 +302,20 @@ async function assertChecksPassedForTree(root, output) {
302
302
  if (!report)
303
303
  throw new CliError("CHECKS_NOT_RUN", `This app keeps ${retained.length} check${plural} that say whether it still works, and ${retained.length === 1 ? "it has" : "they have"} never been run against this code. Isomorph does not deploy an app whose own checks have not passed.`, undefined, RUN_THE_CHECKS);
304
304
  const tree = await sourceDigest(root);
305
- if (report.sourceDigest !== tree.digest)
306
- throw new CliError("CHECKS_STALE", "The app's code has changed since its checks last ran, so that result no longer describes what would be deployed. Isomorph does not deploy an app whose own checks have not passed for this exact code.", undefined, RUN_THE_CHECKS);
305
+ if (report.sourceDigest !== tree.digest) {
306
+ // Name the paths. Without them the refusal is true but unactionable: a
307
+ // builder whose agent wrote scratch files into a gitignored folder inside
308
+ // the app root hit this three times before finding the folder, because
309
+ // everything inside the root is inside the deployment boundary whether git
310
+ // tracks it or not. Reports written before CLI 0.3.4 carry no file hashes,
311
+ // so the sentence degrades to what it always said.
312
+ // The paths lead, and the whole sentence stays inside output.ts's
313
+ // DETAIL_BOUND. That bound keeps both ENDS and elides the middle, precisely
314
+ // because a filename once sat at index 266 and was never printed — so a
315
+ // path placed mid-sentence is put in the one position the bounding deletes.
316
+ const moved = describeSourceChange(report.sourceFiles, tree.entries);
317
+ throw new CliError("CHECKS_STALE", `The app's code has changed since its checks last ran${moved ? ` (${moved})` : ""}. Isomorph does not deploy an app whose checks have not passed for this exact code; everything inside the app folder counts, including files git ignores.`, undefined, RUN_THE_CHECKS);
318
+ }
307
319
  const checks = Array.isArray(report.checks) ? report.checks : [];
308
320
  const failed = checks.filter(check => check.status === "fail").map(check => check.name);
309
321
  if (failed.length || report.passed !== true)
@@ -18,7 +18,7 @@ export async function initKit(root, bundle, options = {}) {
18
18
  const existingPackage = await readJson(join(root, "package.json"));
19
19
  const emptyDir = !existingPackage && !(await exists(join(root, "src")));
20
20
  if (existingPackage && !isSupportedApp(existingPackage))
21
- throw new CliError("APP_UNSUPPORTED", "isomorph init supports an empty directory or an existing Vite + React app (package.json must depend on vite and react).");
21
+ throw new CliError("APP_UNSUPPORTED", "isomorph init supports an empty directory or an existing Vite + React app (package.json must depend on vite and react).", undefined, "Run `isomorph init --app-root <new-empty-folder>` to start a Vite + React app, then move this app's code into it.");
22
22
  // An app from a CLI older than 0.2.0 is moved to this layout by `--upgrade`
23
23
  // and by nothing else: a plain `init` over it would write a second, empty kit
24
24
  // beside the one it has. Refused before anything, the user-level skill included, is written.
@@ -1 +1 @@
1
- export const CLI_VERSION = "0.3.3";
1
+ export const CLI_VERSION = "0.3.4-rc.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isomorph.ai/cli",
3
- "version": "0.3.3",
3
+ "version": "0.3.4-rc.1",
4
4
  "description": "Isomorph development kit CLI",
5
5
  "type": "module",
6
6
  "bin": {