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

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.
@@ -1,99 +1,72 @@
1
1
  #!/usr/bin/env node
2
2
  import { RemoteMcpClient } from "./remote-mcp-client.js";
3
- import { appCallsAi, productionise, SAVE_APPROVAL } from "./productionise.js";
4
- import { confirmAudience, confirmProfile, continueCommand, dismissSecret, fetchStatus, follow, getAppSetup, listSecrets, promoteToProduction, PROMOTION_CONFIRMATION, readSecretFromStdin, readSecretFromTerminal, retryDeployment, setSecret, summarize } from "./operations.js";
3
+ import { appCallsAi, deploy } from "./deploy.js";
4
+ import { continueCommand, fetchStatus, follow, promoteToProduction, retryDeployment, summarize } from "./operations.js";
5
5
  import { CliError, failureEnvelope, operationEnvelope, renderFailure, renderSummary } from "./output.js";
6
6
  import { CLI_VERSION } from "./version.js";
7
7
  import { connectedAccount, login, logout, refreshStoredToken } from "./auth.js";
8
- import { companyLabel, connect, loadConfig, resolveConfig } from "./config.js";
8
+ import { assertCliCurrent, companyLabel, connect, loadConfig, resolveConfig } from "./config.js";
9
9
  import { EMBEDDED_KIT_BUNDLE } from "./kit-bundle.js";
10
- import { appRoot, assertKitCurrent, readKitLock, requestResourceName } from "./kit.js";
10
+ import { appRoot, requestResourceName } from "./kit.js";
11
11
  import { initKit } from "./starter.js";
12
- import { agentPaths, agentSetup, cliVersionLines } from "./agent-setup.js";
13
- import { startDev } from "./dev.js";
14
- import { ensureSdk, LocalRuntime, readDevLock, releaseDevLock, runCommand } from "./local-runtime.js";
12
+ import { agentPaths, agentSetup } from "./agent-setup.js";
13
+ import { detachDev, startDev } from "./dev.js";
14
+ import { endProcess, ensureSdk, LocalRuntime, readDevLock, releaseDevLock, runCommand } from "./local-runtime.js";
15
15
  import { CHECKS_FAILED_HINT, checksFailedMessage, runChecks } from "./check.js";
16
- import { assertAiReady, companySystemsLine, GovernanceClient, groupGrants, IDENTITY_WORDS, integrationsCatalog, integrationsStatus, renderGrantGroup, renderIntegrationsCatalog, requestIntegrations } from "./integrations.js";
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
- const command = args[0];
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];
20
21
  const connectUrl = args[1];
21
22
  const root = optionValue("--app-root");
22
23
  const operationRef = optionValue("--operation");
23
24
  const json = args.includes("--json");
24
25
  const noWait = args.includes("--no-wait");
25
- const secretName = optionValue("--name");
26
- const profileName = optionValue("--name");
27
- const profileDescription = optionValue("--description");
28
- const audienceEmails = optionValue("--emails");
29
26
  const sessionNotes = optionValue("--session-notes");
30
- const personal = args.includes("--personal");
31
- const valueStdin = args.includes("--value-stdin");
32
- /** The reply the save and promote prompts would otherwise wait to have typed, for a caller with no terminal. */
33
- const confirmSave = args.includes("--confirm-save");
27
+ /** The one flag that means "the person tried the preview"; `promote` never prompts for it. */
34
28
  const confirmTested = args.includes("--confirm-tested");
35
29
  const subcommand = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
36
30
  const wait = args.includes("--wait");
37
31
  const reset = args.includes("--reset");
32
+ /** `dev --detach`: start the services, print the origin, exit while they keep running. */
33
+ const detach = args.includes("--detach");
38
34
  const upgrade = args.includes("--upgrade");
39
35
  const testIntegrations = args.includes("--integrations");
40
36
  const reason = optionValue("--reason");
41
37
  const operations = optionValue("--operations");
42
38
  const scheduledAt = optionValue("--scheduled-at");
43
39
  const realJob = args.includes("--real");
44
- /** Seconds `productionise`, `status --wait`, `retry` and `promote` follow the operation before reporting it as still running (default: 30 minutes). */
40
+ /** Seconds `deploy`, `status --wait`, `retry` and `promote` follow the operation before reporting it as still running (default: 30 minutes). */
45
41
  const maxWaitSeconds = args.includes("--max-wait") ? Number(optionValue("--max-wait")) : undefined;
46
42
  const waitOptions = maxWaitSeconds ? { maxWaitMs: maxWaitSeconds * 1000 } : {};
47
- const includePaths = [];
48
43
  let optionError;
49
44
  if (maxWaitSeconds !== undefined && !(Number.isInteger(maxWaitSeconds) && maxWaitSeconds > 0))
50
45
  optionError = "--max-wait takes a whole number of seconds.";
51
- if (command === "productionise" && ((profileName && !profileDescription) || (!profileName && profileDescription) || (audienceEmails && (!profileName || !profileDescription))))
52
- optionError = "Productionise takes --name and --description together; --emails may be added only with both.";
53
- for (let index = 0; index < args.length; index += 1) {
54
- if (args[index] === "--include") {
55
- const value = args[index + 1];
56
- if (!value || value.startsWith("--")) {
57
- optionError = "--include requires a relative file or directory path.";
58
- break;
59
- }
60
- includePaths.push(value);
61
- index += 1;
62
- }
63
- }
64
46
  const explicitToken = process.env.ISOMORPH_TOKEN?.trim() ?? "";
65
47
  const usage = [
66
48
  "Usage:",
67
49
  " isomorph connect <work-email | company-start-url>",
68
50
  " isomorph login | logout",
69
- " isomorph productionise --app-root <path> [--include <relative-path>]... [--name <text> --description <text> [--emails a@co,b@co]] [--session-notes <text>] [--confirm-save] [--no-wait] [--max-wait <seconds>] [--json]",
70
- " isomorph status --operation <reference> [--wait] [--max-wait <seconds>] [--json]",
71
- " isomorph retry --operation <reference> [--no-wait] [--max-wait <seconds>] [--json]",
72
- " isomorph promote --operation <reference> [--app-root <path>] [--confirm-tested] [--no-wait] [--max-wait <seconds>] [--json] (--app-root: also confirm the company's AI setup for production when the app calls governed AI)",
73
- " isomorph setup --operation <reference> [--json] what the app still needs (name, audience, secrets)",
74
- " isomorph profile --operation <reference> [--name <text>] [--description <text>] [--json]",
75
- " isomorph audience --operation <reference> [--emails a@co,b@co] [--json] (no --emails = only you)",
76
- " isomorph secrets list --operation <reference> [--json]",
77
- " isomorph secrets set --operation <reference> --name <NAME> [--personal] [--value-stdin]",
78
- " isomorph secrets dismiss --operation <reference> --name <NAME>",
79
51
  " isomorph agent-setup [--json] install the plain-English Isomorph skill for Claude Code (~/.claude/skills/isomorph) and Codex (~/.codex/skills/isomorph)",
80
- " isomorph init --app-root <path> [--upgrade] create the starter or add the kit files; --upgrade re-pins the kit bundle and moves an app set up by an older CLI to this kit (also runs agent-setup)",
81
- " isomorph dev --app-root <path> [--reset] run the app locally on one loopback origin (--reset deletes this app's local data)",
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",
53
+ " 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)",
82
54
  " isomorph stop --app-root <path> stop this app's local services, keeping its database and files",
83
55
  " 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)",
84
60
  " isomorph jobs run <name> --app-root <path> [--scheduled-at <UTC>] [--real] [--json] run one scheduled job now (local fixtures; --real uses approved company access)",
85
- " 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 productionise file it for you)",
61
+ " 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)",
86
62
  " isomorph integrations status --app-root <path> [--json]",
87
63
  " 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)",
88
64
  "Run `isomorph connect <work-email>` once; sign in with `isomorph login` when Isomorph asks.",
89
- "productionise saves the app, follows its deployment, and prints the protected preview link; promote sends a tested preview to production.",
90
- "productionise stops for a typed `Approved.` before it copies the app files to the company code system, and promote for a typed `Tested.` before it releases the preview. --confirm-save and --confirm-tested give that same reply on the command line when nobody can type it; the approval recorded is the one made by whoever ran the command.",
91
- `--max-wait bounds how long productionise, 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.`,
92
- "For kit apps, productionise first files the access request for every connection in .isomorph/integrations.json (one per connection; IT approves it once for every environment) and exits 2 (INTEGRATIONS_NOT_READY) naming what IT still has to approve, asks governance whether the company's AI setup is ready when the app calls governed AI (exit 2, AI_NOT_READY, with the IT step), then runs the pipeline's kit gate in the local session and refuses (KIT_GATE_FAILED) what CodeBuild would refuse. promote asks the same AI question for production when --app-root names the app.",
93
- "secrets set reads the value from your terminal with echo off (or from stdin with --value-stdin); it is never printed or passed to any other program.",
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.",
66
+ `--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.`,
94
67
  ""
95
68
  ].join("\n");
96
- const OPERATION_COMMANDS = ["status", "retry", "promote", "setup", "profile", "audience", "secrets"];
69
+ const OPERATION_COMMANDS = ["status", "retry", "promote"];
97
70
  const LOCAL_COMMANDS = ["init", "dev", "stop", "check", "jobs"];
98
71
  const progress = (message) => { process.stderr.write(`${message}\n`); };
99
72
  /** Envelope for commands that start no Isomorph operation (local kit commands, integrations). */
@@ -109,11 +82,11 @@ const emit = (value, line) => { process.stdout.write(json ? `${JSON.stringify(va
109
82
  */
110
83
  const exitAfterWriting = (code, out, err = "") => { process.stderr.write(err, () => process.stdout.write(out, () => process.exit(code))); };
111
84
  /** Exit 2, like a usage error: nothing started and the fix is a command the maker runs. */
112
- const USAGE_REFUSALS = ["INTEGRATIONS_NOT_READY", "AI_NOT_READY", "NOT_A_MEMBER", "TENANT_AMBIGUOUS", "NOT_A_START_LINK", "PLATFORM_UNREACHABLE"];
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"];
113
86
  if (command === "--version" || command === "version") {
114
87
  process.stdout.write(`${CLI_VERSION}\n`);
115
88
  // `-h` was matched anywhere in argv while `--help` was only recognised as the
116
- // first token, so `isomorph productionise --help` fell through to argument
89
+ // first token, so `isomorph deploy --help` fell through to argument
117
90
  // validation and exited 2 with the usage block as an error. Both spellings now
118
91
  // mean the same thing wherever they appear.
119
92
  }
@@ -128,22 +101,16 @@ else if (command === "agent-setup") {
128
101
  if (result.removed.length)
129
102
  progress("The guide is no longer in your global Codex instructions, where every session read it; Codex now has it as a skill, like Claude Code.");
130
103
  progress("Claude Code and Codex: the `isomorph` skill is installed. Each agent uses it only for an Isomorph app (a folder with .isomorph/, or an app you ask it to build) and works as usual everywhere else. Say what you want to build; the agent installs and runs the kit itself.");
131
- // Which `isomorph` the next command will run. This command is usually reached through
132
- // `npx …@stable`, which tells you nothing about the CLI that does the actual work.
133
- if (result.cli)
134
- for (const line of cliVersionLines(result.cli))
135
- progress(line);
136
104
  emit(summaryEnvelope({ ...result, paths: agentPaths(process.env) }));
137
105
  }
138
- else if (!["connect", "login", "logout", "productionise", "integrations", ...LOCAL_COMMANDS, ...OPERATION_COMMANDS].includes(command)
106
+ else if (!["connect", "login", "logout", "deploy", "integrations", ...LOCAL_COMMANDS, ...OPERATION_COMMANDS].includes(command)
139
107
  || (command === "connect" && (!connectUrl || connectUrl.startsWith("--")))
140
- || (["productionise", "status", "retry", "promote"].includes(command) && optionError)
141
- || (command === "productionise" && !root)
108
+ || (["deploy", "status", "retry", "promote"].includes(command) && optionError)
109
+ || (command === "deploy" && !root)
142
110
  || (LOCAL_COMMANDS.includes(command) && !root)
143
111
  || (command === "jobs" && (subcommand !== "run" || !args[2] || args[2].startsWith("--")))
144
112
  || (command === "integrations" && (!root || !subcommand || !["request", "status", "catalog"].includes(subcommand) || (subcommand === "request" && (!args[2] || args[2].startsWith("--")))))
145
- || (OPERATION_COMMANDS.includes(command) && !operationRef)
146
- || (command === "secrets" && (!subcommand || !["list", "set", "dismiss"].includes(subcommand) || (subcommand !== "list" && !secretName)))) {
113
+ || (OPERATION_COMMANDS.includes(command) && !operationRef)) {
147
114
  exitAfterWriting(2, "", `${optionError ? `${optionError}\n` : ""}${usage}`);
148
115
  }
149
116
  else {
@@ -156,20 +123,12 @@ else {
156
123
  // Local commands run before the company config/login requirement: the base app needs neither.
157
124
  const bundle = EMBEDDED_KIT_BUNDLE;
158
125
  const target = appRoot(root);
159
- // `init --upgrade` is the one command that moves an app from the old kit layout (initKit decides); every other one refuses it.
160
- if (command !== "init")
161
- await assertKitCurrent(target);
162
126
  const config = resolveConfig(process.env, await loadConfig());
163
127
  const companyToken = async () => config ? (explicitToken || await refreshStoredToken(config.mcpUrl, config.tenantId)) : undefined;
164
128
  if (command === "init") {
165
129
  const result = await initKit(target, bundle, { upgrade, tenantId: config?.tenantId, env: process.env });
166
130
  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)`))])
167
131
  progress(line);
168
- // Silent while the installed CLI is current; loud when `init` came from a
169
- // newer package than the CLI that will run `dev`, `check` and `productionise`.
170
- if (result.agents.cli?.upgradeRequired)
171
- for (const line of cliVersionLines(result.agents.cli))
172
- progress(line);
173
132
  // The SDK is not on the public registry: init installs the pinned tarball (and the app's other dependencies) itself.
174
133
  const sdk = await ensureSdk(target, bundle, process.env, runCommand, progress);
175
134
  if (sdk === "missing")
@@ -185,12 +144,14 @@ else {
185
144
  else if (command === "stop") {
186
145
  const runtime = new LocalRuntime(target);
187
146
  const lock = await readDevLock(target);
188
- if (lock && lock.pid !== process.pid) {
189
- try {
190
- process.kill(lock.pid, "SIGTERM");
191
- }
192
- catch { /* already gone */ }
193
- }
147
+ // The dev process first (its own shutdown ends what it can), then the
148
+ // children it recorded, then Postgres by its controller: after a
149
+ // terminated dev on Windows, or `dev --detach`, nothing else ends them.
150
+ if (lock && lock.pid !== process.pid)
151
+ await endProcess(lock.pid);
152
+ for (const pid of Object.values(lock?.children ?? {}))
153
+ if (pid && pid !== process.pid)
154
+ await endProcess(pid);
194
155
  const stopped = await runtime.stop();
195
156
  await releaseDevLock(target);
196
157
  emit(summaryEnvelope({ project: runtime.project, stopped: stopped.stopped, volumesRetained: true }));
@@ -209,7 +170,7 @@ else {
209
170
  if (!report.passed) {
210
171
  const message = checksFailedMessage(report);
211
172
  exitAfterWriting(1, json
212
- ? `${JSON.stringify({ ...summaryEnvelope(report), status: "FAILED", error: { code: "CHECKS_FAILED", message, remediationHint: CHECKS_FAILED_HINT } })}\n`
173
+ ? `${JSON.stringify({ ...summaryEnvelope(report), status: "FAILED", error: { code: "CHECKS_FAILED", layer: "gate", message, remediationHint: CHECKS_FAILED_HINT, paths: report.checks.filter(check => check.status === "fail").map(check => check.name) } })}\n`
213
174
  : `${message}\n${CHECKS_FAILED_HINT}\n`);
214
175
  }
215
176
  else
@@ -220,6 +181,10 @@ else {
220
181
  progress(`Job ${result.name} completed for ${result.scheduledAt}.`);
221
182
  emit(summaryEnvelope(result));
222
183
  }
184
+ else if (detach) {
185
+ const started = await detachDev(target, { output: progress, reset });
186
+ emit(summaryEnvelope(started), `Isomorph dev is running: ${started.origin} (pid ${started.pid}). \`isomorph stop --app-root .\` stops it.\n`);
187
+ }
223
188
  else {
224
189
  const started = await startDev(target, { bundle, output: progress, reset, company: config ? { apiUrl: config.apiUrl, tenantId: config.tenantId, accessToken: companyToken, account: token => connectedAccount(config.mcpUrl, config.tenantId, token) } : undefined });
225
190
  let stopping = false;
@@ -234,6 +199,9 @@ else {
234
199
  const config = resolveConfig(process.env, await loadConfig());
235
200
  if (!config)
236
201
  throw new CliError("CONFIG_REQUIRED", "Connect Isomorph first with `isomorph connect <work-email>`.");
202
+ // Before any network call: a CLI the company's platform no longer accepts is refused
203
+ // here, in under a second, with the install command, instead of by the pipeline.
204
+ assertCliCurrent(config);
237
205
  const url = config.mcpUrl;
238
206
  const tenant = config.tenantId;
239
207
  if (command === "login") {
@@ -256,7 +224,6 @@ else {
256
224
  let envelope;
257
225
  if (command === "integrations") {
258
226
  const target = appRoot(root);
259
- await assertKitCurrent(target);
260
227
  const governance = new GovernanceClient(config.apiUrl, token, tenant);
261
228
  const result = subcommand === "status"
262
229
  ? await integrationsStatus(target, governance)
@@ -267,13 +234,9 @@ else {
267
234
  if (!json)
268
235
  progress(subcommand === "status" ? renderIntegrationsStatus(result) : subcommand === "catalog" ? renderIntegrationsCatalog(result) : renderRequest(result));
269
236
  }
270
- else if (command === "productionise") {
271
- // Kit apps: the access requests are filed and the preview lane checked (and the app linked) before any operation starts, so productionise never mints a second app for the same root.
272
- const confirmedAppSetup = profileName && profileDescription
273
- ? { displayName: profileName, description: profileDescription, audienceEmails: audienceEmails ? audienceEmails.split(",").map(value => value.trim()).filter(Boolean) : [] }
274
- : undefined;
275
- await assertKitCurrent(appRoot(root));
276
- const result = await productionise(root, client, progress, tenant, includePaths, { waitForDeployment: !noWait, waitOptions, integrations: { governance: new GovernanceClient(config.apiUrl, token, tenant), bundle: EMBEDDED_KIT_BUNDLE }, ...(confirmSave ? { readApproval: async () => SAVE_APPROVAL } : {}), ...(confirmedAppSetup ? { confirmedAppSetup } : {}), ...(sessionNotes ? { sessionNotes } : {}) });
237
+ 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 } : {}) });
277
240
  envelope = operationEnvelope(result.result, result.operationRef, CLI_VERSION);
278
241
  }
279
242
  else {
@@ -282,27 +245,12 @@ else {
282
245
  summary = wait ? await follow(client, operationRef, progress, waitOptions) : summarize(await fetchStatus(client, operationRef));
283
246
  else if (command === "retry")
284
247
  summary = await retryDeployment(client, operationRef, progress, { wait: !noWait, waitOptions });
285
- else if (command === "promote") {
286
- // The production PLAN blocks on the same AI setup plus enforce mode; with the app root in hand, ask before promoting.
287
- if (root && config.apiUrl && await appCallsAi(appRoot(root))) {
288
- const lock = await readKitLock(appRoot(root));
289
- if (lock?.appId)
290
- await assertAiReady(lock.appId, "production", new GovernanceClient(config.apiUrl, token, tenant), progress);
291
- }
292
- summary = await promoteToProduction(client, operationRef, progress, { wait: !noWait, waitOptions, ...(confirmTested ? { readConfirmation: async () => PROMOTION_CONFIRMATION } : {}) });
248
+ 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 });
293
253
  }
294
- else if (command === "setup")
295
- summary = { setup: await getAppSetup(client, operationRef) };
296
- else if (command === "profile")
297
- summary = { setup: await confirmProfile(client, operationRef, { ...(profileName ? { displayName: profileName } : {}), ...(profileDescription ? { description: profileDescription } : {}) }, progress) };
298
- else if (command === "audience")
299
- summary = { setup: await confirmAudience(client, operationRef, audienceEmails ? audienceEmails.split(",").map(value => value.trim()).filter(Boolean) : [], progress) };
300
- else if (subcommand === "list")
301
- summary = { secrets: await listSecrets(client, operationRef) };
302
- else if (subcommand === "set")
303
- summary = await setSecret(client, operationRef, { name: secretName, ...(personal ? { personal: true } : {}), readValue: valueStdin ? readSecretFromStdin : () => readSecretFromTerminal(`Value for ${secretName} (not shown): `) }, progress);
304
- else
305
- summary = await dismissSecret(client, operationRef, secretName, progress);
306
254
  envelope = operationEnvelope(summary, operationRef, CLI_VERSION);
307
255
  }
308
256
  process.stdout.write(json ? `${JSON.stringify(envelope)}\n` : renderSummary(envelope));
@@ -311,7 +259,7 @@ else {
311
259
  }
312
260
  catch (error) {
313
261
  const envelope = failureEnvelope(error, CLI_VERSION);
314
- exitAfterWriting(USAGE_REFUSALS.includes(envelope.error?.code ?? "") ? 2 : 1, json || command === "productionise" ? `${JSON.stringify(envelope)}\n` : "", renderFailure(envelope));
262
+ exitAfterWriting(USAGE_REFUSALS.includes(envelope.error?.code ?? "") ? 2 : 1, json || command === "deploy" ? `${JSON.stringify(envelope)}\n` : "", renderFailure(envelope));
315
263
  }
316
264
  }
317
265
  /** One line per (connection, identity): the three lanes are one approval, so they are read together. */
@@ -3,6 +3,46 @@ import { dirname, join } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { transportFailure } from "./integrations.js";
5
5
  import { CliError } from "./output.js";
6
+ import { CLI_VERSION } from "./version.js";
7
+ /**
8
+ * The one command that installs or upgrades the CLI. It carries no dist-tag: npm resolves an
9
+ * untagged name through `latest`, which is what a release publishes, so every instruction in
10
+ * the kit names the package the same way a person typing it from memory would (LLD §15.5).
11
+ */
12
+ export const CLI_INSTALL_COMMAND = "npm i -g @isomorph.ai/cli";
13
+ /**
14
+ * Refuses, in under a second and before any network call, a CLI older than the
15
+ * minimum the company's platform accepts. The platform knows that number (it is
16
+ * what `kit_bundle_incompatible` used to discover minutes into a pipeline run);
17
+ * `connect` stores it and every company command asks here first.
18
+ */
19
+ export function assertCliCurrent(config, running = CLI_VERSION) {
20
+ const minimum = config.minimumCliVersion;
21
+ if (!minimum || compareCliVersions(running, minimum) >= 0)
22
+ return;
23
+ throw new CliError("CLI_UPGRADE_REQUIRED", `This CLI is ${running}; the company's Isomorph platform needs ${minimum} or newer.`, undefined, `Run \`${CLI_INSTALL_COMMAND}\`, then run this again.`);
24
+ }
25
+ /** Numeric-core semver order; a prerelease sorts before its release. An unparseable version compares equal, so nothing is called stale on a guess. */
26
+ export function compareCliVersions(left, right) {
27
+ const parse = (value) => {
28
+ const match = /^\s*v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(value);
29
+ return match ? { core: [Number(match[1]), Number(match[2]), Number(match[3])], pre: match[4] } : undefined;
30
+ };
31
+ const a = parse(left);
32
+ const b = parse(right);
33
+ if (!a || !b)
34
+ return 0;
35
+ for (let index = 0; index < 3; index += 1)
36
+ if (a.core[index] !== b.core[index])
37
+ return a.core[index] < b.core[index] ? -1 : 1;
38
+ if (a.pre === b.pre)
39
+ return 0;
40
+ if (a.pre === undefined)
41
+ return 1;
42
+ if (b.pre === undefined)
43
+ return -1;
44
+ return a.pre < b.pre ? -1 : 1;
45
+ }
6
46
  export function configPath(env = process.env) {
7
47
  return join(env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config"), "isomorph", "config.json");
8
48
  }
@@ -59,19 +99,26 @@ export async function connect(startUrl, path = configPath(), fetchImpl = fetch)
59
99
  throw new CliError("PLATFORM_UNREACHABLE", `Isomorph answered ${response.status} instead of a company connection profile. Try again in a minute; if it keeps failing, Isomorph is down.`);
60
100
  if (body.schema !== "harbour.tenant-start/1.0")
61
101
  throw new Error("This is not an Isomorph connection profile.");
102
+ const minimum = minimumCliVersion(body);
62
103
  const config = {
63
104
  schema: "isomorph.cli-config/1.0",
64
105
  tenantId: String(body.tenantId ?? ""),
65
106
  ...(typeof body.displayName === "string" ? { displayName: body.displayName } : {}),
66
107
  mcpUrl: String(body.mcpUrl ?? ""),
67
108
  ...(typeof body.uploadUrl === "string" ? { uploadUrl: body.uploadUrl } : {}),
68
- ...(typeof body.apiUrl === "string" ? { apiUrl: body.apiUrl } : {})
109
+ ...(typeof body.apiUrl === "string" ? { apiUrl: body.apiUrl } : {}),
110
+ ...(minimum ? { minimumCliVersion: minimum } : {})
69
111
  };
70
112
  if (!validConfig(config))
71
113
  throw new Error("This is not the expected Isomorph connection profile.");
72
114
  await saveConfig(config, path);
73
115
  return config;
74
116
  }
117
+ /** `kit.minimumCliVersion` of a tenant-start profile; undefined on a profile that predates it. */
118
+ function minimumCliVersion(body) {
119
+ const kit = isRecord(body.kit) ? body.kit : undefined;
120
+ return typeof kit?.minimumCliVersion === "string" && kit.minimumCliVersion.trim() ? kit.minimumCliVersion.trim() : undefined;
121
+ }
75
122
  function platformOrigin() {
76
123
  return process.env.ISOMORPH_PLATFORM_URL?.trim() || "https://platform.isomorph.ai";
77
124
  }
@@ -105,7 +152,7 @@ export function resolveConfig(env, saved) {
105
152
  if (!mcpUrl || !tenantId)
106
153
  return undefined;
107
154
  // The saved name belongs to the saved tenant; an ISOMORPH_TENANT override names another company.
108
- return { schema: "isomorph.cli-config/1.0", tenantId, ...(saved?.displayName && tenantId === saved.tenantId ? { displayName: saved.displayName } : {}), mcpUrl, ...(saved?.uploadUrl ? { uploadUrl: saved.uploadUrl } : {}), apiUrl: env.ISOMORPH_API_URL?.trim() || saved?.apiUrl || apiUrlFromMcpUrl(mcpUrl) };
155
+ return { schema: "isomorph.cli-config/1.0", tenantId, ...(saved?.displayName && tenantId === saved.tenantId ? { displayName: saved.displayName } : {}), mcpUrl, ...(saved?.uploadUrl ? { uploadUrl: saved.uploadUrl } : {}), apiUrl: env.ISOMORPH_API_URL?.trim() || saved?.apiUrl || apiUrlFromMcpUrl(mcpUrl), ...(saved?.minimumCliVersion && tenantId === saved.tenantId ? { minimumCliVersion: saved.minimumCliVersion } : {}) };
109
156
  }
110
157
  /** The company as the CLI names it: `Fourier (fourier)`, or the id twice when the profile carried no name. */
111
158
  export function companyLabel(config) {
@@ -127,7 +174,8 @@ function validConfig(value) {
127
174
  && typeof value.mcpUrl === "string"
128
175
  && /^https?:\/\//.test(value.mcpUrl)
129
176
  && (value.uploadUrl === undefined || typeof value.uploadUrl === "string")
130
- && (value.apiUrl === undefined || (typeof value.apiUrl === "string" && /^https?:\/\//.test(value.apiUrl))));
177
+ && (value.apiUrl === undefined || (typeof value.apiUrl === "string" && /^https?:\/\//.test(value.apiUrl)))
178
+ && (value.minimumCliVersion === undefined || typeof value.minimumCliVersion === "string"));
131
179
  }
132
180
  function isRecord(value) {
133
181
  return Boolean(value && typeof value === "object" && !Array.isArray(value));