@isomorph.ai/cli 0.7.4 → 0.8.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.
@@ -118,12 +118,12 @@ 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, call only what it lists (literals), submit the request yourself, say READY or PENDING in one line. |
121
+ | "I need Slack / Gmail / the warehouse" | read \`integrations.md\` beside this skill first; then: catalog, write the call (literals only), \`isomorph check\`, then submit the request; 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. |
125
125
  | "stop it" | \`isomorph stop --app-root .\` (data kept); \`isomorph dev --app-root . --reset\` only when they ask to start over. |
126
- | "every day at 2pm", "send this automatically" | read \`jobs.md\` beside this skill first; then write the job, run it once with \`isomorph jobs run\`, and say the sentence it gives you. |
126
+ | "every day at 2pm", "send this automatically" | read \`jobs.md\` beside this skill first (a job cannot email as a person); write the job, run it once with \`isomorph jobs run\`, and say its sentence. |
127
127
 
128
128
  A refusal names its layer, its reason and its fix: change that one thing, then run it again. A wait timeout is not a failure — work is still running: run the command it prints (\`${continueCommand("<ref>")}\`); never start a second deploy while one is running.
129
129
 
@@ -2,6 +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 { jobIdentityProblems } from "./jobs.js";
5
6
  import { LocalRuntime, allocatePorts, freePort, installDependencies, nodePackageCommand, readDevLock, runningOrigin } from "./local-runtime.js";
6
7
  import { CLI_VERSION } from "./version.js";
7
8
  /**
@@ -29,6 +30,8 @@ const DETAIL_CAP = 200;
29
30
  export const CHECKS_FAILED_HINT = "Full detail is in .isomorph/local/check-report.json; fix what it reports, then run `isomorph check --app-root .` again.";
30
31
  /** The name the gate reports operation coverage under — the pipeline's rule for it. */
31
32
  export const FLOW_CHECK = "flow.check-failed";
33
+ /** The CLI's own check over `jobs/`: a company operation a job calls that would run as a person. */
34
+ export const JOBS_CHECK = "jobs.identity";
32
35
  /**
33
36
  * Runs the kit checks and writes `.isomorph/local/check-report.json`; a source
34
37
  * edit changes sourceDigest and so invalidates the previous report. The app's
@@ -57,6 +60,10 @@ export async function runChecks(root, options) {
57
60
  const gate = await runKitGate(root, { run, bundle, output, ...(options.fetch ? { fetch: options.fetch } : {}), ...(options.runtime ? { runtime: options.runtime } : {}), ...(options.pollMs ? { pollMs: options.pollMs } : {}) });
58
61
  for (const check of gate.checks)
59
62
  record(check);
63
+ // A job runs as the app: a company operation in `jobs/` that needs a person is refused here, not by the deployed schedule.
64
+ const jobs = await jobIdentityProblems(root, await readDeclaration(root));
65
+ if (jobs.length)
66
+ record({ name: JOBS_CHECK, status: "fail", detail: jobs.join("\n") });
60
67
  let integrations = "not tested";
61
68
  if (options.governance) {
62
69
  // Reads that could not run are one check the report carries as not_run with
@@ -4,7 +4,7 @@ import { continueCommand, follow, promoteToProduction, retryDeployment, statusFe
4
4
  import { CliError, failureEnvelope, operationEnvelope, renderFailure, renderSummary } from "./output.js";
5
5
  import { CLI_VERSION } from "./version.js";
6
6
  import { alreadySignedIn, connectedAccount, login, logout, refreshStoredToken } from "./auth.js";
7
- import { assertCliCurrent, companyLabel, connect, loadConfig, resolveConfig } from "./config.js";
7
+ import { assertCliCurrent, CLI_INSTALL_COMMAND, cliStaleness, companyLabel, connect, loadConfig, resolveConfig } from "./config.js";
8
8
  import { EMBEDDED_KIT_BUNDLE } from "./kit-bundle.js";
9
9
  import { appRoot, assertLockCompany, readAppProfile, readKitLock, requestResourceName } from "./kit.js";
10
10
  import { initKit } from "./starter.js";
@@ -128,6 +128,10 @@ else {
128
128
  const bundle = EMBEDDED_KIT_BUNDLE;
129
129
  const target = appRoot(root);
130
130
  const config = resolveConfig(process.env, await loadConfig());
131
+ // Said here, once, before the local loop starts: `deploy` will refuse this CLI, and the fix takes a minute now.
132
+ const stale = config && cliStaleness(config);
133
+ if (stale)
134
+ progress(`Warning: ${stale} \`isomorph deploy\` will refuse it; run \`${CLI_INSTALL_COMMAND}\` first.`);
131
135
  const companyToken = async () => config ? (explicitToken || await refreshStoredToken(config.mcpUrl, config.tenantId)) : undefined;
132
136
  if (command === "package") {
133
137
  // Purely local: no company, no sign-in, no kit — the person uploads the file in the console.
@@ -17,10 +17,21 @@ export const CLI_INSTALL_COMMAND = "npm i -g @isomorph.ai/cli";
17
17
  * `connect` stores it and every company command asks here first.
18
18
  */
19
19
  export function assertCliCurrent(config, running = CLI_VERSION) {
20
+ const stale = cliStaleness(config, running);
21
+ if (stale)
22
+ throw new CliError("CLI_UPGRADE_REQUIRED", stale, undefined, `Run \`${CLI_INSTALL_COMMAND}\`, then run this again.`);
23
+ }
24
+ /**
25
+ * The same sentence as a warning, for the local commands (`init`, `dev`,
26
+ * `check`, `jobs`), which need no company and so never asked: a builder learnt
27
+ * the kit was too old only when `deploy` refused, after the whole local loop
28
+ * (2026-09-17 round). Undefined when the CLI is current or no minimum is known.
29
+ */
30
+ export function cliStaleness(config, running = CLI_VERSION) {
20
31
  const minimum = config.minimumCliVersion;
21
32
  if (!minimum || compareCliVersions(running, minimum) >= 0)
22
- return;
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.`);
33
+ return undefined;
34
+ return `This CLI is ${running}; the company's Isomorph platform needs ${minimum} or newer.`;
24
35
  }
25
36
  /** 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
37
  export function parseVersion(value) {
@@ -6,7 +6,7 @@ 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
8
  import { acquireDevLock, allocatePorts, assertMigrationNames, installDependencies, LocalRuntime, nodePackageCommand, pidAlive, readDevLock, recordDevChildren, releaseDevLock, runCommand } from "./local-runtime.js";
9
- import { kitPaths } from "./kit.js";
9
+ import { declarationDigest, kitPaths } from "./kit.js";
10
10
  import { CliError, safeError } from "./output.js";
11
11
  /**
12
12
  * Signed in at startup: link the app and file the access request for every
@@ -29,9 +29,41 @@ export async function companyAccessAtStartup(root, client, tenantId, bundle) {
29
29
  return { appId: filed.appId, lines };
30
30
  }
31
31
  catch (error) {
32
- return { lines: [`Company access could not be checked (${safeError(error).message}); the app runs without it until the next start.`] };
32
+ return { lines: [`Company access could not be checked (${safeError(error).message}); the app runs without it until the next start.`], failed: true };
33
33
  }
34
34
  }
35
+ /**
36
+ * Files the declared requests again whenever `.isomorph/integrations.json`
37
+ * changes while `isomorph dev` runs — `isomorph check` rewrites it as the app's
38
+ * calls change — so adding a company system needs no restart. Before this the
39
+ * session filed once at start, nothing watched the file, and a builder's new
40
+ * connection was never requested (2026-09-17 round). `poll` is one tick; a
41
+ * filing that failed is retried on the next tick with the same digest.
42
+ */
43
+ export function declarationWatcher(root, refile, output, filed) {
44
+ let known = filed;
45
+ let busy = false;
46
+ return {
47
+ poll: async () => {
48
+ if (busy)
49
+ return;
50
+ busy = true;
51
+ try {
52
+ const digest = await declarationDigest(root);
53
+ if (digest === known)
54
+ return;
55
+ const result = await refile();
56
+ if (result.lines.length)
57
+ output([".isomorph/integrations.json changed: company access requested again.", ...result.lines].map(line => ` ${line}`).join("\n"));
58
+ if (!result.failed)
59
+ known = digest;
60
+ }
61
+ finally {
62
+ busy = false;
63
+ }
64
+ }
65
+ };
66
+ }
35
67
  /**
36
68
  * Starts the kit-managed Postgres and App Gateway, applies migrations, then
37
69
  * starts Vite and the loopback origin. Stopping retains the app's local data.
@@ -96,7 +128,8 @@ export async function startDev(root, options) {
96
128
  options.output("Company integrations need an Isomorph sign-in: run `isomorph login` (in another terminal) and retry in the app.");
97
129
  } }
98
130
  });
99
- const refreshLock = setInterval(() => { void readKitLock(root).then(lock => { lockAppId = lock?.appId || undefined; }).catch(() => undefined); }, 5_000);
131
+ let watcher;
132
+ const refreshLock = setInterval(() => { void readKitLock(root).then(lock => { lockAppId = lock?.appId || undefined; }).catch(() => undefined); void watcher?.poll().catch(() => undefined); }, 5_000);
100
133
  refreshLock.unref();
101
134
  await new Promise((resolve, reject) => { forwarder.on("error", reject); forwarder.listen(ports.origin, "127.0.0.1", resolve); });
102
135
  const token = company ? await company.accessToken().catch(() => undefined) : undefined;
@@ -114,11 +147,14 @@ export async function startDev(root, options) {
114
147
  ].join("\n"));
115
148
  // Signed in: link now and file the app-wide requests — after the link is out, so governance's round-trips never hold it — and say where each connection stands.
116
149
  if (governance && company) {
150
+ const filed = await declarationDigest(root);
117
151
  const access = await companyAccessAtStartup(root, governance, company.tenantId, options.bundle);
118
152
  lockAppId = access.appId ?? lockAppId;
119
153
  const lines = [...(access.appId && !linkedAtStart ? [`Linked app: ${access.appId}`] : []), ...access.lines];
120
154
  if (lines.length)
121
155
  options.output(lines.map(line => ` ${line}`).join("\n"));
156
+ const refile = async () => { const again = await companyAccessAtStartup(root, governance, company.tenantId, options.bundle); lockAppId = again.appId ?? lockAppId; return again; };
157
+ watcher = declarationWatcher(root, refile, options.output, access.failed ? undefined : filed);
122
158
  }
123
159
  return { origin, stop };
124
160
  }
@@ -97,12 +97,15 @@ async function answerLocally(request, response, path, host, origin, options) {
97
97
  response.writeHead(upstream.status, headers);
98
98
  response.end(new Uint8Array(await upstream.arrayBuffer()));
99
99
  }
100
- function pipe(request, response, port, extraHeaders = {}) {
100
+ /** Forwards the request to a loopback `port` as it came, hop headers dropped; `body` when the caller already read it (job-fixture.ts). */
101
+ export function pipe(request, response, port, extraHeaders = {}, body) {
101
102
  const headers = {};
102
103
  for (const [name, value] of Object.entries(request.headers))
103
104
  if (value !== undefined && !HOP_HEADERS.has(name))
104
105
  headers[name] = value;
105
- if (request.headers["content-length"])
106
+ if (body)
107
+ headers["content-length"] = String(body.length);
108
+ else if (request.headers["content-length"])
106
109
  headers["content-length"] = request.headers["content-length"];
107
110
  Object.assign(headers, extraHeaders);
108
111
  const upstream = httpRequest({ host: "127.0.0.1", port, method: request.method, path: request.url, headers: { ...headers, host: `127.0.0.1:${port}` } }, upstreamResponse => {
@@ -110,7 +113,10 @@ function pipe(request, response, port, extraHeaders = {}) {
110
113
  upstreamResponse.pipe(response);
111
114
  });
112
115
  upstream.on("error", () => reject(response, 502, "UNAVAILABLE", "The local service is not responding."));
113
- request.pipe(upstream);
116
+ if (body)
117
+ upstream.end(body);
118
+ else
119
+ request.pipe(upstream);
114
120
  }
115
121
  /**
116
122
  * An upgrade keeps the browser's own `Host` — this origin's — instead of the
@@ -151,7 +157,7 @@ function withReturnUrl(body, returnUrl) {
151
157
  }
152
158
  return JSON.stringify({ ...(parsed && typeof parsed === "object" ? parsed : {}), returnUrl });
153
159
  }
154
- function readBody(request, limit) {
160
+ export function readBody(request, limit) {
155
161
  return new Promise(resolve => {
156
162
  const chunks = [];
157
163
  let size = 0;
@@ -161,8 +167,9 @@ function readBody(request, limit) {
161
167
  request.on("error", () => resolve(undefined));
162
168
  });
163
169
  }
164
- function reject(response, status, category, message, code = category) {
170
+ /** The SDK's error envelope for an answer this machine gives: `details.code` is what the app reads, `details` carries anything else the refusal names. */
171
+ export function reject(response, status, category, message, code = category, details = {}) {
165
172
  response.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
166
- response.end(JSON.stringify({ error: { category, message, details: { code } }, requestId: "local" }));
173
+ response.end(JSON.stringify({ error: { category, message, details: { ...details, code } }, requestId: "local" }));
167
174
  }
168
175
  const COMPLETE_PAGE = "<!doctype html><meta charset=\"utf-8\"><title>Isomorph</title><body style=\"font-family:system-ui;margin:3rem\"><h1>Connected</h1><p>Your account is linked for this app. You can close this tab and return to the app.</p></body>";
@@ -91,9 +91,11 @@ const reply = await isomorph.ai.chat({ messages: [{ role: "user", content: \`Sum
91
91
  The starter calls no AI; add the one call when the person asks. The check's generated ai journey makes a real governed call locally through the development route with your \`isomorph login\`; in the pipeline it is answered by a canned completion and reported as not tested.`,
92
92
  jobs: `# Scheduled jobs
93
93
 
94
- Scheduled work lives only in \`jobs/<name>.ts\`: export one literal UTC cron as \`schedule\` and one default async handler. Never use \`setInterval\`, an effect or a browser timer as a scheduler; Isomorph runs the same declaration automatically after deployment, and the deployed Kubernetes schedule owns the real clock. Test it immediately: start \`isomorph dev\`, then \`isomorph jobs run <name> --app-root . --scheduled-at <matching-UTC-time> --json\` — local data and safe fixtures, nothing sent. Use \`--real\` only when the person explicitly asks to test the company action now and the exact action has development approval.
94
+ A job runs as the app, never as a person: Gmail and user-mode Slack cannot run in one; \`slack.message.post\` declared \`"identity": "app"\` and warehouse reads can. Say so before building a scheduled email.
95
95
 
96
- The handler acts as the app, so only app-mode operations (\`slack.message.post\` declared \`"identity": "app"\`) may run there; user-mode Slack and Gmail need a person and cannot. A real scheduled send requires the person's explicit request, the exact operation and destination declared in \`.isomorph/integrations.json\`, and a grant for that environment; use one deterministic idempotency key for the business period and destination so a replay does not silently repost.
96
+ Scheduled work lives only in \`jobs/<name>.ts\`: export one literal UTC cron as \`schedule\` and one default async handler. Never use \`setInterval\`, an effect or a browser timer as a scheduler; Isomorph runs the same declaration automatically after deployment, and the deployed Kubernetes schedule owns the real clock. Test it immediately: start \`isomorph dev\`, then \`isomorph jobs run <name> --app-root . --scheduled-at <matching-UTC-time> --json\` — local data, canned AI, company systems refused, nothing sent. Use \`--real\` only when the person explicitly asks to test the company action now and the exact action has development approval.
97
+
98
+ A real scheduled send requires the person's explicit request, the exact operation and destination declared in \`.isomorph/integrations.json\`, and a grant for that environment; use one deterministic idempotency key for the business period and destination so a replay does not silently repost.
97
99
 
98
100
  Tell them: "Your scheduled task is ready. I can test the app on your computer now. Company messages will start after the app is online and access is approved."`
99
101
  };
@@ -0,0 +1,79 @@
1
+ import { createServer } from "node:http";
2
+ import { pipe, readBody, reject } from "./forwarder.js";
3
+ import { isOperationName, OPERATION_IDENTITIES } from "./kit.js";
4
+ /**
5
+ * The origin a job run talks to: a loopback server in front of the session's
6
+ * gateway (an ordinary run) or the dev origin (`--real`).
7
+ *
8
+ * A job used to be handed the gateway port itself, whose local capability set
9
+ * is data, files, realtime and telemetry, so `isomorph.ai.chat` or
10
+ * `isomorph.integrations.execute` from a job was the gateway's 404 "capability
11
+ * is not enabled" (Atlas #236, #238) and the only way past it was `--real`,
12
+ * which sends real messages. The browser never saw this: the forwarder in
13
+ * front of it proxies those paths to governance. Here, governed AI is answered
14
+ * with the pipeline's canned completion (transformbuild/local_ai_fixture.go),
15
+ * company systems are refused naming `--real` — a canned read would be data
16
+ * the job then acts on — and, in both modes, an operation that runs as a
17
+ * person is refused before it leaves the machine: a deployed job has no
18
+ * person, and `--real` carries the builder's own identity through the
19
+ * forwarder, so a scheduled Gmail send used to pass locally and fail deployed.
20
+ * `isomorph check` refuses the same calls statically (jobs.ts `jobIdentityProblems`).
21
+ */
22
+ export const AI_FIXTURE_COMPLETION = "AI is not exercised in the build fixture; this is a canned completion.";
23
+ const BODY_LIMIT = 1024 * 1024;
24
+ /** Whether a job's call to `operation` would run as a person: the request's `mode` or the declaration's identity says "user", or the catalog allows no app identity. */
25
+ export function runsAsPerson(operation, declaration, connection, mode) {
26
+ if (!isOperationName(operation))
27
+ return false;
28
+ const identities = OPERATION_IDENTITIES[operation];
29
+ const chosen = mode === "app" || mode === "user" ? mode : declaration.connections[connection]?.operations[operation]?.identity;
30
+ return !identities.includes("app") || chosen === "user";
31
+ }
32
+ /** The refusal a person-only operation gets from a job, in every mode and in `check`. */
33
+ export const personRefusal = (operation) => `${operation} runs as a person; a scheduled job runs as the app and has no person to act for, locally or deployed. Only app-identity operations run in a job.`;
34
+ export async function startJobFixture(options) {
35
+ const server = createServer((request, response) => {
36
+ const path = (request.url ?? "/").split("?")[0];
37
+ if (request.method === "POST" && (path === "/_harbour/ai/chat" || path === "/_harbour/ai/embed") && !options.real) {
38
+ answer(response, aiFixture(path));
39
+ return;
40
+ }
41
+ if (request.method === "POST" && path === "/_harbour/integrations/execute") {
42
+ void readBody(request, BODY_LIMIT).then(body => execute(request, response, body, options));
43
+ return;
44
+ }
45
+ pipe(request, response, options.upstreamPort);
46
+ });
47
+ await new Promise((resolve, reject) => { server.on("error", reject); server.listen(0, "127.0.0.1", resolve); });
48
+ return { url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise(resolve => server.close(() => resolve())) };
49
+ }
50
+ function execute(request, response, raw, options) {
51
+ if (!raw) {
52
+ reject(response, 413, "VALIDATION_FAILED", "Request too large.", "REQUEST_TOO_LARGE");
53
+ return;
54
+ }
55
+ let body = {};
56
+ try {
57
+ body = JSON.parse(Buffer.from(raw).toString("utf8"));
58
+ }
59
+ catch { /* the upstream answers a malformed body */ }
60
+ const operation = String(body.operation ?? "");
61
+ if (runsAsPerson(operation, options.declaration, String(body.connection ?? ""), body.mode)) {
62
+ reject(response, 409, "CONFLICT", personRefusal(operation), "IDENTITY_MODE_MISMATCH", { operation, identity: "user" });
63
+ return;
64
+ }
65
+ if (!options.real) {
66
+ reject(response, 409, "CONFLICT", `${operation || "This operation"} is not exercised in a local job run. Run \`isomorph jobs run <name> --real\` to use the company's approved development access; the call then really runs.`, "LOCAL_FIXTURE", { operation });
67
+ return;
68
+ }
69
+ pipe(request, response, options.upstreamPort, {}, raw);
70
+ }
71
+ function aiFixture(path) {
72
+ return path === "/_harbour/ai/chat"
73
+ ? { content: AI_FIXTURE_COMPLETION, model: "fixture", finishReason: "stop", usage: { inputTokens: 0, outputTokens: 0 }, traceId: "trc_fixture" }
74
+ : { embeddings: [[0, 0, 0, 0]], model: "fixture", usage: { inputTokens: 0 }, traceId: "trc_fixture" };
75
+ }
76
+ function answer(response, data) {
77
+ response.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
78
+ response.end(JSON.stringify({ data, requestId: "local" }));
79
+ }
@@ -3,7 +3,8 @@ import { join } from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import { CliError } from "./output.js";
5
5
  import { parseSessionEnv, readDevLock, runCommand } from "./local-runtime.js";
6
- import { kitPaths } from "./kit.js";
6
+ import { kitPaths, readDeclaration } from "./kit.js";
7
+ import { personRefusal, runsAsPerson, startJobFixture } from "./job-fixture.js";
7
8
  const jobName = /^[a-z0-9][a-z0-9_-]{0,63}$/;
8
9
  const scheduleDeclaration = /^export\s+const\s+schedule\s*=\s*["']([^"']+)["']\s*;?\s*$/m;
9
10
  export async function discoverJobs(root) {
@@ -42,11 +43,16 @@ export async function runJob(root, name, scheduledAt, run = runCommand, real = f
42
43
  throw new CliError("DEV_NOT_RUNNING", "Start `isomorph dev` before running a job so it uses the same local data and identity boundary.");
43
44
  const env = parseSessionEnv(session);
44
45
  const runner = `const module = await import(${JSON.stringify(pathToFileURL(job.path).href)}); if (typeof module.default !== "function") throw new Error("job has no default handler"); await module.default({scheduledAt: process.env.ISOMORPH_SCHEDULED_AT});`;
45
- // Ordinary runs stay fixture-only. An explicit real run uses the same
46
- // loopback origin as the browser, where the existing forwarder applies the
47
- // builder's approved company/AI access.
48
- const gatewayUrl = real ? lock.origin : `http://127.0.0.1:${lock.ports.gateway}`;
49
- const result = await run(process.execPath, ["--experimental-strip-types", "--input-type=module", "--eval", runner], { cwd: root, env: { ...env, ISOMORPH_GATEWAY_URL: gatewayUrl, ISOMORPH_SCHEDULED_AT: timestamp } });
46
+ // The job talks to a loopback origin of its own (job-fixture.ts) in front of
47
+ // the session's gateway, or of the dev origin under `--real`.
48
+ const fixture = await startJobFixture({ upstreamPort: real ? lock.ports.origin : lock.ports.gateway, real, declaration: await readDeclaration(root) });
49
+ let result;
50
+ try {
51
+ result = await run(process.execPath, ["--experimental-strip-types", "--input-type=module", "--eval", runner], { cwd: root, env: { ...env, ISOMORPH_GATEWAY_URL: fixture.url, ISOMORPH_SCHEDULED_AT: timestamp } });
52
+ }
53
+ finally {
54
+ await fixture.close();
55
+ }
50
56
  if (result.code !== 0)
51
57
  throw new CliError("JOB_FAILED", `Job ${name} failed: ${jobFailureReason(result.stderr)}`, undefined, "Fix what the error names in jobs/<name>.ts, then run this again; `details.stderr` carries the last lines.", undefined, { details: { stderr: result.stderr.trim().split("\n").slice(-20) } });
52
58
  return { name, schedule: job.schedule, scheduledAt: timestamp, mode: real ? "real" : "local" };
@@ -60,5 +66,30 @@ export async function runJob(root, name, scheduledAt, run = runCommand, real = f
60
66
  */
61
67
  export function jobFailureReason(stderr) {
62
68
  const lines = stderr.split("\n").map(line => line.trim()).filter(Boolean);
63
- return lines.find(line => /\b(?:error|exception|failed|cannot|unexpected|invalid|denied|refused)\b/i.test(line)) ?? lines[0] ?? "process exited non-zero";
69
+ // Node echoes the throwing source line before the error: for an SDK refusal that
70
+ // is `throw new IsomorphError(… \`Isomorph request failed with …\`)`, which the
71
+ // word heuristic below matched ahead of the `IsomorphError: <reason>` line.
72
+ return lines.find(line => /^[A-Z]\w*(?:Error|Exception)\b: \S/.test(line))
73
+ ?? lines.find(line => /\b(?:error|exception|failed|cannot|unexpected|invalid|denied|refused)\b/i.test(line)) ?? lines[0] ?? "process exited non-zero";
74
+ }
75
+ const executeCall = /isomorph\s*\.\s*integrations\s*\.\s*execute\s*\(\s*["']([^"']+)["']\s*,\s*\{([^)]*)\)/gs;
76
+ /**
77
+ * Every company operation a job calls that would run as a person, one line
78
+ * each: what `isomorph check` refuses, so a scheduled Gmail send is caught
79
+ * before it is deployed and fails there (a job has no person). The same rule
80
+ * answers the call at run time (job-fixture.ts); this is the static half.
81
+ */
82
+ export async function jobIdentityProblems(root, declaration) {
83
+ const problems = [];
84
+ for (const job of await discoverJobs(root)) {
85
+ const source = await readFile(job.path, "utf8");
86
+ for (const match of source.matchAll(executeCall)) {
87
+ const connection = match[1];
88
+ const operation = /operation\s*:\s*["']([^"']+)["']/.exec(match[2])?.[1] ?? "";
89
+ const mode = /mode\s*:\s*["']([^"']+)["']/.exec(match[2])?.[1];
90
+ if (runsAsPerson(operation, declaration, connection, mode))
91
+ problems.push(`jobs/${job.name}.ts: ${personRefusal(operation)}`);
92
+ }
93
+ }
94
+ return problems;
64
95
  }
@@ -9,6 +9,13 @@ import { CliError } from "./output.js";
9
9
  export const READ_OPERATIONS = ["slack.channel.history", "gmail.thread.list", "warehouse.view.read"];
10
10
  /** Reads whose input needs an identifier from a prior read (a message id): listed as reads, exercised only through the list. */
11
11
  export const DEPENDENT_READ_OPERATIONS = ["gmail.message.read"];
12
+ /** The identities each operation may run under (the platform catalog `INTEGRATION_OPERATIONS`, pinned by tests/cli-jobs.test.ts): a scheduled job runs as the app, so an operation with no "app" identity cannot run in one. */
13
+ export const OPERATION_IDENTITIES = {
14
+ "slack.channel.history": ["user"], "slack.message.post": ["app", "user"],
15
+ "gmail.thread.list": ["user"], "gmail.message.read": ["user"], "gmail.message.send": ["user"],
16
+ "warehouse.view.read": ["app"]
17
+ };
18
+ export function isOperationName(value) { return typeof value === "string" && Object.hasOwn(OPERATION_IDENTITIES, value); }
12
19
  /**
13
20
  * Reads the declaration `isomorph check` derived from the app's
14
21
  * `isomorph.integrations.execute` calls and wrote to `.isomorph/integrations.json`
@@ -24,6 +31,11 @@ export const requestResourceName = (resource) => typeof resource === "string" ?
24
31
  export function requestResources(operation) {
25
32
  return Array.isArray(operation.resources) ? operation.resources : Object.entries(operation.resources).map(([name, spec]) => ({ name, columns: [...spec.columns] }));
26
33
  }
34
+ /** The bytes of `.isomorph/integrations.json` hashed, or the hash of nothing when there is no file: what a dev session records at start and `check` compares afterwards. */
35
+ export async function declarationDigest(root) {
36
+ const raw = await readFile(kitPaths(root).declaration).catch(() => Buffer.alloc(0));
37
+ return `sha256:${createHash("sha256").update(raw).digest("hex")}`;
38
+ }
27
39
  export async function readDeclaration(root) {
28
40
  let raw;
29
41
  try {
@@ -189,9 +189,10 @@ function starterFiles(bundle) {
189
189
  "vite.config.ts": VITE_CONFIG,
190
190
  "index.html": `<!doctype html>\n<html lang="en">\n <head>\n <meta charset="UTF-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1.0" />\n <title>Isomorph app</title>\n </head>\n <body>\n <div id="root"></div>\n <script type="module" src="/src/main.tsx"></script>\n </body>\n</html>\n`,
191
191
  "src/vite-env.d.ts": "/// <reference types=\"vite/client\" />\n",
192
- "src/main.tsx": `import { StrictMode } from "react";\nimport { createRoot } from "react-dom/client";\nimport { App } from "./App";\n\ncreateRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>);\n`,
192
+ "src/main.tsx": `import { StrictMode } from "react";\nimport { createRoot } from "react-dom/client";\nimport { App } from "./App";\nimport "./styles.css";\n\ncreateRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>);\n`,
193
193
  "src/isomorph.client.ts": ISOMORPH_CLIENT,
194
194
  "src/App.tsx": APP,
195
+ "src/styles.css": STYLES,
195
196
  "README.md": STARTER_README
196
197
  };
197
198
  }
@@ -210,6 +211,10 @@ Created by \`isomorph init\`: a Vite + React app that runs on Isomorph. Sign-in,
210
211
 
211
212
  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.
212
213
 
214
+ ## Look and feel
215
+
216
+ \`src/styles.css\` is the whole look: plain CSS, no UI library, light and dark. Change it freely — the starter is a readable floor, not a design.
217
+
213
218
  \`.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.
214
219
  `;
215
220
  const VITE_CONFIG = `import { defineConfig } from "vite";
@@ -280,39 +285,87 @@ export function App() {
280
285
  // Company systems and AI are not part of the starter. Rules for adding
281
286
  // either: integrations.md and ai.md in the isomorph skill.
282
287
 
288
+ // Appearance lives in src/styles.css (plain CSS, no library): change it freely.
283
289
  return (
284
- <main style={{ fontFamily: "system-ui", maxWidth: 720, margin: "2rem auto", padding: "0 1rem" }}>
285
- <h1>Isomorph starter</h1>
286
- <p>Signed in as <strong>{user ? user.email : "…"}</strong> (identity from Isomorph; locally the fixture user).</p>
287
- {error && <p role="alert" style={{ color: "crimson" }}>{error}</p>}
290
+ <main className="app">
291
+ <header className="app-header">
292
+ <h1>Isomorph starter</h1>
293
+ <p className="muted">Signed in as <strong>{user ? user.email : "…"}</strong> (identity from Isomorph; locally the fixture user).</p>
294
+ </header>
295
+ {error && <p role="alert" className="alert">{error}</p>}
288
296
 
289
297
  {/* Notes — schema in migrations/0001_notes.sql. Rules: core.md in the isomorph skill (data). */}
290
- <section>
298
+ <section className="card">
291
299
  <h2>Notes</h2>
292
- <form onSubmit={event => { event.preventDefault(); void addNote(); }}>
300
+ <form className="row" onSubmit={event => { event.preventDefault(); void addNote(); }}>
293
301
  <input value={title} onChange={event => setTitle(event.target.value)} placeholder="New note" aria-label="New note" />
294
- <button type="submit">Add</button>
302
+ <button type="submit" className="primary">Add</button>
295
303
  </form>
296
- <ul>
304
+ <ul className="list">
297
305
  {notes.map(note => (
298
- <li key={note.id}>
306
+ <li key={note.id} className={note.done ? "done" : undefined}>
299
307
  <label><input type="checkbox" checked={note.done} onChange={() => void toggle(note)} /> {note.title}</label>
300
- <button type="button" onClick={() => void remove(note)} aria-label={\`Delete \${note.title}\`}>×</button>
308
+ <button type="button" className="ghost" onClick={() => void remove(note)} aria-label={\`Delete \${note.title}\`}>×</button>
301
309
  </li>
302
310
  ))}
311
+ {notes.length === 0 && <li className="muted">No notes yet.</li>}
303
312
  </ul>
304
313
  </section>
305
314
 
306
315
  {/* Private files: the app's own file store (isomorph.files.*). */}
307
- <section>
316
+ <section className="card">
308
317
  <h2>Private files</h2>
309
318
  <input type="file" aria-label="Upload file" onChange={event => { const file = event.target.files?.[0]; if (file) void upload(file); }} />
310
- <ul>{files.map((file, index) => <li key={index}>{file.path ?? file.name}{file.size !== undefined ? \` (\${file.size} bytes)\` : ""}</li>)}</ul>
319
+ <ul className="list">
320
+ {files.map((file, index) => <li key={index}>{file.path ?? file.name}{file.size !== undefined ? <span className="muted"> ({file.size} bytes)</span> : null}</li>)}
321
+ {files.length === 0 && <li className="muted">No files yet.</li>}
322
+ </ul>
311
323
  </section>
312
324
  </main>
313
325
  );
314
326
  }
315
327
  `;
328
+ /** The starter's whole look: one plain stylesheet, light and dark, no library — a readable floor the builder replaces freely. */
329
+ const STYLES = `/* The starter's look. Plain CSS, no UI library; change anything here freely. */
330
+ :root {
331
+ color-scheme: light dark;
332
+ --bg: #f6f7f9;
333
+ --card: #ffffff;
334
+ --text: #1c1f24;
335
+ --muted: #6b7280;
336
+ --line: #e3e6ea;
337
+ --accent: #2563eb;
338
+ --accent-text: #ffffff;
339
+ --danger: #b91c1c;
340
+ --radius: 10px;
341
+ }
342
+ @media (prefers-color-scheme: dark) {
343
+ :root { --bg: #111418; --card: #1a1e24; --text: #e7e9ec; --muted: #9aa3ad; --line: #2a3038; --accent: #60a5fa; --accent-text: #0b1220; --danger: #f87171; }
344
+ }
345
+ * { box-sizing: border-box; }
346
+ body { margin: 0; background: var(--bg); color: var(--text); font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
347
+ .app { max-width: 720px; margin: 0 auto; padding: 2.5rem 1rem 4rem; }
348
+ .app-header { margin-bottom: 1.5rem; }
349
+ h1 { font-size: 1.75rem; margin: 0 0 0.25rem; letter-spacing: -0.01em; }
350
+ h2 { font-size: 1.1rem; margin: 0 0 0.75rem; }
351
+ p { margin: 0; }
352
+ .muted { color: var(--muted); }
353
+ .alert { color: var(--danger); margin-bottom: 1rem; }
354
+ .card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 1.25rem; margin-bottom: 1rem; }
355
+ .row { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; }
356
+ input[type="text"], input:not([type]) { flex: 1; min-width: 0; padding: 0.55rem 0.75rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg); color: var(--text); font: inherit; }
357
+ input:focus-visible, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
358
+ button { padding: 0.55rem 0.9rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); color: var(--text); font: inherit; cursor: pointer; }
359
+ button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
360
+ button.ghost { border-color: transparent; background: transparent; color: var(--muted); padding: 0.25rem 0.5rem; }
361
+ button.ghost:hover { color: var(--danger); }
362
+ .list { list-style: none; margin: 0; padding: 0; }
363
+ .list li { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 0.5rem 0; border-top: 1px solid var(--line); }
364
+ .list li:first-child { border-top: 0; }
365
+ .list li.done label { text-decoration: line-through; color: var(--muted); }
366
+ .list label { display: flex; align-items: center; gap: 0.5rem; cursor: pointer; }
367
+ input[type="file"] { display: block; margin-bottom: 0.75rem; color: var(--muted); }
368
+ `;
316
369
  const MIGRATION = `-- Notes table for the starter. Ownership comes from the Isomorph gateway's transaction-local
317
370
  -- settings (harbour.user_id / harbour.user_email); the browser never supplies it.
318
371
  CREATE TABLE IF NOT EXISTS notes (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isomorph.ai/cli",
3
- "version": "0.7.4",
3
+ "version": "0.8.0",
4
4
  "description": "Isomorph development kit CLI",
5
5
  "type": "module",
6
6
  "bin": {