@isomorph.ai/cli 0.3.4 → 0.4.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -14
- package/dist/packages/harbour-cli/src/agent-setup.js +25 -125
- package/dist/packages/harbour-cli/src/auth.js +1 -1
- package/dist/packages/harbour-cli/src/check.js +7 -34
- package/dist/packages/harbour-cli/src/cli.js +46 -100
- package/dist/packages/harbour-cli/src/config.js +51 -3
- package/dist/packages/harbour-cli/src/{productionise.js → deploy.js} +130 -183
- package/dist/packages/harbour-cli/src/dev.js +81 -2
- package/dist/packages/harbour-cli/src/guide.js +31 -45
- package/dist/packages/harbour-cli/src/integrations.js +47 -74
- package/dist/packages/harbour-cli/src/kit-bundle.js +0 -3
- package/dist/packages/harbour-cli/src/kit-bundle.manifest.js +9 -9
- package/dist/packages/harbour-cli/src/kit.js +43 -16
- package/dist/packages/harbour-cli/src/local-runtime.js +23 -1
- package/dist/packages/harbour-cli/src/operations.js +11 -90
- package/dist/packages/harbour-cli/src/output.js +24 -41
- package/dist/packages/harbour-cli/src/remote-mcp-client.js +4 -4
- package/dist/packages/harbour-cli/src/starter.js +21 -201
- package/dist/packages/harbour-cli/src/upload.js +1 -1
- package/dist/packages/harbour-cli/src/version.js +1 -1
- package/dist/src/analyzer.js +1 -1
- package/dist/src/secret-paths.js +1 -1
- package/package.json +2 -2
|
@@ -1,99 +1,72 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { RemoteMcpClient } from "./remote-mcp-client.js";
|
|
3
|
-
import { appCallsAi,
|
|
4
|
-
import {
|
|
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,
|
|
10
|
+
import { appRoot, requestResourceName } from "./kit.js";
|
|
11
11
|
import { initKit } from "./starter.js";
|
|
12
|
-
import { agentPaths, agentSetup
|
|
13
|
-
import { startDev } from "./dev.js";
|
|
12
|
+
import { agentPaths, agentSetup } from "./agent-setup.js";
|
|
13
|
+
import { detachDev, startDev } from "./dev.js";
|
|
14
14
|
import { ensureSdk, LocalRuntime, readDevLock, releaseDevLock, runCommand } from "./local-runtime.js";
|
|
15
15
|
import { CHECKS_FAILED_HINT, checksFailedMessage, runChecks } from "./check.js";
|
|
16
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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 `
|
|
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
|
|
81
|
-
" isomorph dev --app-root <path> [--reset]
|
|
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
|
|
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
|
-
"
|
|
90
|
-
|
|
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"
|
|
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
|
|
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", "
|
|
106
|
+
else if (!["connect", "login", "logout", "deploy", "integrations", ...LOCAL_COMMANDS, ...OPERATION_COMMANDS].includes(command)
|
|
139
107
|
|| (command === "connect" && (!connectUrl || connectUrl.startsWith("--")))
|
|
140
|
-
|| (["
|
|
141
|
-
|| (command === "
|
|
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")
|
|
@@ -209,7 +168,7 @@ else {
|
|
|
209
168
|
if (!report.passed) {
|
|
210
169
|
const message = checksFailedMessage(report);
|
|
211
170
|
exitAfterWriting(1, json
|
|
212
|
-
? `${JSON.stringify({ ...summaryEnvelope(report), status: "FAILED", error: { code: "CHECKS_FAILED", message, remediationHint: CHECKS_FAILED_HINT } })}\n`
|
|
171
|
+
? `${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
172
|
: `${message}\n${CHECKS_FAILED_HINT}\n`);
|
|
214
173
|
}
|
|
215
174
|
else
|
|
@@ -220,6 +179,10 @@ else {
|
|
|
220
179
|
progress(`Job ${result.name} completed for ${result.scheduledAt}.`);
|
|
221
180
|
emit(summaryEnvelope(result));
|
|
222
181
|
}
|
|
182
|
+
else if (detach) {
|
|
183
|
+
const started = await detachDev(target, { output: progress, reset });
|
|
184
|
+
emit(summaryEnvelope(started), `Isomorph dev is running: ${started.origin} (pid ${started.pid}). \`isomorph stop --app-root .\` stops it.\n`);
|
|
185
|
+
}
|
|
223
186
|
else {
|
|
224
187
|
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
188
|
let stopping = false;
|
|
@@ -234,6 +197,9 @@ else {
|
|
|
234
197
|
const config = resolveConfig(process.env, await loadConfig());
|
|
235
198
|
if (!config)
|
|
236
199
|
throw new CliError("CONFIG_REQUIRED", "Connect Isomorph first with `isomorph connect <work-email>`.");
|
|
200
|
+
// Before any network call: a CLI the company's platform no longer accepts is refused
|
|
201
|
+
// here, in under a second, with the install command, instead of by the pipeline.
|
|
202
|
+
assertCliCurrent(config);
|
|
237
203
|
const url = config.mcpUrl;
|
|
238
204
|
const tenant = config.tenantId;
|
|
239
205
|
if (command === "login") {
|
|
@@ -256,7 +222,6 @@ else {
|
|
|
256
222
|
let envelope;
|
|
257
223
|
if (command === "integrations") {
|
|
258
224
|
const target = appRoot(root);
|
|
259
|
-
await assertKitCurrent(target);
|
|
260
225
|
const governance = new GovernanceClient(config.apiUrl, token, tenant);
|
|
261
226
|
const result = subcommand === "status"
|
|
262
227
|
? await integrationsStatus(target, governance)
|
|
@@ -267,13 +232,9 @@ else {
|
|
|
267
232
|
if (!json)
|
|
268
233
|
progress(subcommand === "status" ? renderIntegrationsStatus(result) : subcommand === "catalog" ? renderIntegrationsCatalog(result) : renderRequest(result));
|
|
269
234
|
}
|
|
270
|
-
else if (command === "
|
|
271
|
-
//
|
|
272
|
-
const
|
|
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 } : {}) });
|
|
235
|
+
else if (command === "deploy") {
|
|
236
|
+
// 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.
|
|
237
|
+
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
238
|
envelope = operationEnvelope(result.result, result.operationRef, CLI_VERSION);
|
|
278
239
|
}
|
|
279
240
|
else {
|
|
@@ -282,27 +243,12 @@ else {
|
|
|
282
243
|
summary = wait ? await follow(client, operationRef, progress, waitOptions) : summarize(await fetchStatus(client, operationRef));
|
|
283
244
|
else if (command === "retry")
|
|
284
245
|
summary = await retryDeployment(client, operationRef, progress, { wait: !noWait, waitOptions });
|
|
285
|
-
else
|
|
286
|
-
//
|
|
287
|
-
if (root
|
|
288
|
-
|
|
289
|
-
|
|
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 } : {}) });
|
|
246
|
+
else {
|
|
247
|
+
// 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.
|
|
248
|
+
if (root)
|
|
249
|
+
await assertDeployReady(appRoot(root), new GovernanceClient(config.apiUrl, token, tenant), tenant, EMBEDDED_KIT_BUNDLE, "production", progress, await appCallsAi(appRoot(root)));
|
|
250
|
+
summary = await promoteToProduction(client, operationRef, progress, { wait: !noWait, waitOptions, confirmedTested: confirmTested });
|
|
293
251
|
}
|
|
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
252
|
envelope = operationEnvelope(summary, operationRef, CLI_VERSION);
|
|
307
253
|
}
|
|
308
254
|
process.stdout.write(json ? `${JSON.stringify(envelope)}\n` : renderSummary(envelope));
|
|
@@ -311,7 +257,7 @@ else {
|
|
|
311
257
|
}
|
|
312
258
|
catch (error) {
|
|
313
259
|
const envelope = failureEnvelope(error, CLI_VERSION);
|
|
314
|
-
exitAfterWriting(USAGE_REFUSALS.includes(envelope.error?.code ?? "") ? 2 : 1, json || command === "
|
|
260
|
+
exitAfterWriting(USAGE_REFUSALS.includes(envelope.error?.code ?? "") ? 2 : 1, json || command === "deploy" ? `${JSON.stringify(envelope)}\n` : "", renderFailure(envelope));
|
|
315
261
|
}
|
|
316
262
|
}
|
|
317
263
|
/** 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));
|