@isomorph.ai/cli 0.2.0
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 +55 -0
- package/dist/packages/harbour-cli/src/agent-setup.js +235 -0
- package/dist/packages/harbour-cli/src/app-schema.js +142 -0
- package/dist/packages/harbour-cli/src/auth.js +197 -0
- package/dist/packages/harbour-cli/src/check.js +317 -0
- package/dist/packages/harbour-cli/src/cli.js +321 -0
- package/dist/packages/harbour-cli/src/config.js +97 -0
- package/dist/packages/harbour-cli/src/dev.js +125 -0
- package/dist/packages/harbour-cli/src/forwarder.js +168 -0
- package/dist/packages/harbour-cli/src/integrations.js +385 -0
- package/dist/packages/harbour-cli/src/jobs.js +53 -0
- package/dist/packages/harbour-cli/src/kit-bundle.js +41 -0
- package/dist/packages/harbour-cli/src/kit-bundle.manifest.js +32 -0
- package/dist/packages/harbour-cli/src/kit.js +149 -0
- package/dist/packages/harbour-cli/src/local-runtime.js +526 -0
- package/dist/packages/harbour-cli/src/operations.js +382 -0
- package/dist/packages/harbour-cli/src/output.js +273 -0
- package/dist/packages/harbour-cli/src/productionise.js +508 -0
- package/dist/packages/harbour-cli/src/remote-mcp-client.js +121 -0
- package/dist/packages/harbour-cli/src/retained-checks.js +435 -0
- package/dist/packages/harbour-cli/src/source-inventory.js +239 -0
- package/dist/packages/harbour-cli/src/starter.js +557 -0
- package/dist/packages/harbour-cli/src/upload.js +163 -0
- package/dist/packages/harbour-cli/src/version.js +1 -0
- package/dist/src/analyzer.js +685 -0
- package/dist/src/contracts.js +82 -0
- package/dist/src/digest.js +26 -0
- package/dist/src/secret-paths.js +38 -0
- package/dist/src/source-intake.js +125 -0
- package/package.json +43 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { RemoteMcpClient } from "./remote-mcp-client.js";
|
|
3
|
+
import { appCallsAi, productionise } from "./productionise.js";
|
|
4
|
+
import { confirmAudience, confirmProfile, continueCommand, dismissSecret, fetchStatus, follow, getAppSetup, listSecrets, promoteToProduction, readSecretFromStdin, readSecretFromTerminal, retryDeployment, setSecret, summarize } from "./operations.js";
|
|
5
|
+
import { CliError, failureEnvelope, operationEnvelope, renderFailure, renderSummary } from "./output.js";
|
|
6
|
+
import { CLI_VERSION } from "./version.js";
|
|
7
|
+
import { connectedAccount, login, logout, refreshStoredToken } from "./auth.js";
|
|
8
|
+
import { connect, loadConfig, resolveConfig } from "./config.js";
|
|
9
|
+
import { EMBEDDED_KIT_BUNDLE } from "./kit-bundle.js";
|
|
10
|
+
import { appRoot, assertKitCurrent, readKitLock, requestResourceName } from "./kit.js";
|
|
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";
|
|
15
|
+
import { runChecks } from "./check.js";
|
|
16
|
+
import { assertAiReady, GovernanceClient, groupGrants, IDENTITY_WORDS, integrationsCatalog, integrationsStatus, renderGrantGroup, renderIntegrationsCatalog, requestIntegrations } from "./integrations.js";
|
|
17
|
+
import { runJob } from "./jobs.js";
|
|
18
|
+
const args = process.argv.slice(2);
|
|
19
|
+
const command = args[0];
|
|
20
|
+
const connectUrl = args[1];
|
|
21
|
+
const root = optionValue("--app-root");
|
|
22
|
+
const operationRef = optionValue("--operation");
|
|
23
|
+
const json = args.includes("--json");
|
|
24
|
+
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
|
+
const personal = args.includes("--personal");
|
|
30
|
+
const valueStdin = args.includes("--value-stdin");
|
|
31
|
+
const subcommand = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
|
|
32
|
+
const wait = args.includes("--wait");
|
|
33
|
+
const reset = args.includes("--reset");
|
|
34
|
+
const upgrade = args.includes("--upgrade");
|
|
35
|
+
const testIntegrations = args.includes("--integrations");
|
|
36
|
+
const reason = optionValue("--reason");
|
|
37
|
+
const operations = optionValue("--operations");
|
|
38
|
+
const scheduledAt = optionValue("--scheduled-at");
|
|
39
|
+
const realJob = args.includes("--real");
|
|
40
|
+
/** Seconds `productionise`, `status --wait`, `retry` and `promote` follow the operation before reporting it as still running (default: 30 minutes). */
|
|
41
|
+
const maxWaitSeconds = args.includes("--max-wait") ? Number(optionValue("--max-wait")) : undefined;
|
|
42
|
+
const waitOptions = maxWaitSeconds ? { maxWaitMs: maxWaitSeconds * 1000 } : {};
|
|
43
|
+
const includePaths = [];
|
|
44
|
+
let optionError;
|
|
45
|
+
if (maxWaitSeconds !== undefined && !(Number.isInteger(maxWaitSeconds) && maxWaitSeconds > 0))
|
|
46
|
+
optionError = "--max-wait takes a whole number of seconds.";
|
|
47
|
+
if (command === "productionise" && ((profileName && !profileDescription) || (!profileName && profileDescription) || (audienceEmails && (!profileName || !profileDescription))))
|
|
48
|
+
optionError = "Productionise takes --name and --description together; --emails may be added only with both.";
|
|
49
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
50
|
+
if (args[index] === "--include") {
|
|
51
|
+
const value = args[index + 1];
|
|
52
|
+
if (!value || value.startsWith("--")) {
|
|
53
|
+
optionError = "--include requires a relative file or directory path.";
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
includePaths.push(value);
|
|
57
|
+
index += 1;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const explicitToken = process.env.ISOMORPH_TOKEN?.trim() ?? "";
|
|
61
|
+
const usage = [
|
|
62
|
+
"Usage:",
|
|
63
|
+
" isomorph connect <work-email-or-start-url>",
|
|
64
|
+
" isomorph login | logout",
|
|
65
|
+
" isomorph productionise --app-root <path> [--include <relative-path>]... [--name <text> --description <text> [--emails a@co,b@co]] [--no-wait] [--max-wait <seconds>] [--json]",
|
|
66
|
+
" isomorph status --operation <reference> [--wait] [--max-wait <seconds>] [--json]",
|
|
67
|
+
" isomorph retry --operation <reference> [--no-wait] [--max-wait <seconds>] [--json]",
|
|
68
|
+
" isomorph promote --operation <reference> [--app-root <path>] [--no-wait] [--max-wait <seconds>] [--json] (--app-root: also confirm the company's AI setup for production when the app calls governed AI)",
|
|
69
|
+
" isomorph setup --operation <reference> [--json] what the app still needs (name, audience, secrets)",
|
|
70
|
+
" isomorph profile --operation <reference> [--name <text>] [--description <text>] [--json]",
|
|
71
|
+
" isomorph audience --operation <reference> [--emails a@co,b@co] [--json] (no --emails = only you)",
|
|
72
|
+
" isomorph secrets list --operation <reference> [--json]",
|
|
73
|
+
" isomorph secrets set --operation <reference> --name <NAME> [--personal] [--value-stdin]",
|
|
74
|
+
" isomorph secrets dismiss --operation <reference> --name <NAME>",
|
|
75
|
+
" isomorph agent-setup [--json] install the plain-English Isomorph skill for Claude Code (~/.claude/skills/isomorph) and Codex (~/.codex/skills/isomorph)",
|
|
76
|
+
" 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)",
|
|
77
|
+
" isomorph dev --app-root <path> [--reset] run the app locally on one loopback origin (--reset deletes this app's local data)",
|
|
78
|
+
" isomorph stop --app-root <path> stop this app's local services, keeping its database and files",
|
|
79
|
+
" 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)",
|
|
80
|
+
" isomorph jobs run <name> --app-root <path> [--scheduled-at <UTC>] [--real] [--json] run one scheduled job now (local fixtures; --real uses approved company access)",
|
|
81
|
+
" 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)",
|
|
82
|
+
" isomorph integrations status --app-root <path> [--json]",
|
|
83
|
+
" 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)",
|
|
84
|
+
"Run `isomorph connect <work-email-or-start-url>` once, then sign in when Isomorph asks.",
|
|
85
|
+
"productionise saves the app, follows its deployment, and prints the protected preview link; promote sends a tested preview to production.",
|
|
86
|
+
`--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.`,
|
|
87
|
+
"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.",
|
|
88
|
+
"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.",
|
|
89
|
+
""
|
|
90
|
+
].join("\n");
|
|
91
|
+
const OPERATION_COMMANDS = ["status", "retry", "promote", "setup", "profile", "audience", "secrets"];
|
|
92
|
+
const LOCAL_COMMANDS = ["init", "dev", "stop", "check", "jobs"];
|
|
93
|
+
const progress = (message) => { process.stderr.write(`${message}\n`); };
|
|
94
|
+
/** Envelope for commands that start no Isomorph operation (local kit commands, integrations). */
|
|
95
|
+
const summaryEnvelope = (result) => ({ schema: "isomorph.cli-result/1.0", cliVersion: CLI_VERSION, status: "SUCCEEDED", operationStarted: false, result });
|
|
96
|
+
const emit = (value) => { process.stdout.write(json ? `${JSON.stringify(value)}\n` : renderSummary(value)); process.exitCode = 0; };
|
|
97
|
+
if (command === "--version" || command === "version") {
|
|
98
|
+
process.stdout.write(`${CLI_VERSION}\n`);
|
|
99
|
+
}
|
|
100
|
+
else if (!command || command === "help" || command === "--help" || args.includes("-h")) {
|
|
101
|
+
process.stdout.write(usage);
|
|
102
|
+
}
|
|
103
|
+
else if (command === "agent-setup") {
|
|
104
|
+
const result = await agentSetup(process.env);
|
|
105
|
+
for (const state of ["created", "updated", "kept", "removed"])
|
|
106
|
+
for (const path of result[state])
|
|
107
|
+
progress(`${state.padEnd(7)} ${path}`);
|
|
108
|
+
if (result.removed.length)
|
|
109
|
+
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.");
|
|
110
|
+
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.");
|
|
111
|
+
// Which `isomorph` the next command will run. This command is usually reached through
|
|
112
|
+
// `npx …@stable`, which tells you nothing about the CLI that does the actual work.
|
|
113
|
+
if (result.cli)
|
|
114
|
+
for (const line of cliVersionLines(result.cli))
|
|
115
|
+
progress(line);
|
|
116
|
+
emit(summaryEnvelope({ ...result, paths: agentPaths(process.env) }));
|
|
117
|
+
}
|
|
118
|
+
else if (!["connect", "login", "logout", "productionise", "integrations", ...LOCAL_COMMANDS, ...OPERATION_COMMANDS].includes(command)
|
|
119
|
+
|| (command === "connect" && (!connectUrl || connectUrl.startsWith("--")))
|
|
120
|
+
|| (["productionise", "status", "retry", "promote"].includes(command) && optionError)
|
|
121
|
+
|| (command === "productionise" && !root)
|
|
122
|
+
|| (LOCAL_COMMANDS.includes(command) && !root)
|
|
123
|
+
|| (command === "jobs" && (subcommand !== "run" || !args[2] || args[2].startsWith("--")))
|
|
124
|
+
|| (command === "integrations" && (!root || !subcommand || !["request", "status", "catalog"].includes(subcommand) || (subcommand === "request" && (!args[2] || args[2].startsWith("--")))))
|
|
125
|
+
|| (OPERATION_COMMANDS.includes(command) && !operationRef)
|
|
126
|
+
|| (command === "secrets" && (!subcommand || !["list", "set", "dismiss"].includes(subcommand) || (subcommand !== "list" && !secretName)))) {
|
|
127
|
+
if (optionError)
|
|
128
|
+
process.stderr.write(`${optionError}\n`);
|
|
129
|
+
process.stderr.write(usage);
|
|
130
|
+
process.exit(2);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
try {
|
|
134
|
+
if (command === "connect") {
|
|
135
|
+
const saved = await connect(connectUrl);
|
|
136
|
+
process.stdout.write(`Isomorph is connected for ${saved.tenantId}.\n`);
|
|
137
|
+
process.exitCode = 0;
|
|
138
|
+
}
|
|
139
|
+
else if (LOCAL_COMMANDS.includes(command)) {
|
|
140
|
+
// Local commands run before the company config/login requirement: the base app needs neither.
|
|
141
|
+
const bundle = EMBEDDED_KIT_BUNDLE;
|
|
142
|
+
const target = appRoot(root);
|
|
143
|
+
// `init --upgrade` is the one command that moves an app from the old kit layout (initKit decides); every other one refuses it.
|
|
144
|
+
if (command !== "init")
|
|
145
|
+
await assertKitCurrent(target);
|
|
146
|
+
const config = resolveConfig(process.env, await loadConfig());
|
|
147
|
+
const companyToken = async () => config ? (explicitToken || await refreshStoredToken(config.mcpUrl, config.tenantId)) : undefined;
|
|
148
|
+
if (command === "init") {
|
|
149
|
+
const result = await initKit(target, bundle, { upgrade, tenantId: config?.tenantId, env: process.env });
|
|
150
|
+
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)`))])
|
|
151
|
+
progress(line);
|
|
152
|
+
// Silent while the installed CLI is current; loud when `init` came from a
|
|
153
|
+
// newer package than the CLI that will run `dev`, `check` and `productionise`.
|
|
154
|
+
if (result.agents.cli?.upgradeRequired)
|
|
155
|
+
for (const line of cliVersionLines(result.agents.cli))
|
|
156
|
+
progress(line);
|
|
157
|
+
// The SDK is not on the public registry: init installs the pinned tarball (and the app's other dependencies) itself.
|
|
158
|
+
const sdk = await ensureSdk(target, bundle, process.env, runCommand, progress);
|
|
159
|
+
if (sdk === "missing")
|
|
160
|
+
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\`.`);
|
|
161
|
+
progress(result.mode === "starter" ? "Starter created. Next: `isomorph dev --app-root <path>`. Codex reads the AGENTS.md block; Claude Code reads .claude/skills/isomorph-kit/SKILL.md." : upgrade ? (result.bundleChanges.length ? "Kit bundle upgraded; running checks." : "Kit bundle already current; running checks.") : "Kit files added; existing files were kept.");
|
|
162
|
+
if (upgrade) {
|
|
163
|
+
const report = await runChecks(target, { run: runCommand, bundle, output: progress });
|
|
164
|
+
emit(summaryEnvelope({ ...result, report }));
|
|
165
|
+
}
|
|
166
|
+
else
|
|
167
|
+
emit(summaryEnvelope(result));
|
|
168
|
+
}
|
|
169
|
+
else if (command === "stop") {
|
|
170
|
+
const runtime = new LocalRuntime(target);
|
|
171
|
+
const lock = await readDevLock(target);
|
|
172
|
+
if (lock && lock.pid !== process.pid) {
|
|
173
|
+
try {
|
|
174
|
+
process.kill(lock.pid, "SIGTERM");
|
|
175
|
+
}
|
|
176
|
+
catch { /* already gone */ }
|
|
177
|
+
}
|
|
178
|
+
const stopped = await runtime.stop();
|
|
179
|
+
await releaseDevLock(target);
|
|
180
|
+
emit(summaryEnvelope({ project: runtime.project, stopped: stopped.stopped, volumesRetained: true }));
|
|
181
|
+
}
|
|
182
|
+
else if (command === "check") {
|
|
183
|
+
let governance;
|
|
184
|
+
if (testIntegrations) {
|
|
185
|
+
if (!config)
|
|
186
|
+
throw new CliError("CONFIG_REQUIRED", "`--integrations` needs a company connection: run `isomorph connect <work-email-or-start-url>`.");
|
|
187
|
+
const token = await companyToken();
|
|
188
|
+
if (!token)
|
|
189
|
+
throw new CliError("AUTH_REQUIRED", "`--integrations` needs an Isomorph sign-in: run `isomorph login`.");
|
|
190
|
+
governance = new GovernanceClient(config.apiUrl, token, config.tenantId);
|
|
191
|
+
}
|
|
192
|
+
const report = await runChecks(target, { run: runCommand, bundle, output: progress, governance });
|
|
193
|
+
if (!report.passed) {
|
|
194
|
+
process.stdout.write(json ? `${JSON.stringify({ ...summaryEnvelope(report), status: "FAILED", error: { code: "CHECKS_FAILED", message: "One or more checks failed; see .isomorph/local/check-report.json." } })}\n` : "Checks failed; see .isomorph/local/check-report.json.\n");
|
|
195
|
+
process.exitCode = 1;
|
|
196
|
+
}
|
|
197
|
+
else
|
|
198
|
+
emit(summaryEnvelope(report));
|
|
199
|
+
}
|
|
200
|
+
else if (command === "jobs") {
|
|
201
|
+
const result = await runJob(target, args[2], scheduledAt, runCommand, realJob);
|
|
202
|
+
progress(`Job ${result.name} completed for ${result.scheduledAt}.`);
|
|
203
|
+
emit(summaryEnvelope(result));
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
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 });
|
|
207
|
+
let stopping = false;
|
|
208
|
+
const shutdown = () => { if (stopping)
|
|
209
|
+
return; stopping = true; progress("Stopping local Isomorph services (data kept)."); void started.stop().finally(() => process.exit(0)); };
|
|
210
|
+
process.on("SIGINT", shutdown);
|
|
211
|
+
process.on("SIGTERM", shutdown);
|
|
212
|
+
await new Promise(() => { });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
const config = resolveConfig(process.env, await loadConfig());
|
|
217
|
+
if (!config)
|
|
218
|
+
throw new CliError("CONFIG_REQUIRED", "Connect Isomorph first with `isomorph connect <work-email-or-start-url>`.");
|
|
219
|
+
const url = config.mcpUrl;
|
|
220
|
+
const tenant = config.tenantId;
|
|
221
|
+
if (command === "login") {
|
|
222
|
+
await login(url, tenant, progress);
|
|
223
|
+
process.stdout.write("Isomorph sign-in complete.\n");
|
|
224
|
+
process.exitCode = 0;
|
|
225
|
+
}
|
|
226
|
+
else if (command === "logout") {
|
|
227
|
+
await logout(url, tenant);
|
|
228
|
+
process.stdout.write("Isomorph sign-in removed from this device.\n");
|
|
229
|
+
process.exitCode = 0;
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
const resolveToken = async () => explicitToken || await refreshStoredToken(url, tenant);
|
|
233
|
+
const token = await resolveToken();
|
|
234
|
+
if (!token)
|
|
235
|
+
throw new CliError("AUTH_REQUIRED", "Please sign in to Isomorph with `isomorph login`.");
|
|
236
|
+
// The client re-resolves per request so a token rotated by a sibling process mid-poll is picked up.
|
|
237
|
+
const client = new RemoteMcpClient(url, resolveToken, tenant);
|
|
238
|
+
let envelope;
|
|
239
|
+
if (command === "integrations") {
|
|
240
|
+
const target = appRoot(root);
|
|
241
|
+
await assertKitCurrent(target);
|
|
242
|
+
const governance = new GovernanceClient(config.apiUrl, token, tenant);
|
|
243
|
+
const result = subcommand === "status"
|
|
244
|
+
? await integrationsStatus(target, governance)
|
|
245
|
+
: subcommand === "catalog"
|
|
246
|
+
? await integrationsCatalog(governance)
|
|
247
|
+
: await requestIntegrations(target, governance, tenant, EMBEDDED_KIT_BUNDLE, { connection: args[2], ...(reason ? { reason } : {}), operations: operations?.split(",").map(value => value.trim()).filter(Boolean) });
|
|
248
|
+
envelope = summaryEnvelope(result);
|
|
249
|
+
if (!json)
|
|
250
|
+
progress(subcommand === "status" ? renderIntegrationsStatus(result) : subcommand === "catalog" ? renderIntegrationsCatalog(result) : renderRequest(result));
|
|
251
|
+
}
|
|
252
|
+
else if (command === "productionise") {
|
|
253
|
+
// 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.
|
|
254
|
+
const confirmedAppSetup = profileName && profileDescription
|
|
255
|
+
? { displayName: profileName, description: profileDescription, audienceEmails: audienceEmails ? audienceEmails.split(",").map(value => value.trim()).filter(Boolean) : [] }
|
|
256
|
+
: undefined;
|
|
257
|
+
await assertKitCurrent(appRoot(root));
|
|
258
|
+
const result = await productionise(root, client, progress, tenant, includePaths, { waitForDeployment: !noWait, waitOptions, integrations: { governance: new GovernanceClient(config.apiUrl, token, tenant), bundle: EMBEDDED_KIT_BUNDLE }, ...(confirmedAppSetup ? { confirmedAppSetup } : {}) });
|
|
259
|
+
envelope = operationEnvelope(result.result, result.operationRef, CLI_VERSION);
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
let summary;
|
|
263
|
+
if (command === "status")
|
|
264
|
+
summary = wait ? await follow(client, operationRef, progress, waitOptions) : summarize(await fetchStatus(client, operationRef));
|
|
265
|
+
else if (command === "retry")
|
|
266
|
+
summary = await retryDeployment(client, operationRef, progress, { wait: !noWait, waitOptions });
|
|
267
|
+
else if (command === "promote") {
|
|
268
|
+
// The production PLAN blocks on the same AI setup plus enforce mode; with the app root in hand, ask before promoting.
|
|
269
|
+
if (root && config.apiUrl && await appCallsAi(appRoot(root))) {
|
|
270
|
+
const lock = await readKitLock(appRoot(root));
|
|
271
|
+
if (lock?.appId)
|
|
272
|
+
await assertAiReady(lock.appId, "production", new GovernanceClient(config.apiUrl, token, tenant), progress);
|
|
273
|
+
}
|
|
274
|
+
summary = await promoteToProduction(client, operationRef, progress, { wait: !noWait, waitOptions });
|
|
275
|
+
}
|
|
276
|
+
else if (command === "setup")
|
|
277
|
+
summary = { setup: await getAppSetup(client, operationRef) };
|
|
278
|
+
else if (command === "profile")
|
|
279
|
+
summary = { setup: await confirmProfile(client, operationRef, { ...(profileName ? { displayName: profileName } : {}), ...(profileDescription ? { description: profileDescription } : {}) }, progress) };
|
|
280
|
+
else if (command === "audience")
|
|
281
|
+
summary = { setup: await confirmAudience(client, operationRef, audienceEmails ? audienceEmails.split(",").map(value => value.trim()).filter(Boolean) : [], progress) };
|
|
282
|
+
else if (subcommand === "list")
|
|
283
|
+
summary = { secrets: await listSecrets(client, operationRef) };
|
|
284
|
+
else if (subcommand === "set")
|
|
285
|
+
summary = await setSecret(client, operationRef, { name: secretName, ...(personal ? { personal: true } : {}), readValue: valueStdin ? readSecretFromStdin : () => readSecretFromTerminal(`Value for ${secretName} (not shown): `) }, progress);
|
|
286
|
+
else
|
|
287
|
+
summary = await dismissSecret(client, operationRef, secretName, progress);
|
|
288
|
+
envelope = operationEnvelope(summary, operationRef, CLI_VERSION);
|
|
289
|
+
}
|
|
290
|
+
process.stdout.write(json ? `${JSON.stringify(envelope)}\n` : renderSummary(envelope));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
const envelope = failureEnvelope(error, CLI_VERSION);
|
|
296
|
+
process.stderr.write(renderFailure(envelope));
|
|
297
|
+
if (json || command === "productionise")
|
|
298
|
+
process.stdout.write(`${JSON.stringify(envelope)}\n`);
|
|
299
|
+
// Exit 2, like a usage error: nothing started and the fix is a command the maker runs.
|
|
300
|
+
process.exit(envelope.error?.code === "INTEGRATIONS_NOT_READY" || envelope.error?.code === "AI_NOT_READY" ? 2 : 1);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/** One line per (connection, identity): the three lanes are one approval, so they are read together. */
|
|
304
|
+
function renderIntegrationsStatus(status) {
|
|
305
|
+
if (!status.linked)
|
|
306
|
+
return "This app is not linked yet; `isomorph dev` (signed in) or `isomorph integrations request` links it.";
|
|
307
|
+
const lines = [`App ${status.appId}`, ...groupGrants(status.grants).flatMap(group => renderGrantGroup(group).map(line => ` ${line}`))];
|
|
308
|
+
for (const request of status.requests)
|
|
309
|
+
lines.push(` request ${request.requestId}: ${request.state}${request.reason ? ` (${request.reason})` : ""}`);
|
|
310
|
+
if (!status.grants.length && !status.requests.length)
|
|
311
|
+
lines.push(" no grants or requests yet");
|
|
312
|
+
return lines.join("\n");
|
|
313
|
+
}
|
|
314
|
+
function renderRequest(result) {
|
|
315
|
+
return [`App ${result.appId}: ${result.connection}`, ...result.requests.map(item => ` ${IDENTITY_WORDS[item.identityMode]} — ${item.operations.join(", ")} on ${item.resources.map(requestResourceName).join(", ")}${item.presentation ? ` (posts as "${item.presentation.displayName}")` : ""}: ${item.state} — ${item.summary}`)].join("\n");
|
|
316
|
+
}
|
|
317
|
+
function optionValue(flag) {
|
|
318
|
+
const index = args.indexOf(flag);
|
|
319
|
+
const value = index >= 0 ? args[index + 1] : undefined;
|
|
320
|
+
return value && !value.startsWith("--") ? value : undefined;
|
|
321
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
export function configPath(env = process.env) {
|
|
5
|
+
return join(env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config"), "isomorph", "config.json");
|
|
6
|
+
}
|
|
7
|
+
export async function loadConfig(path = configPath()) {
|
|
8
|
+
try {
|
|
9
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
10
|
+
return validConfig(parsed) ? parsed : undefined;
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
if (isMissing(error))
|
|
14
|
+
return undefined;
|
|
15
|
+
throw new Error("Isomorph could not read its saved connection. Run `isomorph connect <start-url>` again.");
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export async function saveConfig(config, path = configPath()) {
|
|
19
|
+
if (!validConfig(config))
|
|
20
|
+
throw new Error("Isomorph received an incomplete connection profile.");
|
|
21
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
22
|
+
await chmod(dirname(path), 0o700);
|
|
23
|
+
await writeFile(path, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
24
|
+
await chmod(path, 0o600);
|
|
25
|
+
}
|
|
26
|
+
export async function connect(startUrl, path = configPath()) {
|
|
27
|
+
const input = startUrl.trim();
|
|
28
|
+
const email = /^[^\s@]+@([a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+)$/i.exec(input);
|
|
29
|
+
const guessedTenant = email?.[1]?.split(".")[0]?.toLowerCase();
|
|
30
|
+
let url;
|
|
31
|
+
try {
|
|
32
|
+
url = new URL(guessedTenant ? `https://platform.isomorph.ai/start/${guessedTenant}.json` : input);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
throw new Error("Enter your work email or the company setup link from your Isomorph start page: `isomorph connect <work-email-or-start-url>`.");
|
|
36
|
+
}
|
|
37
|
+
if (!/^https?:$/.test(url.protocol))
|
|
38
|
+
throw new Error("The Isomorph connection URL must be an HTTPS URL.");
|
|
39
|
+
if (!url.pathname.endsWith(".json")) {
|
|
40
|
+
url.pathname = `${url.pathname.replace(/\/$/, "")}.json`;
|
|
41
|
+
}
|
|
42
|
+
let config;
|
|
43
|
+
try {
|
|
44
|
+
const response = await fetch(url, { headers: { accept: "application/json" }, redirect: "error" });
|
|
45
|
+
if (!response.ok)
|
|
46
|
+
throw new Error("Isomorph could not read this company connection profile.");
|
|
47
|
+
const body = await response.json();
|
|
48
|
+
if (!isRecord(body) || body.schema !== "harbour.tenant-start/1.0")
|
|
49
|
+
throw new Error("This is not an Isomorph connection profile.");
|
|
50
|
+
config = {
|
|
51
|
+
schema: "isomorph.cli-config/1.0",
|
|
52
|
+
tenantId: String(body.tenantId ?? ""),
|
|
53
|
+
mcpUrl: String(body.mcpUrl ?? ""),
|
|
54
|
+
...(typeof body.uploadUrl === "string" ? { uploadUrl: body.uploadUrl } : {}),
|
|
55
|
+
...(typeof body.apiUrl === "string" ? { apiUrl: body.apiUrl } : {})
|
|
56
|
+
};
|
|
57
|
+
if (!validConfig(config) || (guessedTenant && config.tenantId !== guessedTenant))
|
|
58
|
+
throw new Error("This is not the expected Isomorph connection profile.");
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (guessedTenant)
|
|
62
|
+
throw new Error("Isomorph could not find your company from that email. Ask for the company setup link from your Isomorph start page, then run `isomorph connect <company-start-url>`.");
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
await saveConfig(config, path);
|
|
66
|
+
return config;
|
|
67
|
+
}
|
|
68
|
+
export function resolveConfig(env, saved) {
|
|
69
|
+
const mcpUrl = env.ISOMORPH_MCP_URL?.trim() || saved?.mcpUrl;
|
|
70
|
+
const tenantId = env.ISOMORPH_TENANT?.trim() || saved?.tenantId;
|
|
71
|
+
if (!mcpUrl || !tenantId)
|
|
72
|
+
return undefined;
|
|
73
|
+
return { schema: "isomorph.cli-config/1.0", tenantId, mcpUrl, ...(saved?.uploadUrl ? { uploadUrl: saved.uploadUrl } : {}), apiUrl: env.ISOMORPH_API_URL?.trim() || saved?.apiUrl || apiUrlFromMcpUrl(mcpUrl) };
|
|
74
|
+
}
|
|
75
|
+
/** `https://host/stage/mcp` -> `https://host/stage`: the dev routes live beside the MCP endpoint. */
|
|
76
|
+
export function apiUrlFromMcpUrl(mcpUrl) {
|
|
77
|
+
const url = new URL(mcpUrl);
|
|
78
|
+
url.pathname = url.pathname.replace(/\/mcp\/?$/, "");
|
|
79
|
+
url.search = "";
|
|
80
|
+
return url.toString().replace(/\/$/, "");
|
|
81
|
+
}
|
|
82
|
+
function validConfig(value) {
|
|
83
|
+
return Boolean(isRecord(value)
|
|
84
|
+
&& value.schema === "isomorph.cli-config/1.0"
|
|
85
|
+
&& typeof value.tenantId === "string"
|
|
86
|
+
&& value.tenantId.length > 0
|
|
87
|
+
&& typeof value.mcpUrl === "string"
|
|
88
|
+
&& /^https?:\/\//.test(value.mcpUrl)
|
|
89
|
+
&& (value.uploadUrl === undefined || typeof value.uploadUrl === "string")
|
|
90
|
+
&& (value.apiUrl === undefined || (typeof value.apiUrl === "string" && /^https?:\/\//.test(value.apiUrl))));
|
|
91
|
+
}
|
|
92
|
+
function isRecord(value) {
|
|
93
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
94
|
+
}
|
|
95
|
+
function isMissing(error) {
|
|
96
|
+
return Boolean(error && typeof error === "object" && error.code === "ENOENT");
|
|
97
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createForwarder } from "./forwarder.js";
|
|
3
|
+
import { readKitLock } from "./kit.js";
|
|
4
|
+
import { GovernanceClient, ensureLinkedApp, fileDeclaredRequests, IDENTITY_WORDS, renderGrantGroup } from "./integrations.js";
|
|
5
|
+
import { acquireDevLock, allocatePorts, ensureSdk, LocalRuntime, nodePackageCommand, releaseDevLock, runCommand } from "./local-runtime.js";
|
|
6
|
+
import { CliError, safeError } from "./output.js";
|
|
7
|
+
/**
|
|
8
|
+
* Signed in at startup: link the app and file the access request for every
|
|
9
|
+
* declared connection — one per connection, approved by IT once for every
|
|
10
|
+
* environment; the same filing `productionise` does — so IT's queue holds the
|
|
11
|
+
* ask from the first run and no command stands between the builder and the
|
|
12
|
+
* approval. Returns the banner lines saying where each connection stands.
|
|
13
|
+
* Never throws: `isomorph dev` runs without company access.
|
|
14
|
+
*/
|
|
15
|
+
export async function companyAccessAtStartup(root, client, tenantId, bundle) {
|
|
16
|
+
try {
|
|
17
|
+
const filed = await fileDeclaredRequests(root, client, tenantId, bundle);
|
|
18
|
+
// Nothing declared: still link, so governed AI has the app's identity from its first call.
|
|
19
|
+
if (!filed)
|
|
20
|
+
return { appId: await ensureLinkedApp(root, client, tenantId, bundle), lines: [] };
|
|
21
|
+
const lines = filed.groups.flatMap(group => renderGrantGroup(group));
|
|
22
|
+
for (const item of filed.requests)
|
|
23
|
+
if (item.error)
|
|
24
|
+
lines.push(`${item.connection} [${IDENTITY_WORDS[item.identityMode]}] not requested: ${item.error.message}`);
|
|
25
|
+
return { appId: filed.appId, lines };
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
return { lines: [`Company access could not be checked (${safeError(error).message}); the app runs without it until the next start.`] };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Starts the kit-managed Postgres and App Gateway, applies migrations, then
|
|
33
|
+
* starts Vite and the loopback origin. Stopping retains the app's local data.
|
|
34
|
+
*/
|
|
35
|
+
export async function startDev(root, options) {
|
|
36
|
+
const run = options.run ?? runCommand;
|
|
37
|
+
const env = options.env ?? process.env;
|
|
38
|
+
const runtime = new LocalRuntime(root, run);
|
|
39
|
+
if (options.reset)
|
|
40
|
+
await runtime.reset(options.output);
|
|
41
|
+
const ports = await allocatePorts();
|
|
42
|
+
await acquireDevLock(root, ports);
|
|
43
|
+
let vite;
|
|
44
|
+
let forwarder;
|
|
45
|
+
const stop = async () => {
|
|
46
|
+
forwarder?.close();
|
|
47
|
+
vite?.kill("SIGTERM");
|
|
48
|
+
await runtime.stop();
|
|
49
|
+
await releaseDevLock(root);
|
|
50
|
+
};
|
|
51
|
+
try {
|
|
52
|
+
const sdk = await ensureSdk(root, options.bundle, env, run, options.output);
|
|
53
|
+
if (sdk === "missing")
|
|
54
|
+
options.output(`The kit SDK is not installed: set ISOMORPH_KIT_SDK_TARBALL to the bundle's ${options.bundle.sdk.package} tarball (or use a bundle with sdk.url).`);
|
|
55
|
+
await runtime.writeFiles(options.bundle, ports);
|
|
56
|
+
options.output("Preparing the kit-managed native runtime (public registry, no login).");
|
|
57
|
+
await runtime.pull(options.bundle);
|
|
58
|
+
options.output("Starting local Isomorph services (postgres, storage, one Isomorph gateway with the session identities, fixtures and realtime relay).");
|
|
59
|
+
await runtime.up(options.output);
|
|
60
|
+
// The gateway writes the session env and the app's realtime outbox
|
|
61
|
+
// migration together once it is serving, so waiting for the session also
|
|
62
|
+
// waits for the SQL and for the gateway.
|
|
63
|
+
const session = await runtime.sessionEnv();
|
|
64
|
+
const applied = await runtime.migrate();
|
|
65
|
+
options.output(`Applied ${applied.length} migration file(s).`);
|
|
66
|
+
const watched = await runtime.installRealtimeOutbox();
|
|
67
|
+
// The gateway's in-process relay starts publishing as soon as the outbox
|
|
68
|
+
// table exists; nothing to create or restart.
|
|
69
|
+
if (watched > 0)
|
|
70
|
+
options.output(`Installed the Isomorph realtime outbox for ${watched} table(s) (isomorph.realtime change events).`);
|
|
71
|
+
const origin = `http://127.0.0.1:${ports.origin}`;
|
|
72
|
+
const npm = nodePackageCommand("npm", ["run", "dev"]);
|
|
73
|
+
vite = spawn(npm.command, npm.args, { cwd: root, env: { ...env, ISOMORPH_LOCAL_ORIGIN: origin, ISOMORPH_VITE_PORT: String(ports.vite) }, stdio: ["ignore", "inherit", "inherit"] });
|
|
74
|
+
vite.on("error", () => options.output("Vite could not be started; is npm installed and `npm install` done?"));
|
|
75
|
+
const company = options.company;
|
|
76
|
+
let warnedAuth = false;
|
|
77
|
+
let lockAppId = (await readKitLock(root))?.appId || undefined;
|
|
78
|
+
forwarder = createForwarder({
|
|
79
|
+
port: ports.origin, vitePort: ports.vite, gatewayPort: ports.gateway, identityToken: session.ISOMORPH_IDENTITY_CONTEXT,
|
|
80
|
+
apiUrl: company?.apiUrl ?? "", tenantId: company?.tenantId ?? "",
|
|
81
|
+
appId: () => lockAppId,
|
|
82
|
+
accessToken: async () => company ? company.accessToken() : undefined,
|
|
83
|
+
linkApp: async () => {
|
|
84
|
+
const token = company ? await company.accessToken() : undefined;
|
|
85
|
+
if (!company || !token)
|
|
86
|
+
return undefined;
|
|
87
|
+
lockAppId = await ensureLinkedApp(root, new GovernanceClient(company.apiUrl, token, company.tenantId), company.tenantId, options.bundle);
|
|
88
|
+
return lockAppId;
|
|
89
|
+
},
|
|
90
|
+
onAuthRequired: () => { if (!warnedAuth) {
|
|
91
|
+
warnedAuth = true;
|
|
92
|
+
options.output("Company integrations need an Isomorph sign-in: run `isomorph login` (in another terminal) and retry in the app.");
|
|
93
|
+
} }
|
|
94
|
+
});
|
|
95
|
+
const refreshLock = setInterval(() => { void readKitLock(root).then(lock => { lockAppId = lock?.appId || undefined; }).catch(() => undefined); }, 5_000);
|
|
96
|
+
refreshLock.unref();
|
|
97
|
+
await new Promise((resolve, reject) => { forwarder.on("error", reject); forwarder.listen(ports.origin, "127.0.0.1", resolve); });
|
|
98
|
+
const token = company ? await company.accessToken().catch(() => undefined) : undefined;
|
|
99
|
+
const governance = company && token ? new GovernanceClient(company.apiUrl, token, company.tenantId) : undefined;
|
|
100
|
+
const account = company && token ? await company.account(token).catch(() => undefined) : undefined;
|
|
101
|
+
const linkedAtStart = lockAppId;
|
|
102
|
+
options.output([
|
|
103
|
+
"",
|
|
104
|
+
`Isomorph dev is running: ${origin}`,
|
|
105
|
+
` App identity (local session): ${session.HARBOUR_LOCAL_USER_EMAIL ?? "local-user@example.test"}; second user for cross-user checks: ${session.HARBOUR_LOCAL_SECOND_USER_EMAIL ?? "teammate@example.test"}`,
|
|
106
|
+
` Company account for integrations: ${account ?? (company ? "not signed in — run `isomorph login`" : "not connected — run `isomorph connect`")}`,
|
|
107
|
+
...(linkedAtStart ? [` Linked app: ${linkedAtStart}`] : governance ? [] : [" App not linked yet: the next `isomorph dev` after `isomorph login` links it and files its access requests (so does `isomorph integrations request`)."]),
|
|
108
|
+
" Ctrl-C or `isomorph stop` stops the services and keeps local data; `isomorph dev --reset` deletes it.",
|
|
109
|
+
""
|
|
110
|
+
].join("\n"));
|
|
111
|
+
// Signed in: link now and file the app-wide requests — after the link is out, so governance's round-trips never hold it — and say where each connection stands.
|
|
112
|
+
if (governance && company) {
|
|
113
|
+
const access = await companyAccessAtStartup(root, governance, company.tenantId, options.bundle);
|
|
114
|
+
lockAppId = access.appId ?? lockAppId;
|
|
115
|
+
const lines = [...(access.appId && !linkedAtStart ? [`Linked app: ${access.appId}`] : []), ...access.lines];
|
|
116
|
+
if (lines.length)
|
|
117
|
+
options.output(lines.map(line => ` ${line}`).join("\n"));
|
|
118
|
+
}
|
|
119
|
+
return { origin, stop };
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
await stop().catch(() => undefined);
|
|
123
|
+
throw error instanceof CliError ? error : new CliError("DEV_FAILED", error instanceof Error ? error.message : "isomorph dev could not start.");
|
|
124
|
+
}
|
|
125
|
+
}
|