@isomorph.ai/cli 0.5.2 → 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.
- package/dist/packages/harbour-cli/src/agent-setup.js +1 -1
- package/dist/packages/harbour-cli/src/check.js +8 -13
- package/dist/packages/harbour-cli/src/cli.js +4 -6
- package/dist/packages/harbour-cli/src/config.js +7 -6
- package/dist/packages/harbour-cli/src/deploy.js +10 -10
- package/dist/packages/harbour-cli/src/dev.js +2 -4
- package/dist/packages/harbour-cli/src/guide.js +5 -16
- package/dist/packages/harbour-cli/src/integrations.js +23 -27
- package/dist/packages/harbour-cli/src/kit-bundle.js +21 -9
- package/dist/packages/harbour-cli/src/kit-bundle.manifest.js +12 -12
- package/dist/packages/harbour-cli/src/kit.js +13 -24
- package/dist/packages/harbour-cli/src/local-runtime.js +50 -46
- package/dist/packages/harbour-cli/src/starter.js +28 -14
- package/package.json +2 -2
|
@@ -118,7 +118,7 @@ Rules live beside this file: \`core.md\` before the first edit; \`integrations.m
|
|
|
118
118
|
| "run it", "show me" | \`isomorph dev --app-root . --detach --json\`; give them \`result.origin\` (the first start takes a minute or two). Locally they are a fixture user. |
|
|
119
119
|
| "check it", "is it ready?" | \`isomorph check --app-root . --json\` (the output is the report). Failures in the app's code are yours: fix and re-check until clean, after every change and before every ship, unasked and never offered as a choice. |
|
|
120
120
|
| "does it work?" (and before reporting anything as working) | open the dev link in your own browser, press the control you built or changed, read what the app shows; a green \`isomorph check\` is not that proof (fixtures answer AI and company systems). A Send in development is real: reuse explicit authorization or ask once. No browser: say the button is untested. |
|
|
121
|
-
| "I need Slack / Gmail / the warehouse" | read \`integrations.md\` beside this skill first; then, in the same turn: catalog,
|
|
121
|
+
| "I need Slack / Gmail / the warehouse" | read \`integrations.md\` beside this skill first; then, in the same turn: catalog, call only what it lists (literals), submit the request yourself, say READY or PENDING in one line. |
|
|
122
122
|
| "summarise", "draft", "AI" | read \`ai.md\` beside this skill first; then one call behind a control they press. |
|
|
123
123
|
| "ship it", "let my team try it" | show the person the three values in \`.isomorph/app.json\` (name, description, audience — "only you" when empty) and correct the file as they say; then \`isomorph deploy --app-root . --json\` (it runs the checks when needed and refuses once naming every blocker and its fix: relay that line; the app works meanwhile). Give them \`result.deployment.protectedUrl\`. \`--session-notes\`: what cost you time in Isomorph, or \`""\`. |
|
|
124
124
|
| "make it live" | only after they have tried the preview: \`isomorph promote --operation <ref> --app-root . --confirm-tested --json\`. Report the production link, or that operator approval is pending. |
|
|
@@ -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,
|
|
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
|
|
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);
|
|
@@ -69,14 +67,11 @@ export async function runChecks(root, options) {
|
|
|
69
67
|
// failure ended the run as `fetch failed` (CLI_FAILED) with no report written.
|
|
70
68
|
const skipped = (why) => record({ name: "integrations", status: "not_run", detail: why });
|
|
71
69
|
const lock = await readKitLock(root).catch(() => undefined);
|
|
72
|
-
const { declaration, errors } = await readDeclaration(root);
|
|
73
70
|
if (!lock?.appId)
|
|
74
71
|
skipped("the app is not linked yet; `isomorph dev` (signed in) or `isomorph integrations request` links it");
|
|
75
|
-
else if (errors.length)
|
|
76
|
-
skipped(`.isomorph/integrations.json is invalid: ${errors[0]}`);
|
|
77
72
|
else {
|
|
78
73
|
try {
|
|
79
|
-
integrations = await testIntegrationReads(lock.appId, options.governance,
|
|
74
|
+
integrations = await testIntegrationReads(lock.appId, options.governance, await readDeclaration(root), output);
|
|
80
75
|
if (!integrations.length)
|
|
81
76
|
output("No READY development grants with read operations; real integrations were not exercised.");
|
|
82
77
|
}
|
|
@@ -97,7 +92,7 @@ export async function runChecks(root, options) {
|
|
|
97
92
|
createdAt: new Date().toISOString(),
|
|
98
93
|
sourceDigest: source.digest,
|
|
99
94
|
sourceFiles: source.entries,
|
|
100
|
-
toolchain: { node: process.version, cliVersion: CLI_VERSION, bundle: { kitVersion: bundle.kitVersion,
|
|
95
|
+
toolchain: { node: process.version, cliVersion: CLI_VERSION, bundle: { kitVersion: bundle.kitVersion, sdkVersion: bundle.sdk.version, briefFingerprint: bundle.brief.fingerprint } },
|
|
101
96
|
checks,
|
|
102
97
|
gate,
|
|
103
98
|
integrations,
|
|
@@ -252,8 +247,8 @@ function indentLines(detail) { return detail.split("\n").map((line, index) => in
|
|
|
252
247
|
*/
|
|
253
248
|
export async function writeGateFiles(root, files, output) {
|
|
254
249
|
for (const [path, content] of Object.entries(files)) {
|
|
255
|
-
if (!/^\.isomorph\/checks\/[A-Za-z0-9._-]
|
|
256
|
-
throw new CliError("LOCAL_RUNTIME_FAILED", `The kit gate named a file
|
|
250
|
+
if (!/^\.isomorph\/(checks\/[A-Za-z0-9._-]+|integrations\.json)$/.test(path))
|
|
251
|
+
throw new CliError("LOCAL_RUNTIME_FAILED", `The kit gate named a file it does not own: ${path}.`);
|
|
257
252
|
const target = join(root, path);
|
|
258
253
|
if (content === null) {
|
|
259
254
|
await rm(target, { force: true });
|
|
@@ -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,
|
|
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
|
|
142
|
-
|
|
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
|
|
28
|
-
|
|
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
|
-
|
|
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.
|
|
@@ -63,9 +64,7 @@ async function runDeploy(rootArg, governance, output, tenantId, options) {
|
|
|
63
64
|
// that is blank too; this only says so before the checks spend a minute.
|
|
64
65
|
if (!profile.name.trim() || !profile.description.trim())
|
|
65
66
|
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.");
|
|
66
|
-
const
|
|
67
|
-
if (errors.length && !errors[0].startsWith(".isomorph/integrations.json is missing"))
|
|
68
|
-
throw new CliError("DECLARATION_INVALID", `.isomorph/integrations.json is invalid: ${errors[0]}`);
|
|
67
|
+
const declaration = await readDeclaration(root);
|
|
69
68
|
// Link first: the app's identity lands in `.isomorph/kit.lock.json`, which is
|
|
70
69
|
// one of the deployable files, so a report pinned after the link stays fresh
|
|
71
70
|
// through the checks and the package is built from the linked tree.
|
|
@@ -93,15 +92,16 @@ async function runDeploy(rootArg, governance, output, tenantId, options) {
|
|
|
93
92
|
output(`Isomorph is continuing operation ${note.operationRef}; no new deployment was started.`);
|
|
94
93
|
// One call opens the operation — or resumes it — and runs the deploy
|
|
95
94
|
// pre-flight first: a declared connection IT has not approved, a company
|
|
96
|
-
// whose AI setup is not ready for an app that calls it,
|
|
97
|
-
// no longer accepts
|
|
98
|
-
//
|
|
99
|
-
// exists. The reviewed profile
|
|
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.
|
|
100
100
|
const body = {
|
|
101
101
|
environment: "preview",
|
|
102
102
|
app: { name: profile.name, description: profile.description, audience: profile.audience },
|
|
103
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 })) } },
|
|
104
|
-
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)
|
|
105
105
|
};
|
|
106
106
|
let opened;
|
|
107
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,
|
|
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
|
-
|
|
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);
|
|
@@ -53,13 +53,12 @@ Shared-read, owner-write: the same, plus \`CREATE POLICY posts_read ON posts FOR
|
|
|
53
53
|
- \`APP_NOT_FOUND\`: clear \`appId\` and \`tenantId\` in \`.isomorph/kit.lock.json\` to relink.`,
|
|
54
54
|
integrations: `# Company systems (Slack, Gmail, warehouse)
|
|
55
55
|
|
|
56
|
-
##
|
|
56
|
+
## Call shape
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
One call, this exact shape, never a wrapper function; \`connection\`, \`operation\`, \`resource\`, \`mode\` and \`input.columns\` are catalog literals (logical names, never IDs or variables). \`isomorph check\` derives \`.isomorph/integrations.json\` from them (committed, never edited); \`dev\`/\`deploy\` file the request.
|
|
59
59
|
|
|
60
|
-
\`\`\`
|
|
61
|
-
"
|
|
62
|
-
"sales-warehouse": { "kind": "database", "operations": { "warehouse.view.read": { "identity": "app", "resources": { "weekly_sales": { "columns": ["week", "total"] } } } } }
|
|
60
|
+
\`\`\`ts
|
|
61
|
+
await isomorph.integrations.execute("sales-warehouse", { operation: "warehouse.view.read", resource: "weekly_sales", input: { columns: ["week", "total"], limit: 100 } });
|
|
63
62
|
\`\`\`
|
|
64
63
|
|
|
65
64
|
## Catalog
|
|
@@ -70,16 +69,6 @@ Never guess a connection, channel, table, view, mailbox or column: a guess is re
|
|
|
70
69
|
|
|
71
70
|
\`isomorph integrations request <connection> --reason "<why>" --app-root . --json\` files the one request IT approves once for every environment (\`isomorph dev\` and \`isomorph deploy\` file it too); submit it yourself, in the same turn. \`isomorph integrations status --app-root . --json\`: READY, use it; PENDING, say "IT has to approve this; the app works meanwhile". \`RESOURCE_NOT_APPROVED\`: IT adds the channel, view or mailbox in the console (Controls & integrations); then run the same request again.
|
|
72
71
|
|
|
73
|
-
## Call shape
|
|
74
|
-
|
|
75
|
-
Always this exact shape, never a wrapper function: the kit gate reads the direct call.
|
|
76
|
-
|
|
77
|
-
\`\`\`ts
|
|
78
|
-
const report = await isomorph.integrations.execute("sales-warehouse", {
|
|
79
|
-
operation: "warehouse.view.read", resource: "weekly_sales", input: { columns: ["week", "total"], limit: 100 }
|
|
80
|
-
});
|
|
81
|
-
\`\`\`
|
|
82
|
-
|
|
83
72
|
## Consent
|
|
84
73
|
|
|
85
74
|
Only user-identity operations need it, built in unasked: the control calls \`isomorph.integrations.connect(connection)\`, follows \`authorizationUrl\` on \`consent_required\`, and returns to a connected state. Never on page load; app-identity operations never call connect.
|
|
@@ -90,7 +79,7 @@ A send (\`slack.message.post\`, \`gmail.message.send\`) runs only from an explic
|
|
|
90
79
|
|
|
91
80
|
## Slack modes
|
|
92
81
|
|
|
93
|
-
\`"
|
|
82
|
+
\`execute("company-slack", { …, mode: "app" })\` posts as the bot under \`slack.displayName\`/\`iconEmoji\` in \`.isomorph/app.json\`; \`mode: "user"\` posts as the person after consent (\`USER_RECONNECT_REQUIRED\`: offer Connect again). One mode per connection (\`MODE_REQUIRED\`); an unapproved mode is \`MODE_NOT_GRANTED\`, never swapped. \`RESOURCE_NOT_APPROVED\` on a post: the bot is not in the channel; \`/invite @Isomorph AI\`.`,
|
|
94
83
|
ai: `# Governed AI
|
|
95
84
|
|
|
96
85
|
AI goes through \`isomorph.ai\` only: one \`isomorph.ai.chat({ messages, maxTokens })\` call on the app's one client (\`src/isomorph.client.ts\`), never through a wrapper function (the gate reads only the direct call), behind a control the person presses — never on load, in an effect or a timer. Never add an OpenAI/Anthropic/Gemini key, SDK or URL: the governed AI gateway holds the key and IT sees every call. Send \`messages\` and \`maxTokens\` and nothing else; a refusal with \`unsupported_request_capability\` names a field the company's AI route does not accept — remove that field. A refusal with \`AI_NOT_ENABLED\` means IT has not enabled an AI provider yet: say so in one line and keep the app working without AI.
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
|
-
import { linkIdempotencyKey,
|
|
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;
|
|
@@ -164,12 +166,7 @@ function settled(requests) {
|
|
|
164
166
|
* so governance is not called.
|
|
165
167
|
*/
|
|
166
168
|
export async function fileDeclaredRequests(root, client, tenantId, bundle) {
|
|
167
|
-
const
|
|
168
|
-
if (errors.length) {
|
|
169
|
-
if (errors[0].startsWith(".isomorph/integrations.json is missing"))
|
|
170
|
-
return undefined;
|
|
171
|
-
throw new CliError("DECLARATION_INVALID", `.isomorph/integrations.json is invalid: ${errors[0]}`);
|
|
172
|
-
}
|
|
169
|
+
const declaration = await readDeclaration(root);
|
|
173
170
|
const connections = Object.keys(declaration.connections);
|
|
174
171
|
if (!connections.length)
|
|
175
172
|
return undefined;
|
|
@@ -183,9 +180,7 @@ export async function fileDeclaredRequests(root, client, tenantId, bundle) {
|
|
|
183
180
|
* for up to 30 s; PENDING is reported as pending, never as ready.
|
|
184
181
|
*/
|
|
185
182
|
export async function requestIntegrations(root, client, tenantId, bundle, options) {
|
|
186
|
-
const
|
|
187
|
-
if (errors.length)
|
|
188
|
-
throw new CliError("DECLARATION_INVALID", `.isomorph/integrations.json is invalid: ${errors[0]}`);
|
|
183
|
+
const declaration = await readDeclaration(root);
|
|
189
184
|
const appId = await ensureLinkedApp(root, client, tenantId, bundle);
|
|
190
185
|
const submitted = settled(await submitRequests(client, appId, declaration, root, options.connection, options.reason, options.operations)).map(item => ({ ...item, state: item.outcome.state }));
|
|
191
186
|
const sleep = options.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
|
|
@@ -341,22 +336,23 @@ export function renderGrantGroup(group, nowMs = Date.now()) {
|
|
|
341
336
|
* governance whether this app can deploy to the environment: it files the
|
|
342
337
|
* declared access requests itself and answers with every blocker at once —
|
|
343
338
|
* the lanes IT still has to approve, the company's AI setup when the app calls
|
|
344
|
-
* governed AI,
|
|
339
|
+
* governed AI, a CLI older than the platform's minimum, and a kit older than
|
|
340
|
+
* the one Isomorph publishes. Prints each
|
|
345
341
|
* blocker's sentence and refuses once, before any operation exists.
|
|
346
342
|
*
|
|
347
343
|
* The code is `DEPLOY_BLOCKED` when the blockers are of more than one kind;
|
|
348
344
|
* when they are all of one kind it is the code readers already know for that
|
|
349
|
-
* 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).
|
|
350
347
|
* Observed before this: a builder was refused `INTEGRATIONS_NOT_READY`, fixed
|
|
351
348
|
* that, then `AI_NOT_READY`, then the pipeline refused `kit_bundle_incompatible`,
|
|
352
349
|
* each a separate cycle.
|
|
353
350
|
*/
|
|
354
351
|
export async function assertDeployReady(root, client, tenantId, bundle, environment, output, callsAi) {
|
|
355
|
-
const
|
|
356
|
-
if (errors.length && !errors[0].startsWith(".isomorph/integrations.json is missing"))
|
|
357
|
-
throw new CliError("DECLARATION_INVALID", `.isomorph/integrations.json is invalid: ${errors[0]}`);
|
|
352
|
+
const declaration = await readDeclaration(root);
|
|
358
353
|
const appId = await ensureLinkedApp(root, client, tenantId, bundle);
|
|
359
|
-
const
|
|
354
|
+
const lock = await readKitLock(root);
|
|
355
|
+
const preflight = await client.deployPreflight(appId, { environment, callsAi, declaration, cliVersion: CLI_VERSION, ...(lock ? { kitLock: kitLockBody(lock.bundle) } : {}) });
|
|
360
356
|
if (preflight.ready && !blockersOf(preflight.blockers).length) {
|
|
361
357
|
output(`Isomorph confirmed the app can deploy to ${environment}.`);
|
|
362
358
|
return appId;
|
|
@@ -384,27 +380,27 @@ export function deployBlocked(value, environment, output) {
|
|
|
384
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" });
|
|
385
381
|
}
|
|
386
382
|
/** The code readers already know for a refusal that is all of one kind. */
|
|
387
|
-
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" };
|
|
388
384
|
/**
|
|
389
|
-
* Splits the
|
|
390
|
-
* declaration's own `identity` (the gate
|
|
391
|
-
*
|
|
392
|
-
* that carries `slack.message.post` also carries the
|
|
393
|
-
* presentation, so IT sees "posts as"
|
|
385
|
+
* Splits the operations the app calls on one connection by identity mode — the
|
|
386
|
+
* derived declaration's own `identity` (the gate derived it from the call's
|
|
387
|
+
* `mode` or the operation's only identity and checked it against the closed
|
|
388
|
+
* set). The app-mode part that carries `slack.message.post` also carries the
|
|
389
|
+
* connection's presentation (from `.isomorph/app.json`), so IT sees "posts as"
|
|
390
|
+
* on the request. A connection or operation the app does not call cannot be
|
|
391
|
+
* asked for: the declaration is the source, so the fix is the call.
|
|
394
392
|
*/
|
|
395
393
|
export function requestScope(declaration, connection, only) {
|
|
396
394
|
const declared = declaration.connections[connection];
|
|
397
395
|
if (!declared)
|
|
398
|
-
throw new CliError("
|
|
396
|
+
throw new CliError("CONNECTION_NOT_CALLED", `The app does not call ${connection}; add the isomorph.integrations.execute call first, then run \`isomorph check\`.`);
|
|
399
397
|
const names = (only?.length ? only : Object.keys(declared.operations));
|
|
400
398
|
const unknown = names.filter(name => !declared.operations[name]);
|
|
401
399
|
if (unknown.length)
|
|
402
|
-
throw new CliError("
|
|
400
|
+
throw new CliError("OPERATION_NOT_CALLED", `The app does not call ${unknown.join(", ")} on ${connection}; add the call first, then run \`isomorph check\`.`);
|
|
403
401
|
const byMode = new Map();
|
|
404
402
|
for (const name of names) {
|
|
405
|
-
const
|
|
406
|
-
const declaredMode = declared.operations[name].identity;
|
|
407
|
-
const mode = allowed.includes(declaredMode) ? declaredMode : allowed[0];
|
|
403
|
+
const mode = declared.operations[name].identity;
|
|
408
404
|
const entry = byMode.get(mode) ?? { operations: [], resources: new Map() };
|
|
409
405
|
entry.operations.push(name);
|
|
410
406
|
for (const resource of requestResources(declared.operations[name]))
|
|
@@ -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
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* the
|
|
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.
|
|
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.
|
|
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.
|
|
3
|
+
"kitVersion": "0.7.0",
|
|
4
4
|
"sdk": {
|
|
5
5
|
"package": "@isomorph.ai/app-sdk",
|
|
6
|
-
"version": "1.2.
|
|
7
|
-
"tarballSha256": "
|
|
8
|
-
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:
|
|
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:
|
|
12
|
-
"sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:
|
|
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:
|
|
17
|
-
"sha256": "
|
|
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:
|
|
21
|
-
"sha256": "
|
|
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:
|
|
25
|
-
"sha256": "
|
|
24
|
+
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:892199c64f47d1ade017e2f5c78750f0cad01516fd0bd191490a6b79c1af9ff8",
|
|
25
|
+
"sha256": "892199c64f47d1ade017e2f5c78750f0cad01516fd0bd191490a6b79c1af9ff8"
|
|
26
26
|
}
|
|
27
27
|
},
|
|
28
28
|
"brief": {
|
|
@@ -5,30 +5,18 @@ import { basename, dirname, join, parse, resolve } from "node:path";
|
|
|
5
5
|
import { scanWorkspace } from "../../../src/analyzer.js";
|
|
6
6
|
import { isKitBundle } from "./kit-bundle.js";
|
|
7
7
|
import { CliError } from "./output.js";
|
|
8
|
-
/**
|
|
9
|
-
* Closed set: each operation has one connection kind and the identity modes it
|
|
10
|
-
* may run under — exactly one, except slack.message.post, which the app
|
|
11
|
-
* declares (`app` posts as the company's Slack app under its IT-approved name,
|
|
12
|
-
* `user` posts as the signed-in person after their consent). Gmail is
|
|
13
|
-
* user-mode only (two reads and one send as the signed-in person).
|
|
14
|
-
*/
|
|
15
|
-
export const OPERATIONS = {
|
|
16
|
-
"slack.channel.history": { identities: ["user"], kind: "saas" },
|
|
17
|
-
"slack.message.post": { identities: ["app", "user"], kind: "saas" },
|
|
18
|
-
"gmail.thread.list": { identities: ["user"], kind: "saas" },
|
|
19
|
-
"gmail.message.read": { identities: ["user"], kind: "saas" },
|
|
20
|
-
"gmail.message.send": { identities: ["user"], kind: "saas" },
|
|
21
|
-
"warehouse.view.read": { identities: ["app"], kind: "database" }
|
|
22
|
-
};
|
|
23
8
|
/** Explicit reads `isomorph check --integrations` may run; every other operation is a send and never runs during checks. */
|
|
24
9
|
export const READ_OPERATIONS = ["slack.channel.history", "gmail.thread.list", "warehouse.view.read"];
|
|
25
10
|
/** Reads whose input needs an identifier from a prior read (a message id): listed as reads, exercised only through the list. */
|
|
26
11
|
export const DEPENDENT_READ_OPERATIONS = ["gmail.message.read"];
|
|
27
12
|
/**
|
|
28
|
-
* Reads the
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
13
|
+
* Reads the declaration `isomorph check` derived from the app's
|
|
14
|
+
* `isomorph.integrations.execute` calls and wrote to `.isomorph/integrations.json`
|
|
15
|
+
* (data plane ADR 0016). Nobody edits the file: its closed rules are the kit
|
|
16
|
+
* gate's `declaration` check, so a defect is named once, at the call site, by
|
|
17
|
+
* `isomorph check` and by CodeBuild alike. A missing file is the empty
|
|
18
|
+
* declaration (`init` writes one); a file that is not JSON is not the gate's
|
|
19
|
+
* and the fix is to run the check.
|
|
32
20
|
*/
|
|
33
21
|
export function emptyDeclaration() { return { schema: "isomorph.app-integrations/2.0", connections: {} }; }
|
|
34
22
|
export function resourceNames(operation) { return Array.isArray(operation.resources) ? operation.resources : Object.keys(operation.resources); }
|
|
@@ -42,20 +30,20 @@ export async function readDeclaration(root) {
|
|
|
42
30
|
raw = await readFile(kitPaths(root).declaration, "utf8");
|
|
43
31
|
}
|
|
44
32
|
catch {
|
|
45
|
-
return
|
|
33
|
+
return emptyDeclaration();
|
|
46
34
|
}
|
|
47
35
|
let parsed;
|
|
48
36
|
try {
|
|
49
37
|
parsed = JSON.parse(raw);
|
|
50
38
|
}
|
|
51
39
|
catch {
|
|
52
|
-
|
|
40
|
+
parsed = undefined;
|
|
53
41
|
}
|
|
54
42
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
55
|
-
|
|
43
|
+
throw new CliError("DECLARATION_UNREADABLE", ".isomorph/integrations.json is not the file `isomorph check` writes.", undefined, "Run `isomorph check --app-root .`: it derives the file from the app's isomorph.integrations.execute calls.");
|
|
56
44
|
const record = parsed;
|
|
57
45
|
const connections = record.connections && typeof record.connections === "object" && !Array.isArray(record.connections) ? record.connections : {};
|
|
58
|
-
return {
|
|
46
|
+
return { schema: record.schema, connections };
|
|
59
47
|
}
|
|
60
48
|
export function isKitLock(value) {
|
|
61
49
|
const record = value;
|
|
@@ -156,7 +144,8 @@ export async function readAppProfile(root) {
|
|
|
156
144
|
problems.push("audience must be a list of email addresses (an empty list means only you)");
|
|
157
145
|
if (problems.length)
|
|
158
146
|
throw new CliError("APP_PROFILE_INVALID", `.isomorph/app.json is invalid: ${problems.join("; ")}.`, undefined, "Fix the file, then run this again.");
|
|
159
|
-
|
|
147
|
+
const slack = record.slack && typeof record.slack === "object" && typeof record.slack.displayName === "string" ? { slack: { displayName: record.slack.displayName, ...(typeof record.slack.iconEmoji === "string" ? { iconEmoji: record.slack.iconEmoji } : {}) } } : {};
|
|
148
|
+
return { schema: "isomorph.app/1.0", name: record.name.trim(), description: (record.description ?? "").trim(), audience: record.audience.map(entry => entry.trim()), ...slack };
|
|
160
149
|
}
|
|
161
150
|
/** Stable per-project identity for the kit-managed local state. */
|
|
162
151
|
export function projectName(root, suffix = "") {
|
|
@@ -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,
|
|
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
|
|
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
|
-
// ----
|
|
502
|
+
// ---- Dependencies and kit bundle blobs ----------------------------------------------------
|
|
501
503
|
/**
|
|
502
|
-
* The
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
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
|
|
510
|
-
|
|
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}
|
|
533
|
-
const npm = nodePackageCommand("npm", ["install", "--no-audit", "--no-fund"
|
|
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("
|
|
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
|
|
541
|
-
* stores it as a content-addressed OCI blob on a
|
|
542
|
-
* an anonymous GET with a Bearer challenge naming
|
|
543
|
-
* out pull tokens without credentials; the challenge
|
|
544
|
-
* request repeated. Redirects (a blob served from
|
|
545
|
-
* the bearer token is not forwarded across origins
|
|
546
|
-
* verifies the bytes against
|
|
547
|
-
* change where the bytes come from, never which bytes
|
|
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
|
|
553
|
+
export async function downloadBundleBlob(url, fetchImpl = fetch) {
|
|
550
554
|
if (!/^https:\/\//.test(url))
|
|
551
|
-
throw new CliError("
|
|
552
|
-
const attempt = (headers = {}) => fetchImpl(url, { redirect: "follow", headers }).catch(error => { throw new CliError("
|
|
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("
|
|
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);
|
|
@@ -136,14 +154,11 @@ function kitFiles(root) {
|
|
|
136
154
|
// Name, description and audience: what `isomorph deploy` prints before it starts and records
|
|
137
155
|
// with the deployment. The folder name and an empty audience are only a starting point.
|
|
138
156
|
".isomorph/app.json": renderAppProfile(defaultAppProfile(root)),
|
|
139
|
-
// No connection
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
// beside the `isomorph` skill (guide.ts). They are not in this file: it is read
|
|
145
|
-
// with JSON.parse here and again by the pipeline's source intake, so it cannot
|
|
146
|
-
// carry comments.
|
|
157
|
+
// No connection: the starter calls no company system. The kit gate derives
|
|
158
|
+
// this file from the app's isomorph.integrations.execute calls on every
|
|
159
|
+
// `isomorph check` (data plane ADR 0016); it is committed and packaged for
|
|
160
|
+
// governance, never edited. `init` writes the empty one so a fresh app has
|
|
161
|
+
// a declaration before its first check.
|
|
147
162
|
".isomorph/integrations.json": `${JSON.stringify(emptyDeclaration(), null, 2)}\n`
|
|
148
163
|
};
|
|
149
164
|
}
|
|
@@ -165,10 +180,9 @@ function starterFiles(bundle) {
|
|
|
165
180
|
"package.json": `${JSON.stringify({
|
|
166
181
|
name: "isomorph-app", private: true, version: "0.1.0", type: "module",
|
|
167
182
|
scripts: { dev: "vite", build: "tsc --noEmit && vite build", preview: "vite preview" },
|
|
168
|
-
// The SDK is
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
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" },
|
|
172
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" }
|
|
173
187
|
}, null, 2)}\n`,
|
|
174
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`,
|
|
@@ -196,7 +210,7 @@ Created by \`isomorph init\`: a Vite + React app that runs on Isomorph. Sign-in,
|
|
|
196
210
|
|
|
197
211
|
The kit's rules are in the \`isomorph\` skill that \`isomorph agent-setup\` installs for Claude Code (\`~/.claude/skills/isomorph/\`) and Codex (\`~/.codex/skills/isomorph/\`): \`core.md\` (identity, data, files, realtime, commands, error codes), \`integrations.md\` (Slack, Gmail, warehouse), \`ai.md\` and \`jobs.md\`. The managed block in CLAUDE.md / AGENTS.md points there.
|
|
198
212
|
|
|
199
|
-
\`.isomorph/checks/\` is regenerated on each check and gitignored — put your own checks in \`tests/<name>.mjs\`. \`.isomorph/integrations.json\`
|
|
213
|
+
\`.isomorph/checks/\` is regenerated on each check and gitignored — put your own checks in \`tests/<name>.mjs\`. \`.isomorph/integrations.json\` is derived by \`isomorph check\` from the app's \`isomorph.integrations.execute\` calls and committed, never edited. \`.isomorph/app.json\` holds the app's name, description and audience.
|
|
200
214
|
`;
|
|
201
215
|
const VITE_CONFIG = `import { defineConfig } from "vite";
|
|
202
216
|
import react from "@vitejs/plugin-react";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@isomorph.ai/cli",
|
|
3
|
-
"version": "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.
|
|
40
|
+
"version": "0.7.0"
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
}
|