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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -211,13 +211,13 @@ export function outcomeFor(status, operationRef) {
211
211
  if (status.deployment)
212
212
  return { ...summary, outcome: "running" };
213
213
  // No deployment yet has two very different causes. A save that stalled or
214
- // failed (RETRYABLE_FAILURE, FAILED) is resumed by running `productionise`
214
+ // failed (RETRYABLE_FAILURE, FAILED) is resumed by running `deploy`
215
215
  // again — observed 2026-09-14 (fourier "Dad Jokes"): a builder read the
216
216
  // administrator sentence for a save that was simply resumable. Only a
217
217
  // SUCCEEDED save with no deployment behind it is the company-setup case.
218
218
  const save = status.sourceSave?.status;
219
219
  if (save && save !== "SUCCEEDED")
220
- return { ...summary, outcome: "no_deployment", nextStep: summary.nextStep ?? `Isomorph has not finished saving the app (${save}). Run \`isomorph productionise\` again for this app: it resumes the same save and deploys; nothing is deployed twice.` };
220
+ return { ...summary, outcome: "no_deployment", nextStep: summary.nextStep ?? `Isomorph has not finished saving the app (${save}). Run \`isomorph deploy\` again for this app: it resumes the same save and deploys; nothing is deployed twice.` };
221
221
  return { ...summary, outcome: "no_deployment", nextStep: summary.nextStep ?? "Isomorph saved the app but has not started a deployment for this company yet. Ask your Isomorph administrator to connect deployment." };
222
222
  }
223
223
  /**
@@ -250,9 +250,11 @@ export async function retryDeployment(client, operationRef, output, options = {}
250
250
  return summarize(await fetchStatus(client, operationRef));
251
251
  return follow(client, operationRef, output, options.waitOptions);
252
252
  }
253
- /** What the maker types at the promote prompt. `--confirm-tested` supplies this same literal through
254
- * `readConfirmation`, so governance receives the attestation it receives today, byte for byte — the
255
- * claim is unchanged, only reachable where no terminal can answer the prompt. */
253
+ /**
254
+ * What governance records as the promotion attestation. `--confirm-tested` is
255
+ * the one flag that means "the person tried the preview"; nothing is prompted
256
+ * for, because the CLI is run by an agent and the person is in the browser.
257
+ */
256
258
  export const PROMOTION_CONFIRMATION = "Tested.";
257
259
  /**
258
260
  * Promotion mirrors the console's "I tested this version" button: the maker
@@ -269,30 +271,18 @@ export async function promoteToProduction(client, operationRef, output, options
269
271
  const deployment = status.deployment;
270
272
  if (deployment?.state !== "LIVE" || !deployment.versionId || !deployment.protectedUrl)
271
273
  throw new CliError("LIVE_PREVIEW_REQUIRED", "The preview must be live before it can be promoted. Check `isomorph status` first.", operationRef);
272
- output(`Open the protected preview and test it: ${deployment.protectedUrl}`);
273
- output("When it works as expected, type Tested. then press Enter to promote it to production.");
274
- const reply = await (options.readConfirmation ?? readLine)();
275
- if (reply !== PROMOTION_CONFIRMATION)
276
- throw new CliError("PROMOTION_NOT_CONFIRMED", "The production promotion was not confirmed. Confirm the tested preview with --confirm-tested instead.", operationRef);
274
+ if (!options.confirmedTested)
275
+ throw new CliError("PROMOTION_NOT_CONFIRMED", `The preview has not been confirmed as tested: ${deployment.protectedUrl}`, operationRef, "Once the person has opened the preview and it works, run this again with --confirm-tested.");
277
276
  const result = structured(await client.call("isomorph_promote_to_production", {
278
277
  operationId: operationRef,
279
278
  expectedPreviewVersionId: deployment.versionId,
280
- confirmation: { schema: "isomorph.stage-approval/1.0", step: "confirm_preview_tested_and_promote", approved: true, approvedByUser: true, userApprovalText: reply }
279
+ confirmation: { schema: "isomorph.stage-approval/1.0", step: "confirm_preview_tested_and_promote", approved: true, approvedByUser: true, userApprovalText: PROMOTION_CONFIRMATION }
281
280
  }));
282
281
  output("Isomorph accepted the production promotion.");
283
282
  if (options.wait === false)
284
283
  return { ...summarize(status), ...(result.production ? { production: pickProduction(result.production) } : {}) };
285
284
  return follow(client, operationRef, output, options.waitOptions);
286
285
  }
287
- export async function readLine() {
288
- if (!process.stdin.isTTY) {
289
- const chunks = [];
290
- for await (const chunk of process.stdin)
291
- chunks.push(Buffer.from(chunk));
292
- return Buffer.concat(chunks).toString("utf8").trim();
293
- }
294
- return new Promise(resolve => { process.stdin.setEncoding("utf8"); process.stdin.once("data", value => resolve(String(value).trim())); });
295
- }
296
286
  export async function getAppSetup(client, operationRef) {
297
287
  await client.initialize();
298
288
  return structured(await client.call("isomorph_get_app_setup", { operationId: operationRef }));
@@ -302,7 +292,7 @@ export async function confirmProfile(client, operationRef, input, output) {
302
292
  const displayName = input.displayName ?? setup.profile?.suggested?.displayName ?? setup.profile?.displayName;
303
293
  const description = input.description ?? setup.profile?.suggested?.description ?? setup.profile?.description;
304
294
  if (!displayName || !description)
305
- throw new CliError("PROFILE_INCOMPLETE", "Give the app a name (--name) and a description (--description); Isomorph had no suggestion to fall back on.", operationRef);
295
+ throw new CliError("PROFILE_INCOMPLETE", "The app needs a name and a description in .isomorph/app.json; Isomorph had no suggestion to fall back on.", operationRef);
306
296
  if (!setup.profile?.profileVersion)
307
297
  throw new CliError("PROFILE_VERSION_MISSING", "Isomorph did not return the app details version.", operationRef);
308
298
  await client.call("isomorph_confirm_app_profile", { operationId: operationRef, displayName, description, expectedVersion: setup.profile.profileVersion });
@@ -315,72 +305,3 @@ export async function confirmAudience(client, operationRef, emails, output) {
315
305
  output(emails.length ? `Isomorph recorded the app's audience: ${emails.length} colleague${emails.length === 1 ? "" : "s"}.` : "Isomorph recorded that only you can open the app for now.");
316
306
  return getAppSetup(client, operationRef);
317
307
  }
318
- export async function listSecrets(client, operationRef) {
319
- return (await getAppSetup(client, operationRef)).secrets;
320
- }
321
- /**
322
- * The value is read by the CLI itself — from the terminal with echo off, or
323
- * from stdin when the caller pipes it — and sent straight to Isomorph. It is
324
- * never printed, never placed in the result, and never seen by an AI tool that
325
- * merely launched this command.
326
- */
327
- export async function setSecret(client, operationRef, input, output) {
328
- await client.initialize();
329
- const value = (await input.readValue()).replace(/\r?\n$/, "");
330
- if (!value)
331
- throw new CliError("SECRET_VALUE_REQUIRED", "No value was entered.", operationRef);
332
- const result = structured(await client.call("isomorph_set_app_secret", { operationId: operationRef, name: input.name, value, scope: input.personal ? "personal" : "app", ...(input.personal ? { acknowledgePersonal: true } : {}) }));
333
- output(`Isomorph stored the value for ${input.name}.${result.resumedOperationIds?.length ? " The paused deployment is resuming." : ""}`);
334
- return { ...(result.secret ? { secret: result.secret } : {}), resumedOperationIds: result.resumedOperationIds ?? [] };
335
- }
336
- export async function dismissSecret(client, operationRef, name, output) {
337
- await client.initialize();
338
- const result = structured(await client.call("isomorph_dismiss_app_secret", { operationId: operationRef, name }));
339
- output(`Isomorph recorded that ${name} is not needed.`);
340
- return { ...(result.secret ? { secret: result.secret } : {}), resumedOperationIds: result.resumedOperationIds ?? [] };
341
- }
342
- /** Read one line from the controlling terminal with echo off, so the value
343
- * never lands in shell history, scrollback, or a parent process's capture. */
344
- export async function readSecretFromTerminal(prompt) {
345
- const { openSync, readSync, closeSync } = await import("node:fs");
346
- let fd;
347
- try {
348
- fd = openSync("/dev/tty", "r+");
349
- }
350
- catch {
351
- throw new CliError("NO_TERMINAL", "No terminal is available to enter the value. Pipe it on stdin with --value-stdin instead.");
352
- }
353
- const { writeSync } = await import("node:fs");
354
- writeSync(fd, prompt);
355
- const stty = await import("node:child_process");
356
- try {
357
- stty.execSync("stty -echo", { stdio: ["inherit", "ignore", "ignore"] });
358
- }
359
- catch { /* best effort: some terminals cannot toggle echo */ }
360
- const chunks = [];
361
- const buffer = Buffer.alloc(1);
362
- try {
363
- while (true) {
364
- const read = readSync(fd, buffer, 0, 1, null);
365
- if (read === 0 || buffer[0] === 0x0a)
366
- break;
367
- if (buffer[0] !== 0x0d)
368
- chunks.push(Buffer.from(buffer));
369
- }
370
- }
371
- finally {
372
- try {
373
- stty.execSync("stty echo", { stdio: ["inherit", "ignore", "ignore"] });
374
- }
375
- catch { /* ignore */ }
376
- writeSync(fd, "\n");
377
- closeSync(fd);
378
- }
379
- return Buffer.concat(chunks).toString("utf8");
380
- }
381
- export async function readSecretFromStdin() {
382
- const chunks = [];
383
- for await (const chunk of process.stdin)
384
- chunks.push(Buffer.from(chunk));
385
- return Buffer.concat(chunks).toString("utf8");
386
- }
@@ -16,11 +16,15 @@ export const NO_DETAIL = "Isomorph could not complete the request.";
16
16
  */
17
17
  export function safeError(error) {
18
18
  const code = error instanceof CliError ? error.code : "CLI_FAILED";
19
+ const layer = error instanceof CliError ? error.layer : "cli";
19
20
  const raw = error instanceof Error ? error.message : typeof error === "string" ? error : "";
20
21
  // The hint is a separate field rather than more sentence: it is often as long
21
22
  // as the message, and appending it would push the reason itself past the cap.
22
23
  const hint = error instanceof CliError && error.remediationHint ? redact(error.remediationHint) : "";
23
- return { code, message: redact(raw) || NO_DETAIL, ...(hint ? { remediationHint: hint } : {}) };
24
+ // Paths and identifiers are a list field, never prose: `redact` bounds prose and
25
+ // would elide the one token the reader needs. They are carried whole, always.
26
+ const paths = error instanceof CliError && error.paths?.length ? error.paths : undefined;
27
+ return { code, layer, message: redact(raw) || NO_DETAIL, ...(hint ? { remediationHint: hint } : {}), ...(paths ? { paths } : {}) };
24
28
  }
25
29
  /** Anything that must never reach a terminal, a log or an envelope. */
26
30
  const SECRETS = /https?:\/\/\S+|Bearer\s+\S+|(?:x-harbour|authorization|content-type)[^\n]*/gi;
@@ -121,7 +125,9 @@ export function renderFailure(envelope) {
121
125
  const error = envelope.error;
122
126
  const result = (envelope.result ?? {});
123
127
  const stages = ["production", "deployment"].filter(kind => result[kind]?.failure && result[kind]?.versionId).map(kind => `\n${stageLine(kind, result[kind])}`);
124
- return `${error?.message ?? NO_DETAIL}${error?.remediationHint ? ` ${error.remediationHint}` : ""}${stages.join("")}\n`;
128
+ // One path per line, verbatim: never through `said`, which would bound them.
129
+ const paths = (error?.paths ?? []).map(path => `\n ${path}`);
130
+ return `${error?.message ?? NO_DETAIL}${error?.remediationHint ? ` ${error.remediationHint}` : ""}${paths.join("")}${stages.join("")}\n`;
125
131
  }
126
132
  /**
127
133
  * Server-supplied text on its way to a terminal, given exactly the treatment
@@ -131,7 +137,7 @@ export function renderFailure(envelope) {
131
137
  * `safeError` was `redact`’s only caller. `renderSummary` printed the server’s
132
138
  * words straight from the payload, so on the success-shaped path — a deployment
133
139
  * that reports a failure inside an otherwise-successful envelope, which is what
134
- * `status` and `productionise` emit after a failed build — that text reached the
140
+ * `status` and `deploy` emit after a failed build — that text reached the
135
141
  * terminal both unredacted and unbounded. A presigned URL, a bearer token or a
136
142
  * forwarded header dump printed verbatim into whatever captured stdout, and a
137
143
  * pathological message printed whole.
@@ -161,17 +167,6 @@ function said(value) {
161
167
  * - `deployment.versionId`, `production.versionId` — printed only on a failed
162
168
  * stage, and for the same reason: it is the reference the console and the
163
169
  * email carry for that attempt, and a bounded or reworded one names nothing.
164
- * - `secrets.asks[].name` and `.prefilledFromPath` — identifiers, not prose. A
165
- * secret named `AUTHORIZATION_TOKEN` matches the header pattern and would be
166
- * erased whole, printing `Secret : needs a value`; the path is read off the
167
- * local checkout rather than sent by the server.
168
- * - `audience.emails` — the builder’s own confirmed list, echoed back. A
169
- * 240-character bound over a joined list silently drops members, which is
170
- * the information loss the tail-keeping change was about.
171
- * - `ask.status`, `ask.scope`, `secrets.available`, `audience.confirmed` and
172
- * the three mapped `pending` steps — compared against fixed literals, so
173
- * only Isomorph’s own words ever print. An unmapped `pending` step is the
174
- * server’s own id, so that one does go through `said`.
175
170
  *
176
171
  * `deployment.message` and `production.message` are declared on the payload and
177
172
  * never printed here; nothing routes them, and a line that printed one would
@@ -208,32 +203,6 @@ export function renderSummary(envelope) {
208
203
  lines.push(`Action needed: ${waiting}`);
209
204
  else if (nextStep)
210
205
  lines.push(`Next: ${nextStep}`);
211
- const setup = envelope.result?.setup;
212
- const secrets = setup?.secrets ?? envelope.result?.secrets;
213
- if (setup?.profile) {
214
- lines.push(`App name: ${said(setup.profile.displayName) || "(none)"}${setup.profile?.confirmed ? " (confirmed)" : setup.profile?.suggested?.displayName ? ` — suggested: ${said(setup.profile.suggested.displayName)}` : " (not confirmed)"}`);
215
- if (setup.profile?.description || setup.profile?.suggested?.description)
216
- lines.push(`Description: ${said(setup.profile?.confirmed ? setup.profile.description : setup.profile?.suggested?.description ?? setup.profile?.description)}`);
217
- if (setup.audience?.available === false)
218
- lines.push(`Audience: ${said(setup.audience.plainEnglish) || "console only"}`);
219
- else
220
- lines.push(`Audience: ${setup.audience?.confirmed ? (setup.audience.emails?.length ? setup.audience.emails.join(", ") : "only you") + " (confirmed)" : "not confirmed"}`);
221
- }
222
- if (secrets) {
223
- if (secrets.available === false)
224
- lines.push(`Secrets: ${said(secrets.plainEnglish) || "console only"}`);
225
- else if (!secrets.asks?.length)
226
- lines.push("Secrets: none asked for");
227
- else
228
- for (const ask of secrets.asks)
229
- lines.push(`Secret ${ask.name}: ${ask.status === "UNSET" ? "needs a value" : ask.status === "SET" ? "set" : "not needed"}${ask.scope === "personal" ? " (personal)" : ""}${ask.prefilledFromPath ? ` (from ${ask.prefilledFromPath})` : ""}`);
230
- }
231
- // productionise carries only the open steps; the setup commands carry the full view.
232
- const openSteps = setup?.pending?.length ? setup.pending.map(step => step === "confirm_app_profile" ? "confirm the app's name and description" : step === "confirm_app_audience" ? "confirm who may open it" : step === "provide_secrets" ? "provide the secrets it asked for" : said(step)) : [];
233
- if (openSteps.length)
234
- lines.push(`Still needed: ${openSteps.join("; ")}${envelope.result.setup?.plainEnglish && !setup?.profile ? ` — run \`isomorph setup --operation ${envelope.operationRef ?? "<reference>"}\`` : ""}`);
235
- else if (setup?.profile)
236
- lines.push("Setup complete.");
237
206
  return `${lines.join("\n")}\n`;
238
207
  }
239
208
  /**
@@ -257,17 +226,31 @@ function failureText(failure) {
257
226
  const hint = said(failure.remediationHint);
258
227
  return `${said(failure.message) || NO_DETAIL}${hint ? ` ${hint}` : ""}`;
259
228
  }
229
+ /** The layer a code implies when its producer named none: the gate's and the pipeline's codes are fixed sets. */
230
+ function layerFor(code) {
231
+ if (/^CHECKS_/.test(code) || code === "KIT_GATE_FAILED")
232
+ return "gate";
233
+ if (code === "DEPLOYMENT_FAILED" || code === "DEPLOYMENT_RETRYABLE_FAILURE" || code === "PRODUCTION_PROMOTION_FAILED")
234
+ return "pipeline";
235
+ return "cli";
236
+ }
260
237
  export class CliError extends Error {
261
238
  code;
262
239
  operationRef;
263
240
  remediationHint;
264
241
  result;
242
+ /** Which machine refused; `layerFor(code)` when the producer named none. */
243
+ layer;
244
+ /** Paths and identifiers the message refers to, carried whole: the envelope never bounds them. */
245
+ paths;
265
246
  /** `result` becomes the envelope's `result`: what the command knew about the operation when it failed. */
266
- constructor(code, message, operationRef, remediationHint, result) {
247
+ constructor(code, message, operationRef, remediationHint, result, options = {}) {
267
248
  super(message);
268
249
  this.code = code;
269
250
  this.operationRef = operationRef;
270
251
  this.remediationHint = remediationHint;
271
252
  this.result = result;
253
+ this.layer = options.layer ?? layerFor(code);
254
+ this.paths = options.paths?.length ? [...options.paths] : undefined;
272
255
  }
273
256
  }
@@ -26,7 +26,7 @@ export function serverRefusal(result) {
26
26
  const declared = typeof detail?.code === "string" ? detail.code.trim() : "";
27
27
  const hint = typeof detail?.remediationHint === "string" && detail.remediationHint.trim() ? detail.remediationHint.trim() : undefined;
28
28
  const labelled = declared ? null : LABELLED.exec(text);
29
- return new CliError(declared || labelled?.[1] || UNLABELLED_REFUSAL, (labelled?.[2] ?? text) || "Isomorph rejected the request.", undefined, hint);
29
+ return new CliError(declared || labelled?.[1] || UNLABELLED_REFUSAL, (labelled?.[2] ?? text) || "Isomorph rejected the request.", undefined, hint, undefined, { layer: "governance" });
30
30
  }
31
31
  /** A JSON-RPC error is Isomorph refusing the call itself (an unknown method, a
32
32
  * body it could not parse). Its numeric code is the only one it has, so it is
@@ -35,21 +35,21 @@ function rpcRefusal(error, status) {
35
35
  const text = error.message?.trim() ?? "";
36
36
  const labelled = LABELLED.exec(text);
37
37
  const code = labelled?.[1] ?? (typeof error.code === "number" ? `ISOMORPH_RPC_${error.code}` : `ISOMORPH_HTTP_${status}`);
38
- return new CliError(code, (labelled?.[2] ?? text) || `Isomorph refused the request (HTTP ${status}).`);
38
+ return new CliError(code, (labelled?.[2] ?? text) || `Isomorph refused the request (HTTP ${status}).`, undefined, undefined, undefined, { layer: "governance" });
39
39
  }
40
40
  /**
41
41
  * A second 401 in a row: the sign-in itself is gone, or this company does not
42
42
  * admit the signed-in account. The server says which in `error_description`
43
43
  * (plain English, no URL or token); an older server sends only the code, and
44
44
  * the fixed sentence stands in. Both fixes are named because the builder cannot
45
- * tell the two causes apart: on 2026-09-14 a `productionise` 14 seconds after a
45
+ * tell the two causes apart: on 2026-09-14 a `deploy` 14 seconds after a
46
46
  * successful `isomorph login` was refused by a company the account was not a
47
47
  * member of, and "run login again" would only have repeated it.
48
48
  */
49
49
  async function signInRefused(response) {
50
50
  const body = await response.json().catch(() => undefined);
51
51
  const said = typeof body?.error_description === "string" ? body.error_description.trim() : "";
52
- return new CliError("AUTH_REQUIRED", `${said || "Isomorph sign-in expired or was revoked."} Run \`isomorph login\` again, or \`isomorph connect <work-email-or-start-url>\` if this is the wrong company.`);
52
+ return new CliError("AUTH_REQUIRED", `${said || "Isomorph sign-in expired or was revoked."} Run \`isomorph login\` again, or \`isomorph connect <work-email-or-start-url>\` if this is the wrong company.`, undefined, undefined, undefined, { layer: "governance" });
53
53
  }
54
54
  async function readRpc(response) {
55
55
  try {
@@ -1,10 +1,9 @@
1
- import { mkdir, readdir, readFile, rename, rm, rmdir, stat, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
  import { bundleDiff } from "./kit-bundle.js";
4
4
  import { agentSetup, MANAGED_END, MANAGED_START, upsertManagedBlock } from "./agent-setup.js";
5
- import { assertKitCurrent, emptyDeclaration, KIT_DIRECTORY, kitPaths, LEGACY_KIT_DIRECTORY, newKitLock, readKitLock, writeKitLock } from "./kit.js";
5
+ import { defaultAppProfile, emptyDeclaration, newKitLock, readKitLock, renderAppProfile, writeKitLock } from "./kit.js";
6
6
  import { CliError } from "./output.js";
7
- import { isPristine } from "./retained-checks.js";
8
7
  export { MANAGED_END, MANAGED_START };
9
8
  /**
10
9
  * Creates the starter in an empty directory, or adds the missing kit files to
@@ -19,17 +18,7 @@ export async function initKit(root, bundle, options = {}) {
19
18
  const emptyDir = !existingPackage && !(await exists(join(root, "src")));
20
19
  if (existingPackage && !isSupportedApp(existingPackage))
21
20
  throw new CliError("APP_UNSUPPORTED", "isomorph init supports an empty directory or an existing Vite + React app (package.json must depend on vite and react).", undefined, "Run `isomorph init --app-root <new-empty-folder>` to start a Vite + React app, then move this app's code into it.");
22
- // An app from a CLI older than 0.2.0 is moved to this layout by `--upgrade`
23
- // and by nothing else: a plain `init` over it would write a second, empty kit
24
- // beside the one it has. Refused before anything, the user-level skill included, is written.
25
- if (!options.upgrade)
26
- await assertKitCurrent(root);
27
21
  const result = { root, created: [], kept: [], updated: [], mode: options.upgrade ? "upgrade" : emptyDir ? "starter" : "existing", bundleChanges: [], agents: options.env ? await agentSetup(options.env) : { created: [], updated: [], kept: [], removed: [] } };
28
- if (options.upgrade) {
29
- await migrateLegacyKit(root, bundle, result);
30
- await retireLegacySkill(root, result);
31
- await rewriteLegacyReferences(root, bundle.sdk.package, result);
32
- }
33
22
  const write = async (path, content) => {
34
23
  const absolute = join(root, path);
35
24
  if (await exists(absolute)) {
@@ -43,7 +32,7 @@ export async function initKit(root, bundle, options = {}) {
43
32
  // The starter (its source and schema) is created only when there is no app here
44
33
  // yet; kit infrastructure is written on every path, including `--upgrade`.
45
34
  const appFiles = emptyDir ? starterFiles(bundle) : {};
46
- for (const [path, content] of Object.entries({ ...appFiles, ...kitFiles() }))
35
+ for (const [path, content] of Object.entries({ ...appFiles, ...kitFiles(root) }))
47
36
  await write(path, content);
48
37
  await appendManaged(root, ".gitignore", GITIGNORE_LINES, result, "\n");
49
38
  for (const file of ["CLAUDE.md", "AGENTS.md"])
@@ -54,7 +43,6 @@ export async function initKit(root, bundle, options = {}) {
54
43
  result.created.push(".isomorph/kit.lock.json");
55
44
  }
56
45
  else if (options.upgrade) {
57
- // Already re-pinned when the migration moved the lock: then the diff is the migration's and the file is kept here.
58
46
  const changes = bundleDiff(previous.bundle, bundle);
59
47
  result.bundleChanges.push(...changes);
60
48
  if (changes.length) {
@@ -66,159 +54,8 @@ export async function initKit(root, bundle, options = {}) {
66
54
  }
67
55
  else
68
56
  result.kept.push(".isomorph/kit.lock.json");
69
- // A file the migration rewrote is reported once, as updated, not again as kept by the pass after it.
70
- result.kept = result.kept.filter(path => !result.updated.includes(path));
71
57
  return result;
72
58
  }
73
- /** The kit layout of CLI releases before 0.2.0, as `init --upgrade` finds it: everything here is what that layout wrote, nothing an app author chose. */
74
- const LEGACY = {
75
- start: "<!-- harbour:kit:start -->",
76
- end: "<!-- harbour:kit:end -->",
77
- generatedPrefix: "// harbour:generated sha256:",
78
- gitignoreLine: ".harbour/local/",
79
- /**
80
- * The project skills older CLIs wrote — `harbour-kit` up to 0.1.x, `isomorph-kit` up
81
- * to 0.2.2 — each under one frontmatter name and one opening line, with a body that
82
- * changed from release to release. Since 0.3.0 the rules live beside the user-level
83
- * skill and no project skill is written.
84
- */
85
- skills: [
86
- { path: ".claude/skills/harbour-kit/SKILL.md", frontmatter: "name: harbour-kit", opening: 'Follow the "Harbour development kit" block' },
87
- { path: ".claude/skills/isomorph-kit/SKILL.md", frontmatter: "name: isomorph-kit", opening: 'Follow the "Isomorph development kit" block' }
88
- ],
89
- sdkPackage: "@harbour/app-sdk",
90
- /** The SDK's exported type names, renamed with the package. */
91
- typeNames: /\bHarbour(User|ErrorCategory|Error|Client|Result|CompatSource)\b/g,
92
- /**
93
- * The environment names the kit and the platform export to a check, a job and
94
- * the Vite config, renamed with the CLI. Whole words: the session's
95
- * `HARBOUR_LOCAL_USER_EMAIL` and the CLI↔gateway `HARBOUR_APP_GATEWAY_*` and
96
- * `HARBOUR_GATE_*` names are not builder-visible and are not renamed.
97
- */
98
- envNames: /\bHARBOUR_(APP_URL|SDK_MODULE|IDENTITY_CONTEXT_SECOND_USER|IDENTITY_CONTEXT_HEADER|IDENTITY_CONTEXT|GATEWAY_URL|WORKLOAD_TOKEN|SCHEDULED_AT|LOCAL_ORIGIN|VITE_PORT)\b/g
99
- };
100
- /**
101
- * Moves an app set up by a CLI older than 0.2.0 to this kit, once, and only under
102
- * `init --upgrade`: the kit directory and its five entries are renamed (git records
103
- * renames), the two committed schema ids move with it, the `.gitignore` line and
104
- * the managed block in CLAUDE.md / AGENTS.md are rewritten, and the checks the old
105
- * kit generated are deleted for the next `check` to regenerate. A check the author
106
- * wrote or edited is kept, as everywhere else. Nothing happens when there is no old
107
- * layout, or when the current one already exists beside it. The old project skill
108
- * and the old names in the app's own files are handled after this, on every
109
- * `--upgrade`, so an app this pass already moved still loses them.
110
- */
111
- async function migrateLegacyKit(root, bundle, result) {
112
- const legacy = join(root, LEGACY_KIT_DIRECTORY);
113
- const paths = kitPaths(root);
114
- if (!(await exists(legacy)) || await exists(paths.kit))
115
- return;
116
- const touched = (path) => result.updated.push(path);
117
- await mkdir(paths.kit, { recursive: true });
118
- for (const entry of ["kit.lock.json", "integrations.json", "checks", "ai-inventory.json", "local"]) {
119
- if (!(await exists(join(legacy, entry))))
120
- continue;
121
- await rename(join(legacy, entry), join(paths.kit, entry));
122
- touched(`${KIT_DIRECTORY}/${entry}`);
123
- }
124
- await rmdir(legacy).catch(() => undefined); // anything else in it was never the kit's
125
- const declaration = await readJson(paths.declaration);
126
- if (declaration)
127
- await writeFile(paths.declaration, `${JSON.stringify({ ...declaration, schema: emptyDeclaration().schema }, null, 2)}\n`);
128
- const lock = await readJson(paths.lock);
129
- if (lock) {
130
- result.bundleChanges = bundleDiff(lock.bundle, bundle);
131
- await writeKitLock(root, { ...newKitLock(bundle, lock.tenantId, lock.appId), createdAt: lock.createdAt });
132
- }
133
- const ignore = join(root, ".gitignore");
134
- const ignored = (await readFile(ignore, "utf8").catch(() => undefined))?.split("\n");
135
- if (ignored?.includes(LEGACY.gitignoreLine)) {
136
- await writeFile(ignore, ignored.map(line => line === LEGACY.gitignoreLine ? `${KIT_DIRECTORY}/local/` : line).join("\n"));
137
- touched(".gitignore");
138
- }
139
- for (const file of ["CLAUDE.md", "AGENTS.md"]) {
140
- const current = await readFile(join(root, file), "utf8").catch(() => "");
141
- if (current.includes(LEGACY.start) && current.includes(LEGACY.end)) {
142
- await upsertManagedBlock(join(root, file), managedBlock(), { start: LEGACY.start, end: LEGACY.end });
143
- touched(file);
144
- }
145
- }
146
- for (const name of await readdir(paths.checks).catch(() => [])) {
147
- if (!isPristine(await readFile(join(paths.checks, name), "utf8").catch(() => ""), LEGACY.generatedPrefix))
148
- continue;
149
- await rm(join(paths.checks, name));
150
- touched(`${KIT_DIRECTORY}/checks/${name}`);
151
- }
152
- }
153
- /**
154
- * Deletes the project skills older CLIs wrote, on every `--upgrade` — an app the
155
- * layout migration moved under 0.2.0 still has the old one, and every app set up
156
- * before 0.3.0 has the `isomorph-kit` one. A file is the kit's by shape, not by
157
- * bytes: each release wrote a different body under the same frontmatter name and
158
- * the same opening line, so one whose body an author rewrote is kept, and
159
- * reported, as theirs.
160
- */
161
- async function retireLegacySkill(root, result) {
162
- for (const skill of LEGACY.skills) {
163
- const path = join(root, skill.path);
164
- const text = await readFile(path, "utf8").catch(() => undefined);
165
- if (text === undefined)
166
- continue;
167
- if (!isLegacyKitSkill(text, skill)) {
168
- result.kept.push(skill.path);
169
- continue;
170
- }
171
- await rm(path);
172
- await rmdir(dirname(path)).catch(() => undefined);
173
- result.updated.push(skill.path);
174
- }
175
- }
176
- /** Frontmatter naming the kit's skill, and the opening every release wrote as the body's first line. */
177
- function isLegacyKitSkill(text, skill) {
178
- const lines = text.split(/\r?\n/);
179
- const close = lines.indexOf("---", 1);
180
- if (lines[0] !== "---" || close < 0)
181
- return false;
182
- return lines.slice(1, close).includes(skill.frontmatter) && (lines.slice(close + 1).find(line => line.trim()) ?? "").startsWith(skill.opening);
183
- }
184
- /**
185
- * Rewrites what the old kit named in the app's own files — `package.json`,
186
- * `vite.config.*`, `src/`, `jobs/` and the retained checks — on every `--upgrade`:
187
- * the SDK's previous package name (and, in `package.json`, the tarball path the
188
- * old kit staged it under), its six exported type names, and the environment
189
- * names the kit and the platform export to a check, a job and the Vite config.
190
- * Whole words only, so an author's own `HarbourUserProfile` is theirs. Idempotent:
191
- * an app that names none of them is left untouched and unreported.
192
- */
193
- async function rewriteLegacyReferences(root, sdkPackage, result) {
194
- const rewrite = (text) => text.replaceAll(`"${LEGACY.sdkPackage}"`, `"${sdkPackage}"`).replaceAll(`'${LEGACY.sdkPackage}'`, `'${sdkPackage}'`).replace(LEGACY.typeNames, "Isomorph$1").replace(LEGACY.envNames, "ISOMORPH_$1");
195
- for (const path of ["package.json", "vite.config.ts", "vite.config.js", "vite.config.mjs", ...(await appSourceFiles(root))]) {
196
- const current = await readFile(join(root, path), "utf8").catch(() => undefined);
197
- if (current === undefined)
198
- continue;
199
- const next = path === "package.json" ? rewrite(current).replaceAll(`file:${LEGACY_KIT_DIRECTORY}/local/sdk/`, `file:${KIT_DIRECTORY}/local/sdk/`) : rewrite(current);
200
- if (next !== current) {
201
- await writeFile(join(root, path), next);
202
- result.updated.push(path);
203
- }
204
- }
205
- }
206
- /** `src/**`, `jobs/**` and the retained checks, relative to the app root. */
207
- async function appSourceFiles(root) {
208
- const files = [];
209
- const walk = async (directory) => {
210
- for (const entry of await readdir(join(root, directory), { withFileTypes: true }).catch(() => [])) {
211
- const path = `${directory}/${entry.name}`;
212
- if (entry.isDirectory())
213
- await walk(path);
214
- else if (/\.(ts|tsx|js|jsx|mjs|cjs)$/.test(entry.name))
215
- files.push(path);
216
- }
217
- };
218
- for (const directory of ["src", "jobs", `${KIT_DIRECTORY}/checks`])
219
- await walk(directory);
220
- return files.sort();
221
- }
222
59
  function isSupportedApp(pkg) {
223
60
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
224
61
  return Boolean(deps.vite && deps.react);
@@ -256,13 +93,9 @@ export function managedBlock() {
256
93
  MANAGED_START,
257
94
  "## Isomorph development kit",
258
95
  "",
259
- "This app runs on Isomorph. Its rules live in the `isomorph` skill (Claude Code: `~/.claude/skills/isomorph/`, Codex: `~/.codex/skills/isomorph/`); if that skill is not installed, run `npx -y @isomorph.ai/cli agent-setup`.",
260
- "",
261
- "1. Read `core.md` beside the skill's `SKILL.md` before the first edit.",
262
- "2. Before you add a key under `\"connections\"` in `.isomorph/integrations.json`, read `integrations.md` there.",
263
- "3. Before you call `isomorph.ai.*`, read `ai.md`; before you create `jobs/`, read `jobs.md`.",
96
+ "This app runs on Isomorph: use the `isomorph` skill (Claude Code `~/.claude/skills/isomorph/`, Codex `~/.codex/skills/isomorph/`; if it is missing, run `npx -y @isomorph.ai/cli agent-setup`). Read `core.md` there before the first edit.",
264
97
  "",
265
- "Commands: `isomorph dev --app-root .` runs the app locally; `isomorph check --app-root . --json` runs the gates the deployment pipeline runs, before every hand-off; `isomorph productionise --app-root .` deploys the private preview.",
98
+ "Commands: `isomorph dev --app-root . --detach --json`, `isomorph check --app-root . --json`, `isomorph deploy --app-root . --json`.",
266
99
  MANAGED_END
267
100
  ].join("\n");
268
101
  }
@@ -272,10 +105,13 @@ export function managedBlock() {
272
105
  * `--upgrade`. Nothing here is example content, because `write` only fills a
273
106
  * gap: an app that deleted a file it does not want must not have it recreated.
274
107
  */
275
- function kitFiles() {
108
+ function kitFiles(root) {
276
109
  return {
110
+ // Name, description and audience: what `isomorph deploy` prints before it starts and records
111
+ // with the deployment. The folder name and an empty audience are only a starting point.
112
+ ".isomorph/app.json": renderAppProfile(defaultAppProfile(root)),
277
113
  // No connection. The starter calls no company system, and a connection the
278
- // app does not call is a deploy that never happens: `isomorph productionise`
114
+ // app does not call is a deploy that never happens: `isomorph deploy`
279
115
  // files the access request for every declared connection and refuses to
280
116
  // start until IT has approved it. One is added when the app really calls it —
281
117
  // the steps and the worked Slack and warehouse examples are in integrations.md
@@ -330,30 +166,26 @@ Created by \`isomorph init\`: a Vite + React app that runs on Isomorph. Sign-in,
330
166
 
331
167
  ## Three commands
332
168
 
333
- - \`isomorph dev --app-root .\` — starts the app on this machine with a local database, file store and gateway; open the printed link.
334
- - \`isomorph check --app-root . --json\` — the same gates the deployment pipeline runs (types, build, schema, retained checks); read \`.isomorph/local/check-report.json\` afterwards.
335
- - \`isomorph productionise --app-root .\` — deploys a private preview for the people you name; \`isomorph promote\` makes it live for everyone once they have tried it.
169
+ - \`isomorph dev --app-root . --detach --json\` — starts the app on this machine with a local database, file store and gateway; open the printed link.
170
+ - \`isomorph check --app-root . --json\` — the same gates the deployment pipeline runs (types, build, schema, retained checks); the output is the report.
171
+ - \`isomorph deploy --app-root . --json\` — deploys a private preview for the people named in \`.isomorph/app.json\`; \`isomorph promote\` makes it live for everyone once they have tried it.
336
172
 
337
173
  ## Where the rules are
338
174
 
339
- 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, retained checks, commands, error codes), \`integrations.md\` (Slack, Gmail, warehouse), \`ai.md\` and \`jobs.md\`. The managed block in CLAUDE.md / AGENTS.md says when to read each, and \`init --upgrade\` keeps that block current.
175
+ 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.
340
176
 
341
- \`.isomorph/checks/\` is generated by \`isomorph check\` from \`migrations/\` and \`src/\` — do not write it by hand. \`.isomorph/integrations.json\` declares the company systems the app calls: none, to start.
177
+ \`.isomorph/checks/\` is generated by \`isomorph check\` from \`migrations/\` and \`src/\` — do not write it by hand. \`.isomorph/integrations.json\` declares the company systems the app calls: none, to start. \`.isomorph/app.json\` holds the app's name, description and audience.
342
178
  `;
343
179
  const VITE_CONFIG = `import { defineConfig } from "vite";
344
180
  import react from "@vitejs/plugin-react";
345
181
 
346
- // \`isomorph dev\` fronts Vite with one loopback origin and sets ISOMORPH_LOCAL_ORIGIN;
347
- // the proxy below keeps SDK calls working if Vite is opened directly.
348
- const isomorphOrigin = process.env.ISOMORPH_LOCAL_ORIGIN ?? "http://127.0.0.1:4180";
349
-
182
+ // \`isomorph dev\` fronts Vite with one loopback origin (the printed link) and sets ISOMORPH_VITE_PORT.
350
183
  export default defineConfig({
351
184
  plugins: [react()],
352
185
  server: {
353
186
  host: "127.0.0.1",
354
187
  port: Number(process.env.ISOMORPH_VITE_PORT ?? 5173),
355
- strictPort: true,
356
- proxy: { "/_harbour": { target: isomorphOrigin, changeOrigin: false } }
188
+ strictPort: true
357
189
  }
358
190
  });
359
191
  `;
@@ -390,7 +222,6 @@ export function App() {
390
222
  const { data } = await isomorph.data.from<Note>("notes").select("*").order("created_at", { ascending: false });
391
223
  setNotes(data);
392
224
  }, []);
393
- // files capability — this call and \`upload\` below are what .isomorph/checks/files-journey.mjs exercises.
394
225
  const loadFiles = useCallback(async () => { setFiles((await isomorph.files.list("private/")) as StoredFile[]); }, []);
395
226
 
396
227
  useEffect(() => {
@@ -408,10 +239,9 @@ export function App() {
408
239
  };
409
240
  const toggle = async (note: Note) => { await isomorph.data.from("notes").update({ done: !note.done }).eq("id", note.id); await loadNotes(); };
410
241
  const remove = async (note: Note) => { await isomorph.data.from("notes").delete().eq("id", note.id); await loadNotes(); };
411
- const upload = async (file: File) => { await isomorph.files.upload(\`private/\${file.name}\`, file); await loadFiles(); }; // .isomorph/checks/files-journey.mjs
242
+ const upload = async (file: File) => { await isomorph.files.upload(\`private/\${file.name}\`, file); await loadFiles(); };
412
243
 
413
- // Company systems and AI are not part of the starter: .isomorph/integrations.json declares
414
- // nothing and no isomorph.ai call exists, so nothing here waits on IT. Rules for adding
244
+ // Company systems and AI are not part of the starter. Rules for adding
415
245
  // either: integrations.md and ai.md in the isomorph skill.
416
246
 
417
247
  return (
@@ -420,12 +250,7 @@ export function App() {
420
250
  <p>Signed in as <strong>{user ? user.email : "…"}</strong> (identity from Isomorph; locally the fixture user).</p>
421
251
  {error && <p role="alert" style={{ color: "crimson" }}>{error}</p>}
422
252
 
423
- {/* Notes — paired with .isomorph/checks/notes-journey.mjs and notes-cross-user.mjs, generated
424
- from this section and migrations/0001_notes.sql; \`isomorph check\` rewrites both whenever
425
- either one changes. Edit 0001_notes.sql in place only until this app's first deploy: an
426
- applied migration is frozen, so after that the schema changes by adding the next
427
- migrations/000N_*.sql — never by editing or deleting an applied file, and never by
428
- dropping a deployed table. Rules: core.md in the isomorph skill (data). */}
253
+ {/* Notes — schema in migrations/0001_notes.sql. Rules: core.md in the isomorph skill (data). */}
429
254
  <section>
430
255
  <h2>Notes</h2>
431
256
  <form onSubmit={event => { event.preventDefault(); void addNote(); }}>
@@ -442,10 +267,7 @@ export function App() {
442
267
  </ul>
443
268
  </section>
444
269
 
445
- {/* Private files — the only code here that calls isomorph.files.*, and so the only reason
446
- .isomorph/checks/files-journey.mjs exists: delete this section and \`isomorph check\` deletes
447
- that check too (one you have edited is yours to delete, in the same edit). Rules: core.md
448
- in the isomorph skill (retained checks). */}
270
+ {/* Private files: the app's own file store (isomorph.files.*). */}
449
271
  <section>
450
272
  <h2>Private files</h2>
451
273
  <input type="file" aria-label="Upload file" onChange={event => { const file = event.target.files?.[0]; if (file) void upload(file); }} />
@@ -469,6 +291,4 @@ DROP POLICY IF EXISTS notes_owner ON notes;
469
291
  CREATE POLICY notes_owner ON notes
470
292
  USING (owner_subject = current_setting('harbour.user_id', true))
471
293
  WITH CHECK (owner_subject = current_setting('harbour.user_id', true));
472
- GRANT SELECT, INSERT, UPDATE, DELETE ON notes TO harbour_app_gateway;
473
- GRANT USAGE, SELECT ON SEQUENCE notes_id_seq TO harbour_app_gateway;
474
294
  `;