@isomorph.ai/cli 0.6.0 → 0.7.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.
@@ -2,7 +2,7 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
  import { CliError } from "./output.js";
4
4
  import { DEPENDENT_READ_OPERATIONS, READ_OPERATIONS, kitPaths, readDeclaration, readKitLock, resourceNames, sourceDigest } from "./kit.js";
5
- import { LocalRuntime, allocatePorts, ensureSdk, freePort, nodePackageCommand, readDevLock, runningOrigin } from "./local-runtime.js";
5
+ import { LocalRuntime, allocatePorts, freePort, installDependencies, nodePackageCommand, readDevLock, runningOrigin } from "./local-runtime.js";
6
6
  import { CLI_VERSION } from "./version.js";
7
7
  /**
8
8
  * What actually failed, in the refusal itself.
@@ -45,17 +45,15 @@ export async function runChecks(root, options) {
45
45
  const previous = await readReport(root);
46
46
  if (previous && previous.sourceDigest !== (await sourceDigest(root)).digest)
47
47
  output("Source changed since the last report; the previous report is no longer valid.");
48
+ // A fresh clone has no node_modules: install once, and say so, before the typecheck needs the SDK's types.
49
+ await installDependencies(root, bundle, run, output);
48
50
  const npx = nodePackageCommand("npx", ["tsc", "--noEmit"]);
49
51
  const typecheck = await run(npx.command, npx.args, { cwd: root, quiet: true });
50
52
  record({ name: "typecheck", status: typecheck.code === 0 ? "pass" : "fail", ...(typecheck.code === 0 ? {} : { detail: lastLines(typecheck.stdout || typecheck.stderr) }) });
51
53
  const npm = nodePackageCommand("npm", ["run", "build"]);
52
54
  const build = await run(npm.command, npm.args, { cwd: root, quiet: true });
53
55
  record({ name: "build", status: build.code === 0 ? "pass" : "fail", ...(build.code === 0 ? {} : { detail: lastLines(build.stderr || build.stdout) }) });
54
- // The gate runs the journeys with this node and the app's own SDK: the SDK
55
- // the bundle pins, installed now if node_modules holds an older copy.
56
- const sdk = await ensureSdk(root, bundle, options.env ?? process.env, run, output).catch(error => { output(error instanceof Error ? error.message : String(error)); return "missing"; });
57
- if (sdk === "missing")
58
- output(`The kit SDK is not installed: set ISOMORPH_KIT_SDK_TARBALL to the bundle's ${bundle.sdk.package} tarball (or use a bundle with sdk.url).`);
56
+ // The gate runs the journeys with this node and the SDK the app's own install put in node_modules.
59
57
  const gate = await runKitGate(root, { run, bundle, output, ...(options.fetch ? { fetch: options.fetch } : {}), ...(options.runtime ? { runtime: options.runtime } : {}), ...(options.pollMs ? { pollMs: options.pollMs } : {}) });
60
58
  for (const check of gate.checks)
61
59
  record(check);
@@ -94,7 +92,7 @@ export async function runChecks(root, options) {
94
92
  createdAt: new Date().toISOString(),
95
93
  sourceDigest: source.digest,
96
94
  sourceFiles: source.entries,
97
- toolchain: { node: process.version, cliVersion: CLI_VERSION, bundle: { kitVersion: bundle.kitVersion, sdkTarballSha256: bundle.sdk.tarballSha256, briefFingerprint: bundle.brief.fingerprint } },
95
+ toolchain: { node: process.version, cliVersion: CLI_VERSION, bundle: { kitVersion: bundle.kitVersion, sdkVersion: bundle.sdk.version, briefFingerprint: bundle.brief.fingerprint } },
98
96
  checks,
99
97
  gate,
100
98
  integrations,
@@ -11,7 +11,7 @@ import { initKit } from "./starter.js";
11
11
  import { packageApp } from "./package.js";
12
12
  import { agentPaths, agentSetup } from "./agent-setup.js";
13
13
  import { detachDev, startDev } from "./dev.js";
14
- import { endProcess, ensureSdk, LocalRuntime, readDevLock, releaseDevLock, runCommand } from "./local-runtime.js";
14
+ import { endProcess, installDependencies, LocalRuntime, readDevLock, releaseDevLock, runCommand } from "./local-runtime.js";
15
15
  import { CHECKS_FAILED_HINT, checksFailedMessage, runChecks } from "./check.js";
16
16
  import { assertDeployReady, companySystemsLine, GovernanceClient, groupGrants, IDENTITY_WORDS, integrationsCatalog, integrationsStatus, renderGrantGroup, renderIntegrationsCatalog, requestIntegrations } from "./integrations.js";
17
17
  import { runJob } from "./jobs.js";
@@ -87,7 +87,7 @@ const emit = (value, line) => { process.stdout.write(json ? `${JSON.stringify(va
87
87
  */
88
88
  const exitAfterWriting = (code, out, err = "") => { process.stderr.write(err, () => process.stdout.write(out, () => process.exit(code))); };
89
89
  /** Exit 2, like a usage error: nothing started and the fix is a command the maker runs (or a file the maker edits). */
90
- const USAGE_REFUSALS = ["APP_EXISTS", "APP_UNSUPPORTED", "KIT_APP", "DEPLOY_BLOCKED", "INTEGRATIONS_NOT_READY", "AI_NOT_READY", "CLI_UPGRADE_REQUIRED", "NOT_A_MEMBER", "TENANT_AMBIGUOUS", "NOT_A_START_LINK", "PLATFORM_UNREACHABLE", "DEPLOY_IN_FLIGHT", "OPERATION_REQUIRED"];
90
+ const USAGE_REFUSALS = ["APP_EXISTS", "APP_UNSUPPORTED", "KIT_APP", "DEPLOY_BLOCKED", "INTEGRATIONS_NOT_READY", "AI_NOT_READY", "CLI_UPGRADE_REQUIRED", "KIT_BUNDLE_STALE", "NOT_A_MEMBER", "TENANT_AMBIGUOUS", "NOT_A_START_LINK", "PLATFORM_UNREACHABLE", "DEPLOY_IN_FLIGHT", "OPERATION_REQUIRED"];
91
91
  if (command === "--version" || command === "version") {
92
92
  process.stdout.write(`${CLI_VERSION}\n`);
93
93
  // `-h` was matched anywhere in argv while `--help` was only recognised as the
@@ -138,10 +138,8 @@ else {
138
138
  const result = await initKit(target, bundle, { upgrade, adopt, tenantId: config?.tenantId, env: process.env, displayRoot: root });
139
139
  for (const line of [...result.created.map(path => `created ${path}`), ...result.updated.map(path => `updated ${path}`), ...result.kept.map(path => `kept ${path}`), ...result.bundleChanges.map(change => `bundle ${change}`), ...["created", "updated"].flatMap(state => result.agents[state].map(path => `${state} ${path} (agent guide)`))])
140
140
  progress(line);
141
- // The SDK is not on the public registry: init installs the pinned tarball (and the app's other dependencies) itself.
142
- const sdk = await ensureSdk(target, bundle, process.env, runCommand, progress);
143
- if (sdk === "missing")
144
- progress(`The kit SDK was not installed: set ISOMORPH_KIT_SDK_TARBALL to the bundle's ${bundle.sdk.package} tarball (or use a bundle with sdk.url), then rerun \`isomorph init\`.`);
141
+ // The SDK is a normal dependency: one plain `npm install` when node_modules lacks it or holds one outside the range init wrote.
142
+ await installDependencies(target, bundle, runCommand, progress);
145
143
  progress(result.mode === "starter" ? "Starter created. Next: `isomorph dev --app-root <path>`. Both agents read the Isomorph block in CLAUDE.md / AGENTS.md and the `isomorph` skill installed by agent-setup."
146
144
  : upgrade ? (result.bundleChanges.length ? "Kit bundle upgraded; running checks." : "Kit bundle already current; running checks.")
147
145
  : result.mode === "kit" ? `This folder is already an Isomorph kit app (any missing kit file was added; nothing else was changed). Next: \`isomorph dev --app-root ${root}\`, \`isomorph check --app-root ${root}\`, \`isomorph deploy --app-root ${root}\`.`
@@ -22,14 +22,15 @@ export function assertCliCurrent(config, running = CLI_VERSION) {
22
22
  return;
23
23
  throw new CliError("CLI_UPGRADE_REQUIRED", `This CLI is ${running}; the company's Isomorph platform needs ${minimum} or newer.`, undefined, `Run \`${CLI_INSTALL_COMMAND}\`, then run this again.`);
24
24
  }
25
+ /** The numeric core and prerelease of a version string (`v1.2.3-rc.1` → `[1, 2, 3]`, `rc.1`), or `undefined` for one with no numeric core. */
26
+ export function parseVersion(value) {
27
+ const match = /^\s*v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(value);
28
+ return match ? { core: [Number(match[1]), Number(match[2]), Number(match[3])], pre: match[4] } : undefined;
29
+ }
25
30
  /** Numeric-core semver order; a prerelease sorts before its release. An unparseable version compares equal, so nothing is called stale on a guess. */
26
31
  export function compareCliVersions(left, right) {
27
- const parse = (value) => {
28
- const match = /^\s*v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(value);
29
- return match ? { core: [Number(match[1]), Number(match[2]), Number(match[3])], pre: match[4] } : undefined;
30
- };
31
- const a = parse(left);
32
- const b = parse(right);
32
+ const a = parseVersion(left);
33
+ const b = parseVersion(right);
33
34
  if (!a || !b)
34
35
  return 0;
35
36
  for (let index = 0; index < 3; index += 1)
@@ -6,7 +6,7 @@ import { CliError } from "./output.js";
6
6
  import { continueCommand, follow, outcomeFor, pickSourceFailure, recordedRefusal, runningLine, statusFetch, summarize } from "./operations.js";
7
7
  import { CLI_VERSION } from "./version.js";
8
8
  import { assertSafeAppRoot, readAppTree } from "./package.js";
9
- import { deployBlocked, ensureLinkedApp } from "./integrations.js";
9
+ import { deployBlocked, ensureLinkedApp, kitLockBody } from "./integrations.js";
10
10
  import { describeSourceChange, kitPaths, readAppProfile, readDeclaration, readKitLock, sourceDigest } from "./kit.js";
11
11
  import { CHECKS_FAILED_HINT, checksFailedMessage, FLOW_CHECK, readReport, runChecks } from "./check.js";
12
12
  import { runCommand } from "./local-runtime.js";
@@ -50,7 +50,8 @@ async function runDeploy(rootArg, governance, output, tenantId, options) {
50
50
  assertSafeAppRoot(root);
51
51
  // Only a kit app deploys from here: the checks, the gate and the profile all
52
52
  // live in `.isomorph/`. Anything else deploys from the console.
53
- if (!(await readKitLock(root)))
53
+ const lock = await readKitLock(root);
54
+ if (!lock)
54
55
  throw new CliError("KIT_REQUIRED", "This folder is not an Isomorph app: it has no .isomorph/kit.lock.json.", undefined, "Run `isomorph init --app-root .` first.");
55
56
  // The three values the person confirmed, printed so the transcript shows what
56
57
  // was recorded; recorded on the operation before anything is uploaded.
@@ -91,15 +92,16 @@ async function runDeploy(rootArg, governance, output, tenantId, options) {
91
92
  output(`Isomorph is continuing operation ${note.operationRef}; no new deployment was started.`);
92
93
  // One call opens the operation — or resumes it — and runs the deploy
93
94
  // pre-flight first: a declared connection IT has not approved, a company
94
- // whose AI setup is not ready for an app that calls it, or a CLI the platform
95
- // no longer accepts would only park the deployment after the save, so
96
- // governance refuses here, with every blocker at once, before any operation
97
- // exists. The reviewed profile rides on the same call.
95
+ // whose AI setup is not ready for an app that calls it, a CLI the platform
96
+ // no longer accepts, or a kit older than the one Isomorph publishes would
97
+ // only park the deployment after the save, so governance refuses here, with
98
+ // every blocker at once, before any operation exists. The reviewed profile
99
+ // and the kit lock's bundle ride on the same call.
98
100
  const body = {
99
101
  environment: "preview",
100
102
  app: { name: profile.name, description: profile.description, audience: profile.audience },
101
103
  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 })) } },
102
- graph, cliVersion: CLI_VERSION, kitBundleVersion: bundle.kitVersion, declaration, callsAi: await appCallsAi(root)
104
+ graph, cliVersion: CLI_VERSION, kitBundleVersion: bundle.kitVersion, declaration, callsAi: await appCallsAi(root), kitLock: kitLockBody(lock.bundle)
103
105
  };
104
106
  let opened;
105
107
  try {
@@ -5,7 +5,7 @@ import { join } from "node:path";
5
5
  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
- import { acquireDevLock, allocatePorts, ensureSdk, LocalRuntime, nodePackageCommand, pidAlive, readDevLock, recordDevChildren, releaseDevLock, runCommand } from "./local-runtime.js";
8
+ import { acquireDevLock, allocatePorts, installDependencies, LocalRuntime, nodePackageCommand, pidAlive, readDevLock, recordDevChildren, releaseDevLock, runCommand } from "./local-runtime.js";
9
9
  import { kitPaths } from "./kit.js";
10
10
  import { CliError, safeError } from "./output.js";
11
11
  /**
@@ -53,9 +53,7 @@ export async function startDev(root, options) {
53
53
  await releaseDevLock(root);
54
54
  };
55
55
  try {
56
- const sdk = await ensureSdk(root, options.bundle, env, run, options.output);
57
- if (sdk === "missing")
58
- options.output(`The kit SDK is not installed: set ISOMORPH_KIT_SDK_TARBALL to the bundle's ${options.bundle.sdk.package} tarball (or use a bundle with sdk.url).`);
56
+ await installDependencies(root, options.bundle, run, options.output);
59
57
  await runtime.writeFiles(options.bundle, ports);
60
58
  options.output("Preparing the kit-managed native runtime (public registry, no login).");
61
59
  await runtime.pull(options.bundle);
@@ -2,6 +2,8 @@ import { basename } from "node:path";
2
2
  import { linkIdempotencyKey, readDeclaration, readKitLock, requestResourceName, requestResources, writeKitLock, newKitLock } from "./kit.js";
3
3
  import { CliError } from "./output.js";
4
4
  import { CLI_VERSION } from "./version.js";
5
+ /** The lock's bundle as the preflight body carries it. */
6
+ export const kitLockBody = (bundle) => ({ kitVersion: bundle.kitVersion, sdk: { version: bundle.sdk.version } });
5
7
  export class GovernanceClient {
6
8
  apiUrl;
7
9
  tenantId;
@@ -334,12 +336,14 @@ export function renderGrantGroup(group, nowMs = Date.now()) {
334
336
  * governance whether this app can deploy to the environment: it files the
335
337
  * declared access requests itself and answers with every blocker at once —
336
338
  * the lanes IT still has to approve, the company's AI setup when the app calls
337
- * governed AI, and a CLI older than the platform's minimum. Prints each
339
+ * governed AI, a CLI older than the platform's minimum, and a kit older than
340
+ * the one Isomorph publishes. Prints each
338
341
  * blocker's sentence and refuses once, before any operation exists.
339
342
  *
340
343
  * The code is `DEPLOY_BLOCKED` when the blockers are of more than one kind;
341
344
  * when they are all of one kind it is the code readers already know for that
342
- * kind (`INTEGRATIONS_NOT_READY`, `AI_NOT_READY`, `CLI_UPGRADE_REQUIRED`).
345
+ * kind (`INTEGRATIONS_NOT_READY`, `AI_NOT_READY`, `CLI_UPGRADE_REQUIRED`,
346
+ * `KIT_BUNDLE_STALE` — the code intake records for the same refusal).
343
347
  * Observed before this: a builder was refused `INTEGRATIONS_NOT_READY`, fixed
344
348
  * that, then `AI_NOT_READY`, then the pipeline refused `kit_bundle_incompatible`,
345
349
  * each a separate cycle.
@@ -347,7 +351,8 @@ export function renderGrantGroup(group, nowMs = Date.now()) {
347
351
  export async function assertDeployReady(root, client, tenantId, bundle, environment, output, callsAi) {
348
352
  const declaration = await readDeclaration(root);
349
353
  const appId = await ensureLinkedApp(root, client, tenantId, bundle);
350
- const preflight = await client.deployPreflight(appId, { environment, callsAi, declaration, cliVersion: CLI_VERSION });
354
+ const lock = await readKitLock(root);
355
+ const preflight = await client.deployPreflight(appId, { environment, callsAi, declaration, cliVersion: CLI_VERSION, ...(lock ? { kitLock: kitLockBody(lock.bundle) } : {}) });
351
356
  if (preflight.ready && !blockersOf(preflight.blockers).length) {
352
357
  output(`Isomorph confirmed the app can deploy to ${environment}.`);
353
358
  return appId;
@@ -375,7 +380,7 @@ export function deployBlocked(value, environment, output) {
375
380
  return new CliError(code, blockers.map(blocker => blocker.sentence).join(" ") || `Isomorph cannot deploy this app to ${environment} yet.`, undefined, fixes || undefined, undefined, { layer: "governance" });
376
381
  }
377
382
  /** The code readers already know for a refusal that is all of one kind. */
378
- const BLOCKER_CODES = { integration: "INTEGRATIONS_NOT_READY", ai: "AI_NOT_READY", cli: "CLI_UPGRADE_REQUIRED" };
383
+ const BLOCKER_CODES = { integration: "INTEGRATIONS_NOT_READY", ai: "AI_NOT_READY", cli: "CLI_UPGRADE_REQUIRED", kit: "KIT_BUNDLE_STALE" };
379
384
  /**
380
385
  * Splits the operations the app calls on one connection by identity mode — the
381
386
  * derived declaration's own `identity` (the gate derived it from the call's
@@ -1,4 +1,5 @@
1
1
  import { PUBLISHED_KIT_BUNDLE } from "./kit-bundle.manifest.js";
2
+ import { parseVersion } from "./config.js";
2
3
  /**
3
4
  * The local stack's supporting services. Not part of the published bundle: they run only under `isomorph dev`.
4
5
  * MinIO is the exact image the pipeline's validation cell runs (data plane `transformbuild.validationMinIOImage`),
@@ -14,24 +15,35 @@ export const LOCAL_SERVICE_IMAGES = {
14
15
  * The bundle this CLI was built with: `manifest.json` of the
15
16
  * harbour-deployment-data-plane `publish-kit-bundle` artifact for the kit
16
17
  * version pinned in package.json `harbour.kitBundle`, fetched at build time
17
- * into `kit-bundle.manifest.ts` (the images and the tarball live in the kit's
18
- * public registry, `public.ecr.aws/<alias>/`, pinned by digest so no login
19
- * and no tag can substitute other bytes). `isomorph init` records it in the
20
- * app's kit.lock; `isomorph init --upgrade` shows the digest diff against it;
21
- * the hosted pipeline compares `sdk.tarballSha256` with the SDK it bakes.
18
+ * into `kit-bundle.manifest.ts` (the native runtime lives in the kit's public
19
+ * registry, `public.ecr.aws/<alias>/`, pinned by digest so no login and no
20
+ * tag can substitute other bytes; the SDK is `@isomorph.ai/app-sdk` on npm at
21
+ * `sdk.version`). `isomorph init` records it in the app's kit.lock and writes
22
+ * the SDK dependency; `isomorph init --upgrade` shows what moved and bumps the
23
+ * dependency range; the hosted pipeline refuses a kit.lock whose
24
+ * `sdk.version` is older than the SDK surface its gate drives.
22
25
  */
23
- export const EMBEDDED_KIT_BUNDLE = { ...PUBLISHED_KIT_BUNDLE, images: { ...LOCAL_SERVICE_IMAGES, ...PUBLISHED_KIT_BUNDLE.images } };
26
+ export const EMBEDDED_KIT_BUNDLE = { ...PUBLISHED_KIT_BUNDLE, sdk: { package: PUBLISHED_KIT_BUNDLE.sdk.package, version: publishedSdkVersion() }, images: { ...LOCAL_SERVICE_IMAGES, ...PUBLISHED_KIT_BUNDLE.images } };
27
+ /** The contracts type still allows a manifest without `sdk.version`; the generator refuses one, so a build that reaches here without it is a stale sync. */
28
+ function publishedSdkVersion() {
29
+ const version = PUBLISHED_KIT_BUNDLE.sdk.version;
30
+ if (!version || !parseVersion(version))
31
+ throw new Error("kit-bundle.manifest.ts names no sdk.version; run `npm run kit-bundle:sync` in packages/harbour-cli");
32
+ return version;
33
+ }
34
+ /** `"^<sdk.version>"`: the dependency range `init` writes and `init --upgrade` moves. */
35
+ export const sdkDependencyRange = (bundle) => `^${bundle.sdk.version}`;
24
36
  export function isKitBundle(value) {
25
37
  const record = value;
26
38
  return Boolean(record && typeof record === "object" && record.schema === "isomorph.kit-bundle/1.0" && typeof record.kitVersion === "string"
27
- && record.sdk && typeof record.sdk.package === "string" && typeof record.sdk.tarballSha256 === "string"
39
+ && record.sdk && typeof record.sdk.package === "string" && typeof record.sdk.version === "string" && parseVersion(record.sdk.version) !== undefined
28
40
  && record.brief && typeof record.brief.fingerprint === "string" && record.declarationSchema === "isomorph.app-integrations/2.0");
29
41
  }
30
- /** Lines naming every digest that differs between two manifests, for `init --upgrade`. */
42
+ /** Lines naming every version and digest that differs between two manifests, for `init --upgrade`. */
31
43
  export function bundleDiff(before, after) {
32
44
  const fields = [
33
45
  ["kitVersion", before.kitVersion, after.kitVersion],
34
- ["sdk.tarballSha256", before.sdk.tarballSha256, after.sdk.tarballSha256],
46
+ ["sdk.version", before.sdk.version, after.sdk.version],
35
47
  ["brief.fingerprint", before.brief.fingerprint, after.brief.fingerprint]
36
48
  ];
37
49
  return fields.filter(([, a, b]) => a !== b).map(([name, a, b]) => `${name}: ${a ?? "(none)"} -> ${b ?? "(none)"}`);
@@ -1,28 +1,28 @@
1
1
  export const PUBLISHED_KIT_BUNDLE = {
2
2
  "schema": "isomorph.kit-bundle/1.0",
3
- "kitVersion": "0.6.0",
3
+ "kitVersion": "0.7.0",
4
4
  "sdk": {
5
5
  "package": "@isomorph.ai/app-sdk",
6
- "version": "1.2.1",
7
- "tarballSha256": "607e3896aa4116d82d0473677d38a68fecd4855a250cd44e038780290c335c67",
8
- "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:607e3896aa4116d82d0473677d38a68fecd4855a250cd44e038780290c335c67"
6
+ "version": "1.2.2",
7
+ "tarballSha256": "8953bb7eb6846bcddf48fd5407c098a13c8ad2cf249d702c35b5666d5af0ad53",
8
+ "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:8953bb7eb6846bcddf48fd5407c098a13c8ad2cf249d702c35b5666d5af0ad53"
9
9
  },
10
10
  "images": {
11
- "appGateway": "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:7f7ae24c24d81cf2951ce8ae9033e252373c17f3a31299e05714103068273232",
12
- "sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:14803bd23b64c0ba5aa68022716c339a772ce0664c739dabda3457ec27978bb2"
11
+ "appGateway": "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:3669ac07c51d5160463ba9985dbdca035b6944ae4ebbaea57ea02eeed83fb8ee",
12
+ "sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:67650669576642104b03afd97bd1463d507bc9e1e83fb7314693ce7d3bdfe373"
13
13
  },
14
14
  "nativeRuntime": {
15
15
  "darwinArm64": {
16
- "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:42705c1b74d45f08b4f926e98b7ae15bcc8688d26d1424a74cf2d03472c9b332",
17
- "sha256": "42705c1b74d45f08b4f926e98b7ae15bcc8688d26d1424a74cf2d03472c9b332"
16
+ "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:aa7c6b1da2a51c6c3ff98582ed3f34959a8081c6af005961f0d4737da83402cf",
17
+ "sha256": "aa7c6b1da2a51c6c3ff98582ed3f34959a8081c6af005961f0d4737da83402cf"
18
18
  },
19
19
  "linuxX64": {
20
- "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:853563fb277f49eb8862ee06227d5faba8c82cb8de304357083c19d9f616fa81",
21
- "sha256": "853563fb277f49eb8862ee06227d5faba8c82cb8de304357083c19d9f616fa81"
20
+ "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:2c20bb1f3ad39917e6514aef77f1588efb433996079d3e844ef2a2cb28fabde4",
21
+ "sha256": "2c20bb1f3ad39917e6514aef77f1588efb433996079d3e844ef2a2cb28fabde4"
22
22
  },
23
23
  "windowsX64": {
24
- "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:8709b4922c8c5e3ba19662999df4beb97d01fe8a8a85b32d6025088234714a50",
25
- "sha256": "8709b4922c8c5e3ba19662999df4beb97d01fe8a8a85b32d6025088234714a50"
24
+ "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:892199c64f47d1ade017e2f5c78750f0cad01516fd0bd191490a6b79c1af9ff8",
25
+ "sha256": "892199c64f47d1ade017e2f5c78750f0cad01516fd0bd191490a6b79c1af9ff8"
26
26
  }
27
27
  },
28
28
  "brief": {
@@ -2,8 +2,10 @@ import { spawn } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { copyFile, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
4
4
  import { createServer } from "node:net";
5
- import { dirname, join, relative, win32 } from "node:path";
5
+ import { dirname, join, win32 } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
+ import { sdkDependencyRange } from "./kit-bundle.js";
8
+ import { parseVersion } from "./config.js";
7
9
  import { kitPaths, projectName } from "./kit.js";
8
10
  import { CliError } from "./output.js";
9
11
  export function nodePackageCommand(tool, args, platform = process.platform, execPath = process.execPath) {
@@ -467,7 +469,7 @@ async function ensureNativeGateway(root, bundle) {
467
469
  const directory = join(kitPaths(root).local, "runtime");
468
470
  const target = join(directory, `isomorph-app-gateway-${runtime.sha256.slice(0, 12)}${process.platform === "win32" ? ".exe" : ""}`);
469
471
  if (!(await exists(target))) {
470
- const bytes = await downloadSdk(runtime.url);
472
+ const bytes = await downloadBundleBlob(runtime.url);
471
473
  const digest = createHash("sha256").update(bytes).digest("hex");
472
474
  if (digest !== runtime.sha256)
473
475
  throw new CliError("KIT_BUNDLE_INCOMPATIBLE", `The native runtime digest ${digest} does not match the kit bundle ${runtime.sha256}.`);
@@ -497,59 +499,61 @@ export function parseSessionEnv(text) {
497
499
  }
498
500
  return env;
499
501
  }
500
- // ---- SDK tarball ---------------------------------------------------------------------------
502
+ // ---- Dependencies and kit bundle blobs ----------------------------------------------------
501
503
  /**
502
- * The SDK ships in the kit bundle, not the public registry. Stages the tarball
503
- * named by ISOMORPH_KIT_SDK_TARBALL or downloaded from the manifest's sdk.url under
504
- * `.isomorph/local/sdk/` (so package.json gets a relative `file:` dependency), checks
505
- * its sha256 against the bundle and installs it with the app's other dependencies.
506
- * Returns "present" when the staged tarball is already installed, "missing" when
507
- * no tarball is available and node_modules lacks the package.
504
+ * The app's dependencies, `@isomorph.ai/app-sdk` among them, come from the public
505
+ * npm registry like any package (data plane ADR 0017): one plain `npm install`,
506
+ * nothing staged or pinned by the kit. One rule for `init`, `dev` and `check`:
507
+ * install when node_modules lacks the SDK (a fresh init or clone) or holds one
508
+ * outside the bundle's range (`init --upgrade` moved it); otherwise nothing runs.
508
509
  */
509
- export async function ensureSdk(root, bundle, env, run, output, fetchImpl = fetch) {
510
- const installed = await readFile(join(root, "node_modules", ...bundle.sdk.package.split("/"), "package.json")).then(() => true, () => false);
511
- const stageDir = join(kitPaths(root).local, "sdk");
512
- const staged = join(stageDir, `isomorph-app-sdk-${bundle.sdk.tarballSha256.slice(0, 12)}.tgz`);
513
- const source = env.ISOMORPH_KIT_SDK_TARBALL?.trim();
514
- if (!(await exists(staged))) {
515
- let bytes;
516
- if (source)
517
- bytes = await readFile(source).catch(() => { throw new CliError("SDK_UNAVAILABLE", `ISOMORPH_KIT_SDK_TARBALL is not readable: ${source}`); });
518
- else if (bundle.sdk.url) {
519
- output(`Downloading ${bundle.sdk.package} from the kit bundle (sha256 ${bundle.sdk.tarballSha256.slice(0, 12)}…).`);
520
- bytes = await downloadSdk(bundle.sdk.url, fetchImpl);
521
- }
522
- if (!bytes)
523
- return installed ? "present" : "missing";
524
- const digest = createHash("sha256").update(bytes).digest("hex");
525
- if (bundle.sdk.tarballSha256 !== "unknown" && digest !== bundle.sdk.tarballSha256)
526
- throw new CliError("KIT_BUNDLE_INCOMPATIBLE", `The SDK tarball digest ${digest} does not match the kit bundle ${bundle.sdk.tarballSha256}. Run \`isomorph init --upgrade\` or use the matching tarball.`);
527
- await mkdir(stageDir, { recursive: true });
528
- await writeFile(staged, bytes);
529
- }
530
- else if (installed && (await readFile(join(root, "package.json"), "utf8").catch(() => "")).includes(relative(root, staged)))
510
+ export async function installDependencies(root, bundle, run, output) {
511
+ if (await sdkInstalled(root, bundle))
531
512
  return "present";
532
- output(`Installing ${bundle.sdk.package} from the kit bundle.`);
533
- const npm = nodePackageCommand("npm", ["install", "--no-audit", "--no-fund", relative(root, staged)]);
513
+ output(`Installing the app's dependencies, ${bundle.sdk.package} ${sdkDependencyRange(bundle)} among them (npm install).`);
514
+ const npm = nodePackageCommand("npm", ["install", "--no-audit", "--no-fund"]);
534
515
  const result = await run(npm.command, npm.args, { cwd: root, quiet: true });
535
516
  if (result.code !== 0)
536
- throw new CliError("SDK_INSTALL_FAILED", `npm could not install the kit SDK tarball: ${result.stderr.trim().split("\n").at(-1) ?? "npm error"}`);
517
+ throw new CliError("NPM_INSTALL_FAILED", `npm install failed: ${result.stderr.trim().split("\n").at(-1) ?? "npm error"}`, undefined, "Fix the dependency problem npm names (package.json, the lockfile or the registry), then run this again.");
537
518
  return "installed";
538
519
  }
520
+ /** Whether node_modules holds the SDK at a version inside `^<bundle.sdk.version>`: same major (0.x: same minor) and not older. */
521
+ async function sdkInstalled(root, bundle) {
522
+ const manifest = await readFile(join(root, "node_modules", ...bundle.sdk.package.split("/"), "package.json"), "utf8").catch(() => undefined);
523
+ if (manifest === undefined)
524
+ return false;
525
+ let installed;
526
+ try {
527
+ installed = parseVersion(String(JSON.parse(manifest).version ?? ""))?.core;
528
+ }
529
+ catch {
530
+ return false;
531
+ }
532
+ const wanted = parseVersion(bundle.sdk.version)?.core;
533
+ if (!installed || !wanted)
534
+ return false;
535
+ if (installed[0] !== wanted[0] || (wanted[0] === 0 && installed[1] !== wanted[1]))
536
+ return false;
537
+ for (let index = 0; index < 3; index += 1)
538
+ if (installed[index] !== wanted[index])
539
+ return installed[index] > wanted[index];
540
+ return true;
541
+ }
539
542
  /**
540
- * Fetches the SDK tarball from the bundle's `sdk.url`. The published bundle
541
- * stores it as a content-addressed OCI blob on a public registry, which answers
542
- * an anonymous GET with a Bearer challenge naming a token endpoint that hands
543
- * out pull tokens without credentials; the challenge is followed once and the
544
- * request repeated. Redirects (a blob served from object storage) are followed;
545
- * the bearer token is not forwarded across origins by fetch itself. The caller
546
- * verifies the bytes against `sdk.tarballSha256` — a token or a redirect can
547
- * change where the bytes come from, never which bytes are accepted.
543
+ * Fetches one of the kit bundle's blobs — the native runtime binary for this
544
+ * platform. The published bundle stores it as a content-addressed OCI blob on a
545
+ * public registry, which answers an anonymous GET with a Bearer challenge naming
546
+ * a token endpoint that hands out pull tokens without credentials; the challenge
547
+ * is followed once and the request repeated. Redirects (a blob served from
548
+ * object storage) are followed; the bearer token is not forwarded across origins
549
+ * by fetch itself. The caller verifies the bytes against the manifest's sha256 —
550
+ * a token or a redirect can change where the bytes come from, never which bytes
551
+ * are accepted.
548
552
  */
549
- export async function downloadSdk(url, fetchImpl = fetch) {
553
+ export async function downloadBundleBlob(url, fetchImpl = fetch) {
550
554
  if (!/^https:\/\//.test(url))
551
- throw new CliError("SDK_UNAVAILABLE", `The kit bundle's sdk.url must be https: ${url}`);
552
- const attempt = (headers = {}) => fetchImpl(url, { redirect: "follow", headers }).catch(error => { throw new CliError("SDK_UNAVAILABLE", `The kit SDK tarball could not be downloaded from ${url}: ${error instanceof Error ? error.message : String(error)}`); });
555
+ throw new CliError("KIT_RUNTIME_UNAVAILABLE", `The kit bundle's blob URL must be https: ${url}`);
556
+ const attempt = (headers = {}) => fetchImpl(url, { redirect: "follow", headers }).catch(error => { throw new CliError("KIT_RUNTIME_UNAVAILABLE", `The kit runtime could not be downloaded from ${url}: ${error instanceof Error ? error.message : String(error)}`); });
553
557
  let response = await attempt();
554
558
  if (response.status === 401) {
555
559
  const challenge = parseBearerChallenge(response.headers.get("www-authenticate"));
@@ -567,7 +571,7 @@ export async function downloadSdk(url, fetchImpl = fetch) {
567
571
  }
568
572
  }
569
573
  if (!response.ok)
570
- throw new CliError("SDK_UNAVAILABLE", `The kit SDK tarball could not be downloaded from ${url} (HTTP ${response.status}).`);
574
+ throw new CliError("KIT_RUNTIME_UNAVAILABLE", `The kit runtime could not be downloaded from ${url} (HTTP ${response.status}).`);
571
575
  return new Uint8Array(await response.arrayBuffer());
572
576
  }
573
577
  /** `Bearer realm="…",service="…",scope="…"` (RFC 6750 / distribution token auth); anything else is no challenge. */
@@ -1,6 +1,6 @@
1
1
  import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
- import { bundleDiff } from "./kit-bundle.js";
3
+ import { bundleDiff, sdkDependencyRange } from "./kit-bundle.js";
4
4
  import { agentSetup, MANAGED_END, MANAGED_START, upsertManagedBlock } from "./agent-setup.js";
5
5
  import { defaultAppProfile, emptyDeclaration, newKitLock, readKitLock, renderAppProfile, writeKitLock } from "./kit.js";
6
6
  import { CliError } from "./output.js";
@@ -80,8 +80,26 @@ export async function initKit(root, bundle, options = {}) {
80
80
  }
81
81
  else
82
82
  result.kept.push(".isomorph/kit.lock.json");
83
+ // The SDK is a normal dependency of the app's own package.json: added when an
84
+ // existing app lacks it (adopt), moved to the bundle's range on --upgrade,
85
+ // otherwise left as the app has it. The CLI runs `npm install` afterwards.
86
+ if (existingPackage && await ensureSdkDependency(root, existingPackage, bundle, Boolean(options.upgrade)))
87
+ result.updated.push("package.json");
83
88
  return result;
84
89
  }
90
+ /** Writes `"<sdk package>": "^<sdk.version>"` into an existing package.json when it is missing, or when `move` is set and it differs; reports whether the file changed. */
91
+ async function ensureSdkDependency(root, pkg, bundle, move) {
92
+ const { package: name } = bundle.sdk;
93
+ const range = sdkDependencyRange(bundle);
94
+ const current = pkg.dependencies?.[name] ?? pkg.devDependencies?.[name];
95
+ if (current === range || (current !== undefined && !move))
96
+ return false;
97
+ if (pkg.devDependencies)
98
+ delete pkg.devDependencies[name];
99
+ pkg.dependencies = { ...pkg.dependencies, [name]: range };
100
+ await writeFile(join(root, "package.json"), `${JSON.stringify(pkg, null, 2)}\n`);
101
+ return true;
102
+ }
85
103
  function isSupportedApp(pkg) {
86
104
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
87
105
  return Boolean(deps.vite && deps.react);
@@ -162,10 +180,9 @@ function starterFiles(bundle) {
162
180
  "package.json": `${JSON.stringify({
163
181
  name: "isomorph-app", private: true, version: "0.1.0", type: "module",
164
182
  scripts: { dev: "vite", build: "tsc --noEmit && vite build", preview: "vite preview" },
165
- // The SDK is distributed as a tarball in the kit bundle, not from the public registry:
166
- // `isomorph dev` installs it from ISOMORPH_KIT_SDK_TARBALL or the manifest's sdk.url and
167
- // verifies its sha256 against .isomorph/kit.lock.json before use.
168
- dependencies: { [bundle.sdk.package]: bundle.sdk.version ?? "1.0.0", react: "^18.3.1", "react-dom": "^18.3.1" },
183
+ // The SDK is a public npm package like the rest: `init` runs `npm install` and
184
+ // `.isomorph/kit.lock.json` records the bundle's SDK version for the pipeline's floor.
185
+ dependencies: { [bundle.sdk.package]: sdkDependencyRange(bundle), react: "^18.3.1", "react-dom": "^18.3.1" },
169
186
  devDependencies: { "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", typescript: "^5.6.3", vite: "^5.4.11" }
170
187
  }, null, 2)}\n`,
171
188
  "tsconfig.json": `${JSON.stringify({ compilerOptions: { target: "ES2022", lib: ["ES2022", "DOM", "DOM.Iterable"], module: "ESNext", moduleResolution: "Bundler", jsx: "react-jsx", strict: true, noEmit: true, skipLibCheck: true, isolatedModules: true }, include: ["src"] }, null, 2)}\n`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isomorph.ai/cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Isomorph development kit CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,7 +37,7 @@
37
37
  "harbour": {
38
38
  "kitBundle": {
39
39
  "repository": "public.ecr.aws/y6t4p3i8/harbour-kit-bundle",
40
- "version": "0.6.0"
40
+ "version": "0.7.0"
41
41
  }
42
42
  }
43
43
  }