@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 +9 -6
- package/dist/packages/harbour-cli/src/agent-setup.js +1 -1
- package/dist/packages/harbour-cli/src/check.js +42 -103
- package/dist/packages/harbour-cli/src/cli.js +54 -34
- package/dist/packages/harbour-cli/src/deploy.js +128 -330
- package/dist/packages/harbour-cli/src/guide.js +2 -2
- package/dist/packages/harbour-cli/src/integrations.js +66 -10
- package/dist/packages/harbour-cli/src/kit-bundle.manifest.js +12 -12
- package/dist/packages/harbour-cli/src/kit.js +5 -3
- package/dist/packages/harbour-cli/src/local-runtime.js +9 -8
- package/dist/packages/harbour-cli/src/operations.js +101 -71
- package/dist/packages/harbour-cli/src/output.js +11 -2
- package/dist/packages/harbour-cli/src/package.js +101 -0
- package/dist/packages/harbour-cli/src/starter.js +44 -22
- package/dist/packages/harbour-cli/src/upload.js +8 -2
- package/dist/src/analyzer.js +3 -17
- package/dist/src/secret-paths.js +6 -6
- package/package.json +2 -2
- package/dist/packages/harbour-cli/src/app-schema.js +0 -150
- package/dist/packages/harbour-cli/src/remote-mcp-client.js +0 -135
- package/dist/packages/harbour-cli/src/retained-checks.js +0 -443
- package/dist/packages/harbour-cli/src/source-inventory.js +0 -239
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]
|
|
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
|
|
40
|
-
isomorph status | retry | promote --operation <reference> (
|
|
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
|
|
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;
|
|
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 {
|
|
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
|
|
40
|
-
* checks are reported verbatim. The
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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
|
|
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
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
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
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
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
|
|
140
|
-
return await fetchImpl(`${base}${path}
|
|
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
|
-
//
|
|
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
|
|
143
|
+
let gate;
|
|
150
144
|
try {
|
|
151
|
-
|
|
152
|
-
let announced
|
|
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(
|
|
170
|
-
throw new CliError("LOCAL_RUNTIME_FAILED", `The kit gate stopped before reporting: ${await runtime.gateLogs(
|
|
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 === "
|
|
174
|
-
|
|
175
|
-
|
|
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 (
|
|
198
|
-
await runtime.stopGate(
|
|
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
|
-
/**
|
|
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 {
|
|
3
|
-
import {
|
|
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`
|
|
20
|
-
const command = 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]
|
|
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 --
|
|
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
|
|
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",
|
|
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 === "
|
|
129
|
-
|
|
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."
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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:
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
summary = await promoteToProduction(
|
|
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,
|
|
274
|
+
envelope = operationEnvelope(summary, ref, CLI_VERSION);
|
|
255
275
|
}
|
|
256
276
|
process.stdout.write(json ? `${JSON.stringify(envelope)}\n` : renderSummary(envelope));
|
|
257
277
|
}
|