@isomorph.ai/cli 0.4.2 → 0.5.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.
package/README.md CHANGED
@@ -28,7 +28,8 @@ The only step you do yourself is the company sign-in: when the agent runs `isomo
28
28
 
29
29
  ```
30
30
  isomorph agent-setup install the `isomorph` skill for Claude Code and Codex; idempotent
31
- isomorph init --app-root <path> [--upgrade] starter app in an empty folder, or kit files in a Vite + React app (also runs agent-setup); --upgrade re-pins the kit bundle and runs the checks
31
+ isomorph init --app-root <path> [--adopt] [--upgrade] [--json] starter app in an empty folder (also runs agent-setup); a folder with an app and no kit is refused with three choices (APP_EXISTS); --adopt adds the kit files to a Vite + React app in place; --upgrade re-pins the kit bundle and runs the checks
32
+ isomorph package --app-root <path> [--out <file>] [--json] zip an app built without the kit for the console's Upload package (local; .env files included, node_modules/build output/.git left out)
32
33
  isomorph dev --app-root <path> [--reset] [--detach] [--json] run the app locally on one loopback origin; --detach starts it in the background and prints {origin, pid} once it answers
33
34
  isomorph stop --app-root <path> stop local services, keep data
34
35
  isomorph check --app-root <path> [--integrations] [--json] types, build, migrations, database gate, write probe, journeys, coverage; --json prints the whole report
@@ -36,15 +37,17 @@ isomorph integrations catalog --app-root <path> [--json] the company's connect
36
37
  isomorph integrations request <connection> --app-root <path> [--reason <text>] one request per connection; IT approves it once for every environment (`isomorph dev` and `isomorph deploy` file it for you)
37
38
  isomorph integrations status --app-root <path> [--json]
38
39
  isomorph connect <work-email | company-start-url> once per company; then isomorph login | logout
39
- isomorph deploy --app-root <path> [--json] checks (when not already passed for this code), one pre-flight, save + preview deployment; prints the protected link
40
- isomorph status | retry | promote --operation <reference> (promote: [--app-root <path>] [--confirm-tested])
40
+ isomorph deploy --app-root <path> [--json] checks (when not already passed for this code), then one call that runs the pre-flight and opens the deployment; the package is handed over and Isomorph saves, verifies and deploys it; prints the protected link
41
+ isomorph status | retry | promote [--app-root <path>] [--operation <reference>] (--app-root defaults to the current folder; --operation to the deployment last started from it; promote: [--confirm-tested])
41
42
  ```
42
43
 
43
- `deploy` reads the app's name, description and audience from `.isomorph/app.json` (written by `init`; edit it before shipping) and prints the three values before it starts. It then asks Isomorph once whether the app can deploy — the access request for every declared connection, the company's AI setup when the app calls governed AI, and this CLI's version — and refuses with exit 2 naming every blocker and its fix (`DEPLOY_BLOCKED`, or `INTEGRATIONS_NOT_READY` / `AI_NOT_READY` / `CLI_UPGRADE_REQUIRED` when the blockers are all of one kind).
44
+ `deploy` reads the app's name, description and audience from `.isomorph/app.json` (written by `init`; edit it before shipping) and prints the three values before it starts. It then asks Isomorph once whether the app can deploy — the access request for every declared connection, the company's AI setup when the app calls governed AI, and this CLI's version — and refuses with exit 2 naming every blocker and its fix (`DEPLOY_BLOCKED`, or `INTEGRATIONS_NOT_READY` / `AI_NOT_READY` / `CLI_UPGRADE_REQUIRED` when the blockers are all of one kind). The same package (by digest) always continues the same deployment, so running `deploy` again after an interruption resumes rather than restarts; a different package while one is still deploying is refused (`DEPLOY_IN_FLIGHT`) naming the operation to follow.
44
45
 
45
- `deploy`, `status --wait`, `retry` and `promote` follow the deployment for up to `--max-wait <seconds>` (default 30 minutes). Past that they exit 0 with `status: "RUNNING"` and the `isomorph status --operation <reference> --wait --max-wait 120 --json` that continues the same deployment — a bounded wait is not a failure, and never a reason to start another deploy.
46
+ `deploy`, `status --wait`, `retry` and `promote` follow the deployment for up to `--max-wait <seconds>` (default 30 minutes). Past that they exit 0 with `status: "RUNNING"` and the `isomorph status --app-root . --operation <reference> --wait --max-wait 120 --json` that continues the same deployment — a bounded wait is not a failure, and never a reason to start another deploy.
46
47
 
47
- `promote` never prompts: `--confirm-tested` is the one flag that means the person has opened the preview and it works. `productionise` is still accepted as an unlisted alias of `deploy` for one release.
48
+ `promote` never prompts: `--confirm-tested` is the one flag that means the person has opened the preview and it works. (`productionise`, the command's name until 0.3.4, was accepted as an alias for one release and is no longer a command.)
49
+
50
+ There are two ways onto Isomorph and `init` is where the folder decides. An empty folder (or one holding only `.git`, a README, a licence or editor and agent files) gets the starter. A folder that already is a kit app (it has `.isomorph/kit.lock.json`) is kept as it is and told its next commands (`dev`, `check`, `deploy`). A folder with an app and no kit is never adopted silently: `init` refuses with exit 2, `APP_EXISTS`, and the three choices — `isomorph init --app-root <dir> --adopt` (add the kit in place; Vite + React only, otherwise `APP_UNSUPPORTED`), `isomorph init --app-root <new-empty-folder>` (start a new kit app), or `isomorph package --app-root <dir>` (package the app as it is for the console import). With `--json` the choices are in `error.details.choices` as `{ id, command, description }`, so an agent relays them and runs the one the person picks. `package` is local (no company, no sign-in): it scans the tree the way `deploy` does, includes the app's `.env` files (the console reads their values into encrypted defaults and never commits the files), refuses private keys and other secret-bearing paths, and writes a deterministic `isomorph-import.zip` (or `--out <file>`) whose result names the path, file count, bytes, sha256 and the one next step: upload it in the console (Add app → Upload package), or connect the repository there instead. A kit app is refused (`KIT_APP`): it deploys with `isomorph deploy`.
48
51
 
49
52
  `isomorph --help` prints the full usage. Local commands need no company sign-in; integrations and shipping do.
50
53
 
@@ -108,7 +108,7 @@ Rules live beside this file: \`core.md\` before the first edit; \`integrations.m
108
108
 
109
109
  ## Getting ready
110
110
 
111
- - No \`.isomorph/\` yet: \`isomorph init --app-root .\` (empty folder → starter; Vite + React app → kit files only). Node 22+, no Docker.
111
+ - No \`.isomorph/\` yet: \`isomorph init --app-root .\` (empty folder → starter; an existing app → three choices to relay). Node 22+, no Docker.
112
112
  - Sign-in (company systems and shipping only): \`isomorph connect <work-email>\` (refused? run the command it prints), then \`isomorph login\` — the browser opens and the person finishes there (their one step; say so).
113
113
 
114
114
  ## Intent → command
@@ -1,8 +1,5 @@
1
- import { readFile, readdir, rm, writeFile } from "node:fs/promises";
2
- import { join } from "node:path";
3
- import { pathToFileURL } from "node:url";
4
- import { readAppSchema } from "./app-schema.js";
5
- import { describeRetainedChecks, syncRetainedChecks } from "./retained-checks.js";
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
6
3
  import { CliError } from "./output.js";
7
4
  import { DEPENDENT_READ_OPERATIONS, READ_OPERATIONS, kitPaths, readDeclaration, readKitLock, resourceNames, sourceDigest } from "./kit.js";
8
5
  import { LocalRuntime, allocatePorts, ensureSdk, freePort, nodePackageCommand, readDevLock, runningOrigin } from "./local-runtime.js";
@@ -36,10 +33,10 @@ export const FLOW_CHECK = "flow.check-failed";
36
33
  * Runs the kit checks and writes `.isomorph/local/check-report.json`; a source
37
34
  * edit changes sourceDigest and so invalidates the previous report. The app's
38
35
  * own toolchain first (typecheck, build), then the kit gate — the pipeline's
39
- * gate, run by the pinned gateway image in this app's local session — whose
40
- * checks are reported verbatim. The retained journeys are generated inside
41
- * that run, from the app's tables as the gate's replayed database holds
42
- * them, before the gate enumerates and runs them.
36
+ * gate, run by the pinned native gateway in this app's local session — whose
37
+ * checks are reported verbatim. The gate generates `.isomorph/checks/` from
38
+ * the app's tables as its replayed database holds them and runs those
39
+ * journeys, and the builder's own `tests/*.mjs`, itself (data plane ADR 0014).
43
40
  */
44
41
  export async function runChecks(root, options) {
45
42
  const { output, run, bundle } = options;
@@ -54,12 +51,12 @@ export async function runChecks(root, options) {
54
51
  const npm = nodePackageCommand("npm", ["run", "build"]);
55
52
  const build = await run(npm.command, npm.args, { cwd: root, quiet: true });
56
53
  record({ name: "build", status: build.code === 0 ? "pass" : "fail", ...(build.code === 0 ? {} : { detail: lastLines(build.stderr || build.stdout) }) });
57
- // The journeys run here with the app's own node and SDK: the SDK the bundle
58
- // pins, installed now if node_modules holds an older copy.
54
+ // The gate runs the journeys with this node and the app's own SDK: the SDK
55
+ // the bundle pins, installed now if node_modules holds an older copy.
59
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"; });
60
57
  if (sdk === "missing")
61
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).`);
62
- const gate = await runKitGate(root, { run, bundle, output, ...(options.fetch ? { fetch: options.fetch } : {}), ...(options.runtime ? { runtime: options.runtime } : {}) });
59
+ const gate = await runKitGate(root, { run, bundle, output, ...(options.fetch ? { fetch: options.fetch } : {}), ...(options.runtime ? { runtime: options.runtime } : {}), ...(options.pollMs ? { pollMs: options.pollMs } : {}) });
63
60
  for (const check of gate.checks)
64
61
  record(check);
65
62
  let integrations = "not tested";
@@ -91,11 +88,9 @@ export async function runChecks(root, options) {
91
88
  }
92
89
  }
93
90
  }
94
- // After the gate, because `.isomorph/checks/*.mjs` is part of the deployed
95
- // tree (kit.ts `sourceDigest`) and the gate may have regenerated it: the
96
- // report has to pin the tree the journeys actually ran against, or
97
- // `isomorph deploy` would refuse the very tree this check just passed
98
- // as CHECKS_STALE.
91
+ // `.isomorph/checks/` is generated output outside the deployable tree
92
+ // (kit.ts `sourceDigest`), so the digest pinned here is the one `deploy`
93
+ // recomputes whether or not the gate regenerated the directory.
99
94
  const source = await sourceDigest(root);
100
95
  const report = {
101
96
  schema: "isomorph.check-report/1.0",
@@ -119,13 +114,13 @@ function renderCheck(check) {
119
114
  /**
120
115
  * Runs the pipeline's kit gate in this app's local session: the pinned
121
116
  * native gateway's `gate` subcommand as a one-shot process using the local
122
- * session's scratch database and files. The gate's control API drives two things
123
- * from here: the `checks` phase, where the gate hands over the app's tables
124
- * as its replayed database holds them and `.isomorph/checks/` is regenerated
125
- * from them (retained-checks.ts) and handed back as the set to run; and the
126
- * journeys, run here — the app's own node and SDK — one at a time as the
127
- * gate asks for them. The session services are started when `isomorph dev`
128
- * is not running and stopped again afterwards.
117
+ * session's scratch database and files. The gate does the whole run itself —
118
+ * derivation, database, the generation of `.isomorph/checks/` and the journeys
119
+ * (generated and `tests/*.mjs`) with this node and the app's SDK — so this is
120
+ * a status loop: `GET /_gate/session` for the progress line, `GET /_gate/report`
121
+ * for the result, whose `files` is `.isomorph/checks/` as generated and is
122
+ * applied to the app before the report is returned. The session services are
123
+ * started when `isomorph dev` is not running and stopped again afterwards.
129
124
  */
130
125
  export async function runKitGate(root, options) {
131
126
  const { run, bundle, output } = options;
@@ -136,111 +131,48 @@ export async function runKitGate(root, options) {
136
131
  const base = `http://127.0.0.1:${port}`;
137
132
  const deadline = Date.now() + (options.timeoutMs ?? 10 * 60_000);
138
133
  const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
139
- const request = async (path, init) => { try {
140
- return await fetchImpl(`${base}${path}`, init);
134
+ const request = async (path) => { try {
135
+ return await fetchImpl(`${base}${path}`);
141
136
  }
142
137
  catch {
143
138
  return undefined;
144
139
  } };
145
- const post = (path, body) => request(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
146
140
  // Everything after the session is up runs under one `finally`: a gate
147
- // gate process that fails to start is exactly the case that could leave a
141
+ // process that fails to start is exactly the case that could leave a
148
142
  // session this run had started still running.
149
- let container;
143
+ let gate;
150
144
  try {
151
- container = await runtime.startGate(bundle, port, base);
152
- let announced = false;
145
+ gate = await runtime.startGate(bundle, port);
146
+ let announced;
153
147
  while (Date.now() < deadline) {
154
148
  const done = await request("/_gate/report");
155
149
  if (done?.status === 200) {
156
150
  const result = await done.json();
157
151
  if (result.report) {
158
- // The app is mounted read-only and the gate works on a mirror, so
159
- // what it authored for the app to ship comes home here — today the
160
- // governed-AI inventory (`.isomorph/ai-inventory.json`), which the
161
- // pipeline verifies against the source and which only the gate may
162
- // write. Written before the caller pins the tree digest, like the
163
- // regenerated retained checks.
164
152
  await writeGateFiles(root, result.files ?? {}, output);
165
153
  return result.report;
166
154
  }
167
155
  throw new CliError("LOCAL_RUNTIME_FAILED", `The kit gate could not run: ${result.error ?? "no report"}.`);
168
156
  }
169
- if (!(await runtime.gateRunning(container)))
170
- throw new CliError("LOCAL_RUNTIME_FAILED", `The kit gate stopped before reporting: ${await runtime.gateLogs(container)}`);
157
+ if (!(await runtime.gateRunning(gate)))
158
+ throw new CliError("LOCAL_RUNTIME_FAILED", `The kit gate stopped before reporting: ${await runtime.gateLogs(gate)}`);
171
159
  const session = await request("/_gate/session");
172
160
  const state = session?.status === 200 ? await session.json() : undefined;
173
- if (state?.status === "checks") {
174
- const files = await regenerateRetainedChecks(root, state.catalog, state.capabilities, output);
175
- const posted = await post("/_gate/checks", { files });
176
- if (posted?.status !== 200)
177
- throw new CliError("LOCAL_RUNTIME_FAILED", "The kit gate did not accept the retained checks.");
178
- continue;
179
- }
180
- if (state?.status === "journeys" && state.current && state.environment) {
181
- if (!announced) {
182
- announced = true;
183
- output("Running the retained journeys against the gate's App Gateway.");
184
- }
185
- const name = state.current;
186
- const result = await run("node", [join(kitPaths(root).checks, name)], { cwd: root, quiet: true, env: { ...state.environment, ISOMORPH_SDK_MODULE: sdkModuleSpecifier(root, bundle), NODE_NO_WARNINGS: "1" } });
187
- const posted = await post(`/_gate/journeys/${encodeURIComponent(name)}`, { code: result.code, output: `${result.stdout}${result.stderr}`.slice(-4000) });
188
- if (posted?.status !== 200)
189
- throw new CliError("LOCAL_RUNTIME_FAILED", `The kit gate did not accept the result of ${name}.`);
190
- continue;
161
+ if (state?.status === "journeys" && state.current && state.current !== announced) {
162
+ announced = state.current;
163
+ output(`Running journey ${state.current}`);
191
164
  }
192
165
  await sleep(options.pollMs ?? 500);
193
166
  }
194
167
  throw new CliError("LOCAL_RUNTIME_FAILED", "The kit gate did not finish within its budget.");
195
168
  }
196
169
  finally {
197
- if (container)
198
- await runtime.stopGate(container);
170
+ if (gate)
171
+ await runtime.stopGate(gate);
199
172
  if (started)
200
173
  await runtime.down();
201
174
  }
202
175
  }
203
- /**
204
- * The SDK a generated journey imports, as a `file://` URL rather than a path.
205
- *
206
- * The journey does `await import(process.env.ISOMORPH_SDK_MODULE)`, and on
207
- * Windows a bare absolute path is not a specifier `import()` accepts: Node
208
- * reads the drive letter in `D:\\app\\node_modules\\…` as a URL scheme and
209
- * refuses it with ERR_UNSUPPORTED_ESM_URL_SCHEME. Every journey on Windows
210
- * therefore failed before it reached its first assertion. It went unseen
211
- * because `isomorph check` reported those failures and still exited 0 (a
212
- * dependency's `beforeExit` hook overwrote `process.exitCode`, fixed in
213
- * 0.3.1-rc.1), so the Windows lane read the run as a pass.
214
- */
215
- export function sdkModuleSpecifier(root, bundle) {
216
- return pathToFileURL(join(root, "node_modules", ...bundle.sdk.package.split("/"), "dist", "index.js")).href;
217
- }
218
- /**
219
- * `.isomorph/checks/` is generated from what the app declares — the tables the
220
- * gate's replayed database holds, the capabilities the gate derived from the
221
- * source (the set its coverage gate will demand evidence for) —
222
- * before the gate reads it, so a check for a feature the app dropped cannot
223
- * survive a single `isomorph check`, and a feature it added arrives with its
224
- * journey. This is not one of the gates: it writes files and reports what it
225
- * did, and the gate's coverage check still decides. Files Isomorph did not
226
- * write, and generated files the builder has edited, are never touched — and
227
- * when the catalog is not one, nothing is touched at all. Resolves to the
228
- * complete set the gate is to run.
229
- */
230
- async function regenerateRetainedChecks(root, catalog, capabilities, output) {
231
- const schema = await readAppSchema(typeof catalog === "string" ? catalog : JSON.stringify(catalog ?? null), root);
232
- if (schema)
233
- for (const line of describeRetainedChecks(await syncRetainedChecks(root, schema, Array.isArray(capabilities) ? capabilities : undefined)))
234
- output(line);
235
- else
236
- output(".isomorph/checks/ was left as it is: the app's tables could not be read from the gate's database, so there was nothing to generate from.");
237
- const directory = kitPaths(root).checks;
238
- const names = (await readdir(directory).catch(() => [])).filter(name => /\.(mjs|js|cjs)$/.test(name)).sort();
239
- const files = {};
240
- for (const name of names)
241
- files[name] = await readFile(join(directory, name), "utf8");
242
- return files;
243
- }
244
176
  /** Brings the session services up when `isomorph dev` is not running; resolves to whether they were started here (and so are taken down again after the gate, data kept). */
245
177
  async function ensureSession(root, runtime, bundle, output) {
246
178
  const lock = await readDevLock(root);
@@ -311,17 +243,24 @@ export async function readReport(root) {
311
243
  function lastLines(text, count = 5) { return text.trim().split("\n").slice(-count).join(" | ").slice(0, 600); }
312
244
  /** A multi-line detail (one gate violation per line) keeps its lines, each under the check name. */
313
245
  function indentLines(detail) { return detail.split("\n").map((line, index) => index ? ` ${line}` : line).join("\n"); }
314
- /** Files the gate authored for the app (path → content, or null to remove), confined to `.isomorph/`. */
246
+ /**
247
+ * `.isomorph/checks/` as the gate generated it (path → content, or null for a
248
+ * file this run did not generate), applied to the app. Confined to that
249
+ * directory: it is generated output, gitignored by `init` and outside the
250
+ * deployable tree; the builder's own checks live in `tests/*.mjs`, which the
251
+ * gate runs and never writes.
252
+ */
315
253
  export async function writeGateFiles(root, files, output) {
316
254
  for (const [path, content] of Object.entries(files)) {
317
- if (!/^\.isomorph\/[A-Za-z0-9._-]+$/.test(path))
318
- throw new CliError("LOCAL_RUNTIME_FAILED", `The kit gate named a file outside .isomorph/: ${path}.`);
255
+ if (!/^\.isomorph\/checks\/[A-Za-z0-9._-]+$/.test(path))
256
+ throw new CliError("LOCAL_RUNTIME_FAILED", `The kit gate named a file outside .isomorph/checks/: ${path}.`);
319
257
  const target = join(root, path);
320
258
  if (content === null) {
321
259
  await rm(target, { force: true });
322
260
  output(`Removed ${path}: the app no longer needs it.`);
323
261
  }
324
262
  else {
263
+ await mkdir(dirname(target), { recursive: true });
325
264
  await writeFile(target, content);
326
265
  output(`Wrote ${path}.`);
327
266
  }
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
- import { RemoteMcpClient } from "./remote-mcp-client.js";
3
- import { appCallsAi, deploy } from "./deploy.js";
4
- import { continueCommand, fetchStatus, follow, promoteToProduction, retryDeployment, summarize } from "./operations.js";
2
+ import { appCallsAi, deploy, readDeployNote } from "./deploy.js";
3
+ import { continueCommand, follow, promoteToProduction, retryDeployment, statusFetch, summarize } from "./operations.js";
5
4
  import { CliError, failureEnvelope, operationEnvelope, renderFailure, renderSummary } from "./output.js";
6
5
  import { CLI_VERSION } from "./version.js";
7
6
  import { connectedAccount, login, logout, refreshStoredToken } from "./auth.js";
8
7
  import { assertCliCurrent, companyLabel, connect, loadConfig, resolveConfig } from "./config.js";
9
8
  import { EMBEDDED_KIT_BUNDLE } from "./kit-bundle.js";
10
- import { appRoot, requestResourceName } from "./kit.js";
9
+ import { appRoot, readAppProfile, readKitLock, requestResourceName } from "./kit.js";
11
10
  import { initKit } from "./starter.js";
11
+ import { packageApp } from "./package.js";
12
12
  import { agentPaths, agentSetup } from "./agent-setup.js";
13
13
  import { detachDev, startDev } from "./dev.js";
14
14
  import { endProcess, ensureSdk, LocalRuntime, readDevLock, releaseDevLock, runCommand } from "./local-runtime.js";
@@ -16,8 +16,8 @@ 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";
18
18
  const args = process.argv.slice(2);
19
- // `productionise` was the command's name until 0.3.4: it is accepted for one release, unlisted, and behaves exactly as `deploy`.
20
- const command = args[0] === "productionise" ? "deploy" : args[0];
19
+ // `productionise` (the command's name until 0.3.4) was an alias of `deploy` for one release; it is no longer a command and falls through to the usage error.
20
+ const command = args[0];
21
21
  const connectUrl = args[1];
22
22
  const root = optionValue("--app-root");
23
23
  const operationRef = optionValue("--operation");
@@ -32,6 +32,10 @@ const reset = args.includes("--reset");
32
32
  /** `dev --detach`: start the services, print the origin, exit while they keep running. */
33
33
  const detach = args.includes("--detach");
34
34
  const upgrade = args.includes("--upgrade");
35
+ /** `init --adopt`: add the kit to the app already in the folder (Vite + React only). Without it a folder with an app and no kit is refused with its three choices. */
36
+ const adopt = args.includes("--adopt");
37
+ /** `package --out`: where the console import package is written (default `<app-root>/isomorph-import.zip`). */
38
+ const out = optionValue("--out");
35
39
  const testIntegrations = args.includes("--integrations");
36
40
  const reason = optionValue("--reason");
37
41
  const operations = optionValue("--operations");
@@ -49,25 +53,26 @@ const usage = [
49
53
  " isomorph connect <work-email | company-start-url>",
50
54
  " isomorph login | logout",
51
55
  " isomorph agent-setup [--json] install the plain-English Isomorph skill for Claude Code (~/.claude/skills/isomorph) and Codex (~/.codex/skills/isomorph)",
52
- " isomorph init --app-root <path> [--upgrade] create the starter or add the kit files (also runs agent-setup); --upgrade re-pins the kit bundle and runs the checks",
56
+ " isomorph init --app-root <path> [--adopt] [--upgrade] [--json] create the starter in an empty folder (also runs agent-setup); a folder with an app and no kit is refused with three choices; --adopt adds the kit files to a Vite + React app in place; --upgrade re-pins the kit bundle and runs the checks",
57
+ " isomorph package --app-root <path> [--out <file>] [--json] zip an app built without the kit for the Isomorph console's Upload package (local, no sign-in; .env files included, node_modules/build output/.git left out; kit apps use deploy)",
53
58
  " isomorph dev --app-root <path> [--reset] [--detach] [--json] run the app locally on one loopback origin (--reset deletes this app's local data; --detach starts it in the background and prints the origin and pid once it answers)",
54
59
  " isomorph stop --app-root <path> stop this app's local services, keeping its database and files",
55
60
  " isomorph check --app-root <path> [--integrations] [--json] types, build, then the pipeline's kit gate in the local session: declaration, migrations + database gate, write probe with cross-user denial, journeys, operation coverage (+ authorised real reads)",
56
- " isomorph deploy --app-root <path> [--session-notes <text>] [--no-wait] [--max-wait <seconds>] [--json] deploy the private preview: name, description and audience come from .isomorph/app.json",
57
- " isomorph status --operation <reference> [--wait] [--max-wait <seconds>] [--json]",
58
- " isomorph retry --operation <reference> [--no-wait] [--max-wait <seconds>] [--json]",
59
- " isomorph promote --operation <reference> [--app-root <path>] [--confirm-tested] [--no-wait] [--max-wait <seconds>] [--json] (--confirm-tested: the person has tried the preview; --app-root: also confirm the company's AI setup for production when the app calls governed AI)",
61
+ " isomorph deploy --app-root <path> [--session-notes <text>] [--no-wait] [--max-wait <seconds>] [--json] deploy the private preview: name, description and audience come from .isomorph/app.json (--no-wait returns once Isomorph has the package, with the command that follows it)",
62
+ " isomorph status [--app-root <path>] [--operation <reference>] [--wait] [--max-wait <seconds>] [--json] (--app-root defaults to the current folder; --operation to the deployment last started from it)",
63
+ " isomorph retry [--app-root <path>] [--operation <reference>] [--no-wait] [--max-wait <seconds>] [--json]",
64
+ " isomorph promote [--app-root <path>] [--operation <reference>] [--confirm-tested] [--no-wait] [--max-wait <seconds>] [--json] (--confirm-tested: the person has tried the preview; the company's AI setup is confirmed for production when the app calls governed AI)",
60
65
  " isomorph jobs run <name> --app-root <path> [--scheduled-at <UTC>] [--real] [--json] run one scheduled job now (local fixtures; --real uses approved company access)",
61
66
  " isomorph integrations request <connection> --app-root <path> [--reason <text>] [--operations a,b] [--json] one request per connection, for every environment at once: IT approves it once (isomorph dev and deploy file it for you)",
62
67
  " isomorph integrations status --app-root <path> [--json]",
63
68
  " isomorph integrations catalog --app-root <path> [--json] the company's connections as .isomorph/integrations.json names them: identifiers, allowed operations, approved channels/tables/views/mailboxes per environment (no app needed)",
64
69
  "Run `isomorph connect <work-email>` once; sign in with `isomorph login` when Isomorph asks.",
65
- "deploy runs the checks when they have not passed for this exact code, then asks Isomorph once whether the app can deploy (the access request for every connection in .isomorph/integrations.json, which IT approves once for every environment; the company's AI setup when the app calls governed AI; this CLI's version) and refuses with exit 2 naming every blocker and its fix; then it saves the app, follows its deployment and prints the protected preview link. promote sends a tested preview to production after the same question for production.",
70
+ "deploy runs the checks when they have not passed for this exact code, then asks Isomorph once whether the app can deploy (the access request for every connection in .isomorph/integrations.json, which IT approves once for every environment; the company's AI setup when the app calls governed AI; this CLI's version) and refuses with exit 2 naming every blocker and its fix; then it hands the app package to Isomorph, which saves it, verifies it and deploys it, and prints the protected preview link. The same package always continues the same deployment. promote sends a tested preview to production after the same question for production.",
66
71
  `--max-wait bounds how long deploy, status --wait, retry and promote follow the deployment (default 30 minutes). When it passes the command exits 0 with status RUNNING, the last known deployment state, and the \`${continueCommand("<reference>")}\` that continues the same deployment: a bounded wait is not a failure and never means start another one.`,
67
72
  ""
68
73
  ].join("\n");
69
74
  const OPERATION_COMMANDS = ["status", "retry", "promote"];
70
- const LOCAL_COMMANDS = ["init", "dev", "stop", "check", "jobs"];
75
+ const LOCAL_COMMANDS = ["init", "package", "dev", "stop", "check", "jobs"];
71
76
  const progress = (message) => { process.stderr.write(`${message}\n`); };
72
77
  /** Envelope for commands that start no Isomorph operation (local kit commands, integrations). */
73
78
  const summaryEnvelope = (result) => ({ schema: "isomorph.cli-result/1.0", cliVersion: CLI_VERSION, status: "SUCCEEDED", operationStarted: false, result });
@@ -81,8 +86,8 @@ const emit = (value, line) => { process.stdout.write(json ? `${JSON.stringify(va
81
86
  * which is how an agent or a CI job reads this CLI. Both writes flush first.
82
87
  */
83
88
  const exitAfterWriting = (code, out, err = "") => { process.stderr.write(err, () => process.stdout.write(out, () => process.exit(code))); };
84
- /** Exit 2, like a usage error: nothing started and the fix is a command the maker runs. */
85
- const USAGE_REFUSALS = ["DEPLOY_BLOCKED", "INTEGRATIONS_NOT_READY", "AI_NOT_READY", "CLI_UPGRADE_REQUIRED", "NOT_A_MEMBER", "TENANT_AMBIGUOUS", "NOT_A_START_LINK", "PLATFORM_UNREACHABLE"];
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"];
86
91
  if (command === "--version" || command === "version") {
87
92
  process.stdout.write(`${CLI_VERSION}\n`);
88
93
  // `-h` was matched anywhere in argv while `--help` was only recognised as the
@@ -105,12 +110,11 @@ else if (command === "agent-setup") {
105
110
  }
106
111
  else if (!["connect", "login", "logout", "deploy", "integrations", ...LOCAL_COMMANDS, ...OPERATION_COMMANDS].includes(command)
107
112
  || (command === "connect" && (!connectUrl || connectUrl.startsWith("--")))
108
- || (["deploy", "status", "retry", "promote"].includes(command) && optionError)
113
+ || (["deploy", ...OPERATION_COMMANDS].includes(command) && optionError)
109
114
  || (command === "deploy" && !root)
110
115
  || (LOCAL_COMMANDS.includes(command) && !root)
111
116
  || (command === "jobs" && (subcommand !== "run" || !args[2] || args[2].startsWith("--")))
112
- || (command === "integrations" && (!root || !subcommand || !["request", "status", "catalog"].includes(subcommand) || (subcommand === "request" && (!args[2] || args[2].startsWith("--")))))
113
- || (OPERATION_COMMANDS.includes(command) && !operationRef)) {
117
+ || (command === "integrations" && (!root || !subcommand || !["request", "status", "catalog"].includes(subcommand) || (subcommand === "request" && (!args[2] || args[2].startsWith("--")))))) {
114
118
  exitAfterWriting(2, "", `${optionError ? `${optionError}\n` : ""}${usage}`);
115
119
  }
116
120
  else {
@@ -125,15 +129,23 @@ else {
125
129
  const target = appRoot(root);
126
130
  const config = resolveConfig(process.env, await loadConfig());
127
131
  const companyToken = async () => config ? (explicitToken || await refreshStoredToken(config.mcpUrl, config.tenantId)) : undefined;
128
- if (command === "init") {
129
- const result = await initKit(target, bundle, { upgrade, tenantId: config?.tenantId, env: process.env });
132
+ if (command === "package") {
133
+ // Purely local: no company, no sign-in, no kit — the person uploads the file in the console.
134
+ const result = await packageApp(root, progress, { ...(out ? { out } : {}) });
135
+ emit(summaryEnvelope(result), `${result.path}\nNext: ${result.nextStep}\n`);
136
+ }
137
+ else if (command === "init") {
138
+ const result = await initKit(target, bundle, { upgrade, adopt, tenantId: config?.tenantId, env: process.env, displayRoot: root });
130
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)`))])
131
140
  progress(line);
132
141
  // The SDK is not on the public registry: init installs the pinned tarball (and the app's other dependencies) itself.
133
142
  const sdk = await ensureSdk(target, bundle, process.env, runCommand, progress);
134
143
  if (sdk === "missing")
135
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\`.`);
136
- 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." : upgrade ? (result.bundleChanges.length ? "Kit bundle upgraded; running checks." : "Kit bundle already current; running checks.") : "Kit files added; existing files were kept.");
145
+ 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
+ : upgrade ? (result.bundleChanges.length ? "Kit bundle upgraded; running checks." : "Kit bundle already current; running checks.")
147
+ : 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}\`.`
148
+ : "Kit files added; existing files were kept.");
137
149
  if (upgrade) {
138
150
  const report = await runChecks(target, { run: runCommand, bundle, output: progress });
139
151
  emit(summaryEnvelope({ ...result, report }));
@@ -202,6 +214,7 @@ else {
202
214
  // Before any network call: a CLI the company's platform no longer accepts is refused
203
215
  // here, in under a second, with the install command, instead of by the pipeline.
204
216
  assertCliCurrent(config);
217
+ // Sign-in is discovered from the MCP endpoint's OAuth metadata; every command here talks REST to the same API.
205
218
  const url = config.mcpUrl;
206
219
  const tenant = config.tenantId;
207
220
  if (command === "login") {
@@ -216,15 +229,13 @@ else {
216
229
  }
217
230
  else {
218
231
  const resolveToken = async () => explicitToken || await refreshStoredToken(url, tenant);
219
- const token = await resolveToken();
220
- if (!token)
232
+ if (!(await resolveToken()))
221
233
  throw new CliError("AUTH_REQUIRED", "Please sign in to Isomorph with `isomorph login`.");
222
234
  // The client re-resolves per request so a token rotated by a sibling process mid-poll is picked up.
223
- const client = new RemoteMcpClient(url, resolveToken, tenant);
235
+ const governance = new GovernanceClient(config.apiUrl, resolveToken, tenant);
224
236
  let envelope;
225
237
  if (command === "integrations") {
226
238
  const target = appRoot(root);
227
- const governance = new GovernanceClient(config.apiUrl, token, tenant);
228
239
  const result = subcommand === "status"
229
240
  ? await integrationsStatus(target, governance)
230
241
  : subcommand === "catalog"
@@ -235,23 +246,32 @@ else {
235
246
  progress(subcommand === "status" ? renderIntegrationsStatus(result) : subcommand === "catalog" ? renderIntegrationsCatalog(result) : renderRequest(result));
236
247
  }
237
248
  else if (command === "deploy") {
238
- // The access requests are filed and the preview lane checked (and the app linked) before any operation starts, so deploy never mints a second app for the same root.
239
- const result = await deploy(root, client, progress, tenant, { waitForDeployment: !noWait, waitOptions, integrations: { governance: new GovernanceClient(config.apiUrl, token, tenant), bundle: EMBEDDED_KIT_BUNDLE }, ...(sessionNotes ? { sessionNotes } : {}) });
249
+ const result = await deploy(root, governance, progress, tenant, { waitForDeployment: !noWait, waitOptions, bundle: EMBEDDED_KIT_BUNDLE, ...(sessionNotes ? { sessionNotes } : {}) });
240
250
  envelope = operationEnvelope(result.result, result.operationRef, CLI_VERSION);
241
251
  }
242
252
  else {
253
+ // The deployment routes are addressed by the app, so these read its identity
254
+ // from the folder (the current one unless --app-root says otherwise) and,
255
+ // without --operation, follow the deployment last started from it.
256
+ const target = appRoot(root ?? ".");
257
+ const appId = (await readKitLock(target))?.appId;
258
+ if (!appId)
259
+ throw new CliError("KIT_REQUIRED", `${root ? "That folder" : "The current folder"} is not a deployed Isomorph app: it has no .isomorph/kit.lock.json with an app identity.`, undefined, "Run this from the app's folder, or pass --app-root <path>.");
260
+ const ref = operationRef ?? (await readDeployNote(target, tenant))?.operationRef;
261
+ if (!ref)
262
+ throw new CliError("OPERATION_REQUIRED", "No deployment has been started from this folder yet.", undefined, "Run `isomorph deploy --app-root .`, or pass --operation <reference>.");
243
263
  let summary;
244
264
  if (command === "status")
245
- summary = wait ? await follow(client, operationRef, progress, waitOptions) : summarize(await fetchStatus(client, operationRef));
265
+ summary = wait ? await follow(statusFetch(governance, appId, ref), ref, progress, waitOptions) : summarize(await governance.deploymentStatus(appId, ref));
246
266
  else if (command === "retry")
247
- summary = await retryDeployment(client, operationRef, progress, { wait: !noWait, waitOptions });
267
+ summary = await retryDeployment(governance, appId, ref, progress, { wait: !noWait, waitOptions });
248
268
  else {
249
- // Production has its own lane and its own AI mode: with the app root in hand, the same one-call pre-flight runs for production before promoting.
250
- if (root)
251
- await assertDeployReady(appRoot(root), new GovernanceClient(config.apiUrl, token, tenant), tenant, EMBEDDED_KIT_BUNDLE, "production", progress, await appCallsAi(appRoot(root)));
252
- summary = await promoteToProduction(client, operationRef, progress, { wait: !noWait, waitOptions, confirmedTested: confirmTested });
269
+ // Production has its own lane and its own AI mode: the same one-call pre-flight runs for production before promoting.
270
+ await assertDeployReady(target, governance, tenant, EMBEDDED_KIT_BUNDLE, "production", progress, await appCallsAi(target));
271
+ const profile = await readAppProfile(target);
272
+ summary = await promoteToProduction(governance, appId, ref, { name: profile.name, description: profile.description, audience: profile.audience }, progress, { wait: !noWait, waitOptions, confirmedTested: confirmTested });
253
273
  }
254
- envelope = operationEnvelope(summary, operationRef, CLI_VERSION);
274
+ envelope = operationEnvelope(summary, ref, CLI_VERSION);
255
275
  }
256
276
  process.stdout.write(json ? `${JSON.stringify(envelope)}\n` : renderSummary(envelope));
257
277
  }