@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.
Files changed (30) hide show
  1. package/README.md +55 -0
  2. package/dist/packages/harbour-cli/src/agent-setup.js +235 -0
  3. package/dist/packages/harbour-cli/src/app-schema.js +142 -0
  4. package/dist/packages/harbour-cli/src/auth.js +197 -0
  5. package/dist/packages/harbour-cli/src/check.js +317 -0
  6. package/dist/packages/harbour-cli/src/cli.js +321 -0
  7. package/dist/packages/harbour-cli/src/config.js +97 -0
  8. package/dist/packages/harbour-cli/src/dev.js +125 -0
  9. package/dist/packages/harbour-cli/src/forwarder.js +168 -0
  10. package/dist/packages/harbour-cli/src/integrations.js +385 -0
  11. package/dist/packages/harbour-cli/src/jobs.js +53 -0
  12. package/dist/packages/harbour-cli/src/kit-bundle.js +41 -0
  13. package/dist/packages/harbour-cli/src/kit-bundle.manifest.js +32 -0
  14. package/dist/packages/harbour-cli/src/kit.js +149 -0
  15. package/dist/packages/harbour-cli/src/local-runtime.js +526 -0
  16. package/dist/packages/harbour-cli/src/operations.js +382 -0
  17. package/dist/packages/harbour-cli/src/output.js +273 -0
  18. package/dist/packages/harbour-cli/src/productionise.js +508 -0
  19. package/dist/packages/harbour-cli/src/remote-mcp-client.js +121 -0
  20. package/dist/packages/harbour-cli/src/retained-checks.js +435 -0
  21. package/dist/packages/harbour-cli/src/source-inventory.js +239 -0
  22. package/dist/packages/harbour-cli/src/starter.js +557 -0
  23. package/dist/packages/harbour-cli/src/upload.js +163 -0
  24. package/dist/packages/harbour-cli/src/version.js +1 -0
  25. package/dist/src/analyzer.js +685 -0
  26. package/dist/src/contracts.js +82 -0
  27. package/dist/src/digest.js +26 -0
  28. package/dist/src/secret-paths.js +38 -0
  29. package/dist/src/source-intake.js +125 -0
  30. package/package.json +43 -0
@@ -0,0 +1,526 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { copyFile, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
4
+ import { createServer } from "node:net";
5
+ import { dirname, join, relative, win32 } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import EmbeddedPostgres from "embedded-postgres";
8
+ import { kitPaths, projectName } from "./kit.js";
9
+ import { CliError } from "./output.js";
10
+ export function nodePackageCommand(tool, args, platform = process.platform, execPath = process.execPath) {
11
+ if (platform !== "win32")
12
+ return { command: tool, args };
13
+ return { command: execPath, args: [win32.join(win32.dirname(execPath), "node_modules", "npm", "bin", `${tool}-cli.js`), ...args] };
14
+ }
15
+ export const runCommand = (command, args, options = {}) => new Promise(resolve => {
16
+ const child = spawn(command, args, { cwd: options.cwd, env: { ...process.env, ...options.env }, stdio: [options.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"] });
17
+ let stdout = "";
18
+ let stderr = "";
19
+ child.stdout?.on("data", chunk => { stdout += chunk; });
20
+ child.stderr?.on("data", chunk => { stderr += chunk; if (!options.quiet)
21
+ process.stderr.write(chunk); });
22
+ child.on("error", error => resolve({ code: 127, stdout, stderr: `${stderr}${error.message}` }));
23
+ child.on("close", code => resolve({ code: code ?? 1, stdout, stderr }));
24
+ if (options.stdin !== undefined) {
25
+ child.stdin?.end(options.stdin);
26
+ }
27
+ });
28
+ const runWindowsController = (command, args) => new Promise(resolve => {
29
+ const child = spawn(command, args, { stdio: "ignore", windowsHide: true });
30
+ child.on("error", () => resolve(127));
31
+ child.on("close", code => resolve(code ?? 1));
32
+ });
33
+ export const LOCAL = {
34
+ tenant: "local-tenant",
35
+ app: "local-app",
36
+ userId: "local-user",
37
+ email: "local-user@example.test",
38
+ /** The second signed-in person of every local session: what a cross-user check runs as. */
39
+ secondUserId: "local-teammate",
40
+ secondEmail: "teammate@example.test",
41
+ database: "harbour",
42
+ dbUser: "harbour",
43
+ dbPassword: "harbour-local",
44
+ bucket: "harbour-local",
45
+ gatewayRole: "harbour_app_gateway"
46
+ };
47
+ /**
48
+ * App Gateway configuration in the shape cmd/appgateway/main.go decodes (unknown fields are rejected there).
49
+ * `localSession` is what makes the gateway the whole local runtime: issuer = its own published origin (the
50
+ * tokens' `iss`), no jwksUrl (the keys are in-process), the session's bucket, users and state files.
51
+ */
52
+ export function gatewayConfig(project, ports, stateDir) {
53
+ return {
54
+ listen: ":8080",
55
+ issuer: `http://127.0.0.1:${ports.gateway}`,
56
+ environment: "development",
57
+ publicBaseUrl: `http://127.0.0.1:${ports.origin}`,
58
+ // `unrestrictedFiles` with no filePolicies is the local cell's file policy.
59
+ bindings: [{
60
+ tenant: LOCAL.tenant,
61
+ app: LOCAL.app,
62
+ databaseUrl: internalDatabaseUrl(LOCAL.gatewayRole),
63
+ bucket: LOCAL.bucket,
64
+ prefix: `${LOCAL.tenant}/${LOCAL.app}/development/`,
65
+ publicBaseUrl: `http://127.0.0.1:${ports.minio}/${LOCAL.bucket}`,
66
+ capabilities: ["data", "files", "realtime", "telemetry"],
67
+ filePolicies: [],
68
+ unrestrictedFiles: true
69
+ }],
70
+ localSession: {
71
+ session: project, tenant: LOCAL.tenant, app: LOCAL.app, userId: LOCAL.userId, email: LOCAL.email,
72
+ secondUserId: LOCAL.secondUserId, secondEmail: LOCAL.secondEmail,
73
+ metadataFile: "/state/session.json", envFile: "/state/session.env", displayDirectory: stateDir,
74
+ migrationsDir: "/state/migrations", realtimeOutboxFile: "/state/realtime-outbox.sql",
75
+ bucket: LOCAL.bucket
76
+ }
77
+ };
78
+ }
79
+ export function nativeGatewayConfig(project, ports, stateDir) {
80
+ const config = gatewayConfig(project, ports, stateDir);
81
+ config.listen = `127.0.0.1:${ports.gateway}`;
82
+ config.bindings[0] = {
83
+ ...config.bindings[0],
84
+ databaseUrl: `postgresql://${LOCAL.gatewayRole}:${LOCAL.dbPassword}@127.0.0.1:${ports.postgres}/${LOCAL.database}?sslmode=disable`,
85
+ publicBaseUrl: `http://127.0.0.1:${ports.origin}`,
86
+ localFilesDirectory: join(stateDir, "files")
87
+ };
88
+ config.localSession = {
89
+ ...config.localSession,
90
+ metadataFile: join(stateDir, "session.json"), envFile: join(stateDir, "session.env"),
91
+ migrationsDir: join(stateDir, "migrations"), realtimeOutboxFile: join(stateDir, "realtime-outbox.sql")
92
+ };
93
+ return config;
94
+ }
95
+ /** `migrations/*.sql` in name order — what `isomorph dev` applies and what the fixture derives the outbox from. */
96
+ async function migrationNames(root) {
97
+ return (await readdir(join(root, "migrations")).catch(() => [])).filter(name => name.endsWith(".sql")).sort();
98
+ }
99
+ export function internalDatabaseUrl(user = LOCAL.dbUser) { return `postgresql://${user}:${LOCAL.dbPassword}@127.0.0.1/${LOCAL.database}?sslmode=disable`; }
100
+ function uploadKey(project) { return createHash("sha256").update(`upload-key:${project}`).digest("base64"); }
101
+ // ---- Ports and lock ------------------------------------------------------------------
102
+ export async function freePort() {
103
+ return new Promise((resolve, reject) => {
104
+ const server = createServer();
105
+ server.on("error", reject);
106
+ server.listen(0, "127.0.0.1", () => { const address = server.address(); server.close(() => typeof address === "object" && address ? resolve(address.port) : reject(new Error("no port"))); });
107
+ });
108
+ }
109
+ export async function allocatePorts() {
110
+ const [postgres, minio, gateway, vite, origin] = await Promise.all([freePort(), freePort(), freePort(), freePort(), freePort()]);
111
+ return { postgres: postgres, minio: minio, gateway: gateway, vite: vite, origin: origin };
112
+ }
113
+ /** Acquires `.isomorph/local/dev.lock`; a lock whose process is gone is stale and replaced. */
114
+ export async function acquireDevLock(root, ports, isAlive = pidAlive) {
115
+ const existing = await readDevLock(root);
116
+ if (existing && existing.pid !== process.pid && isAlive(existing.pid))
117
+ throw new CliError("DEV_ALREADY_RUNNING", `isomorph dev is already running for this app (pid ${existing.pid}, ${existing.origin}). Run \`isomorph stop\` first.`);
118
+ const lock = { pid: process.pid, startedAt: new Date().toISOString(), ports, origin: `http://127.0.0.1:${ports.origin}` };
119
+ await mkdir(kitPaths(root).local, { recursive: true });
120
+ await writeFile(kitPaths(root).devLock, JSON.stringify(lock));
121
+ return lock;
122
+ }
123
+ export async function readDevLock(root) {
124
+ try {
125
+ return JSON.parse(await readFile(kitPaths(root).devLock, "utf8"));
126
+ }
127
+ catch {
128
+ return undefined;
129
+ }
130
+ }
131
+ export async function releaseDevLock(root) { await rm(kitPaths(root).devLock, { force: true }); }
132
+ /** The running dev origin for this app, if its lock holder is alive. */
133
+ export async function runningOrigin(root, isAlive = pidAlive) {
134
+ const lock = await readDevLock(root);
135
+ return lock && isAlive(lock.pid) ? lock.origin : undefined;
136
+ }
137
+ export function pidAlive(pid) { try {
138
+ process.kill(pid, 0);
139
+ return true;
140
+ }
141
+ catch {
142
+ return false;
143
+ } }
144
+ // ---- Native lifecycle -----------------------------------------------------------------
145
+ export class LocalRuntime {
146
+ root;
147
+ run;
148
+ project;
149
+ postgres;
150
+ windowsPgCtl;
151
+ gateway;
152
+ gates = new Map();
153
+ gateOutput = new Map();
154
+ ports;
155
+ binary;
156
+ constructor(root, run = runCommand, project = projectName(root)) {
157
+ this.root = root;
158
+ this.run = run;
159
+ this.project = project;
160
+ }
161
+ async writeFiles(bundle, ports) {
162
+ const paths = kitPaths(this.root);
163
+ await mkdir(paths.state, { recursive: true });
164
+ this.ports = ports;
165
+ await writeFile(join(paths.state, "app-gateway.json"), `${JSON.stringify(nativeGatewayConfig(this.project, ports, paths.state), null, 2)}\n`);
166
+ // The gateway derives the realtime outbox from the app's migrations; they
167
+ // are staged into the state dir (already mounted at /state) rather than
168
+ // bind-mounting migrations/, which Docker would create as root if absent.
169
+ const staged = join(paths.state, "migrations");
170
+ await rm(staged, { recursive: true, force: true });
171
+ await mkdir(staged, { recursive: true });
172
+ for (const name of await migrationNames(this.root))
173
+ await copyFile(join(this.root, "migrations", name), join(staged, name));
174
+ await rm(join(paths.state, "realtime-outbox.sql"), { force: true });
175
+ // Session credentials are signed by the gateway process that is about to
176
+ // start. Leaving the previous process's files here lets sessionEnv return
177
+ // before the new gateway publishes its keys, so the browser receives a
178
+ // token the new gateway cannot verify after stop/start.
179
+ await rm(join(paths.state, "session.env"), { force: true });
180
+ await rm(join(paths.state, "session.json"), { force: true });
181
+ }
182
+ /**
183
+ * Pulls the pinned images before `up`, so a registry problem is named as such
184
+ * rather than surfacing as a failed start. Every kit image is a public
185
+ * `repository@sha256:<digest>` reference: no registry login is needed and
186
+ * a tag can never substitute other bytes.
187
+ */
188
+ async pull(bundle) {
189
+ this.binary = process.env.ISOMORPH_APP_GATEWAY_BINARY || await ensureNativeGateway(this.root, bundle);
190
+ const result = await this.run(this.binary, ["--help"], { quiet: true });
191
+ if (result.code === 127)
192
+ throw new CliError("KIT_RUNTIME_UNAVAILABLE", "The native Isomorph runtime could not be started. Reinstall the current Isomorph CLI.");
193
+ }
194
+ /** Attach a one-shot command to the native services owned by a running dev process. */
195
+ async attach(bundle, ports) {
196
+ this.ports = ports;
197
+ await this.pull(bundle);
198
+ }
199
+ /**
200
+ * `compose up`. When Docker's bridge address pool is full (each Isomorph
201
+ * project holds one network), the stopped projects of other app roots are
202
+ * brought down — networks freed, data volumes kept — and `up` is retried
203
+ * once; a pool still full after that is reported with the remedies only the
204
+ * person can apply, never as a generic "could not start".
205
+ */
206
+ async up(output) {
207
+ if (!this.ports)
208
+ throw new CliError("LOCAL_RUNTIME_FAILED", "The local runtime was not prepared.");
209
+ const paths = kitPaths(this.root);
210
+ const postgresBinaries = await hydrateEmbeddedPostgres(this.run);
211
+ const postgresMessages = [];
212
+ this.postgres = new EmbeddedPostgres({ databaseDir: join(paths.state, "postgres"), port: this.ports.postgres, user: LOCAL.dbUser, password: LOCAL.dbPassword, persistent: true, onLog: message => postgresMessages.push(String(message).trim()), onError: error => postgresMessages.push(String(error)) });
213
+ try {
214
+ if (!(await readdir(join(paths.state, "postgres")).catch(() => [])).length)
215
+ await this.postgres.initialise();
216
+ output?.("Local database files are ready.");
217
+ if (process.platform === "win32") {
218
+ this.windowsPgCtl = postgresBinaries.pg_ctl;
219
+ const started = await runWindowsController(this.windowsPgCtl, ["start", "-w", "-D", join(paths.state, "postgres"), "-l", join(paths.state, "postgres.log"), "-o", `-p ${this.ports.postgres}`]);
220
+ if (started !== 0)
221
+ throw new Error(`pg_ctl could not start Postgres (exit ${started})`);
222
+ }
223
+ else {
224
+ await this.postgres.start();
225
+ }
226
+ output?.("Local database is running.");
227
+ }
228
+ catch (error) {
229
+ const detail = postgresMessages.filter(Boolean).slice(-3).join(" ") || (error instanceof Error ? error.message : String(error));
230
+ throw new CliError("LOCAL_RUNTIME_FAILED", `Local Postgres could not start${detail ? `: ${detail}` : "."}`);
231
+ }
232
+ const root = this.postgres.getPgClient("postgres", "127.0.0.1");
233
+ try {
234
+ await root.connect();
235
+ await root.query(`CREATE DATABASE ${LOCAL.database}`).catch(error => { if (!String(error).includes("already exists"))
236
+ throw error; });
237
+ }
238
+ finally {
239
+ await root.end().catch(() => undefined);
240
+ }
241
+ output?.("Local app database is ready.");
242
+ const admin = this.postgres.getPgClient(LOCAL.database, "127.0.0.1");
243
+ await admin.connect();
244
+ await admin.query(`DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${LOCAL.gatewayRole}') THEN CREATE ROLE ${LOCAL.gatewayRole} LOGIN PASSWORD '${LOCAL.dbPassword}'; END IF; END $$;`);
245
+ await admin.end();
246
+ const binary = this.binary;
247
+ this.gateway = spawn(binary, [], { cwd: this.root, env: { ...process.env, HARBOUR_APP_GATEWAY_CONFIG_FILE: join(paths.state, "app-gateway.json"), HARBOUR_APP_GATEWAY_UPLOAD_KEY: uploadKey(this.project), AWS_EC2_METADATA_DISABLED: "true" }, stdio: ["ignore", "ignore", "pipe"] });
248
+ this.gateway.stderr?.on("data", chunk => process.stderr.write(chunk));
249
+ }
250
+ /**
251
+ * `isomorph stop` and Ctrl-C: the project's containers and network go, its
252
+ * volumes (the app's local data) stay; safe to repeat. `compose stop` alone
253
+ * kept the network, and ~31 stopped apps then exhausted Docker's address
254
+ * pool for every later `isomorph dev` and `isomorph check`.
255
+ */
256
+ async stop() { return { stopped: await this.down() }; }
257
+ /**
258
+ * Removes this project's containers and network, keeping its named volumes
259
+ * (the app's local data). What the kit gate uses for a session it started
260
+ * itself: `stop` would leave one Docker network per checked app behind,
261
+ * and Docker's address pools run out after a dozen or so ("all predefined
262
+ * address pools have been fully subnetted").
263
+ */
264
+ async down() {
265
+ this.gateway?.kill("SIGTERM");
266
+ for (const child of this.gates.values())
267
+ child.kill("SIGTERM");
268
+ if (this.windowsPgCtl) {
269
+ await runWindowsController(this.windowsPgCtl, ["stop", "-w", "-D", join(kitPaths(this.root).state, "postgres"), "-m", "fast"]);
270
+ }
271
+ else {
272
+ await this.postgres?.stop().catch(() => undefined);
273
+ }
274
+ return true;
275
+ }
276
+ /** Removes this project's containers and named volumes only. */
277
+ async reset(output) {
278
+ output("Deleting this app's local database and files.");
279
+ await this.down();
280
+ await rm(kitPaths(this.root).state, { recursive: true, force: true });
281
+ }
282
+ /** Applies `migrations/*.sql` in name order through psql inside the postgres container, after ensuring the runtime role. */
283
+ async migrate() {
284
+ const dir = join(this.root, "migrations");
285
+ const names = await migrationNames(this.root);
286
+ const psql = (sql) => this.psql(sql);
287
+ const role = await psql(`DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${LOCAL.gatewayRole}') THEN CREATE ROLE ${LOCAL.gatewayRole} LOGIN PASSWORD '${LOCAL.dbPassword}'; END IF; END $$;`);
288
+ if (role.code !== 0)
289
+ throw new CliError("MIGRATION_FAILED", "Could not prepare the local database role.");
290
+ for (const name of names) {
291
+ const result = await psql(await readFile(join(dir, name), "utf8"));
292
+ if (result.code !== 0)
293
+ throw new CliError("MIGRATION_FAILED", `Migration ${name} failed: ${result.stderr.trim().split("\n").at(-1) ?? "psql error"}`);
294
+ }
295
+ return names;
296
+ }
297
+ psql(sql) {
298
+ if (!this.postgres)
299
+ return Promise.resolve({ code: 1, stdout: "", stderr: "Postgres is not running" });
300
+ return (async () => {
301
+ const client = this.postgres.getPgClient(LOCAL.database, "127.0.0.1");
302
+ try {
303
+ await client.connect();
304
+ await client.query(sql);
305
+ return { code: 0, stdout: "", stderr: "" };
306
+ }
307
+ catch (error) {
308
+ return { code: 1, stdout: "", stderr: error instanceof Error ? error.message : String(error) };
309
+ }
310
+ finally {
311
+ await client.end().catch(() => undefined);
312
+ }
313
+ })();
314
+ }
315
+ /**
316
+ * Starts the kit gate: the pinned gateway image's `gate` subcommand as a
317
+ * one-shot service of this project (`docker compose run`), the app mounted
318
+ * read-only at /workspace, its control listener published on `port`, the
319
+ * session's PostgreSQL and bucket named for its scratch database and files.
320
+ * Resolves to the container id; the caller drives the control API
321
+ * (check.ts) and stops it with stopGate.
322
+ */
323
+ async startGate(bundle, port, publicUrl) {
324
+ void bundle;
325
+ if (!this.ports)
326
+ throw new CliError("LOCAL_RUNTIME_FAILED", "The local runtime was not prepared.");
327
+ const id = `gate-${Date.now()}`;
328
+ const child = spawn(this.binary, ["gate"], { cwd: this.root, env: { ...process.env, HARBOUR_GATE_WORKSPACE: this.root, HARBOUR_GATE_LISTEN: `127.0.0.1:${port}`, HARBOUR_GATE_PUBLIC_URL: publicUrl, HARBOUR_GATE_DATABASE_URL: `postgresql://${LOCAL.dbUser}:${LOCAL.dbPassword}@127.0.0.1:${this.ports.postgres}/${LOCAL.database}?sslmode=disable`, HARBOUR_GATE_S3_BUCKET: LOCAL.bucket, HARBOUR_GATE_FILES_DIRECTORY: join(kitPaths(this.root).state, "gate-files") }, stdio: ["ignore", "ignore", "pipe"] });
329
+ this.gates.set(id, child);
330
+ child.stderr?.on("data", chunk => this.gateOutput.set(id, `${this.gateOutput.get(id) ?? ""}${chunk}`.slice(-4000)));
331
+ return id;
332
+ }
333
+ async gateRunning(container) {
334
+ const child = this.gates.get(container);
335
+ return Boolean(child && child.exitCode === null && !child.killed);
336
+ }
337
+ async gateLogs(container) {
338
+ return (this.gateOutput.get(container) || (this.gates.has(container) ? "native gate exited" : "native gate was not started")).trim().split("\n").slice(-20).join(" | ").slice(0, 3000);
339
+ }
340
+ async stopGate(container) {
341
+ this.gates.get(container)?.kill("SIGTERM");
342
+ this.gates.delete(container);
343
+ this.gateOutput.delete(container);
344
+ }
345
+ /**
346
+ * Applies the platform's realtime outbox migration the gateway generated
347
+ * for this app's tables (state/realtime-outbox.sql: the data plane's one
348
+ * generator, the same text the hosted pipeline appends to the app's
349
+ * bundle) — after the app's migrations, idempotent on every start. The
350
+ * gateway's in-process relay starts publishing once the table exists.
351
+ * Returns the number of watched tables; 0 when the app has no tables.
352
+ */
353
+ async installRealtimeOutbox() {
354
+ const path = join(kitPaths(this.root).state, "realtime-outbox.sql");
355
+ const sql = await readFile(path, "utf8").catch(() => "");
356
+ if (!sql.trim())
357
+ return 0;
358
+ const result = await this.psql(sql);
359
+ if (result.code !== 0)
360
+ throw new CliError("MIGRATION_FAILED", `The realtime outbox migration failed: ${result.stderr.trim().split("\n").at(-1) ?? "psql error"}`);
361
+ return (sql.match(/harbour_runtime\.watch_table\(/g) ?? []).length;
362
+ }
363
+ /**
364
+ * The gateway's session env — both signed identities under the pipeline's
365
+ * names (ISOMORPH_IDENTITY_CONTEXT, _HEADER, _SECOND_USER) — once the
366
+ * gateway has bound its listener and written it.
367
+ */
368
+ async sessionEnv(attempts = 60) {
369
+ const path = join(kitPaths(this.root).state, "session.env");
370
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
371
+ try {
372
+ const env = parseSessionEnv(await readFile(path, "utf8"));
373
+ if (env.ISOMORPH_IDENTITY_CONTEXT && env.ISOMORPH_IDENTITY_CONTEXT_SECOND_USER)
374
+ return env;
375
+ }
376
+ catch { /* not written yet */ }
377
+ if (attempts > 1)
378
+ await new Promise(resolve => setTimeout(resolve, 500));
379
+ }
380
+ throw new CliError("LOCAL_RUNTIME_FAILED", "The local Isomorph gateway did not publish a session.");
381
+ }
382
+ }
383
+ async function hydrateEmbeddedPostgres(run) {
384
+ const packageName = embeddedPostgresPackage(process.platform, process.arch);
385
+ let entry;
386
+ try {
387
+ entry = fileURLToPath(import.meta.resolve(packageName));
388
+ }
389
+ catch {
390
+ throw new CliError("KIT_RUNTIME_UNAVAILABLE", `This Isomorph CLI does not include Postgres for ${process.platform}-${process.arch}.`);
391
+ }
392
+ const packageRoot = dirname(dirname(entry));
393
+ const script = join(packageRoot, "scripts", "hydrate-symlinks.js");
394
+ const result = await run(process.execPath, [script], { cwd: packageRoot, quiet: true });
395
+ if (result.code !== 0)
396
+ throw new CliError("KIT_RUNTIME_UNAVAILABLE", "The bundled Postgres files could not be prepared.");
397
+ const binaries = await import(packageName);
398
+ if (!binaries.pg_ctl)
399
+ throw new CliError("KIT_RUNTIME_UNAVAILABLE", "The bundled Postgres controller is missing.");
400
+ return { pg_ctl: binaries.pg_ctl };
401
+ }
402
+ export function embeddedPostgresPackage(platform, arch) {
403
+ return `@embedded-postgres/${platform === "win32" ? "windows" : platform}-${arch}`;
404
+ }
405
+ async function ensureNativeGateway(root, bundle) {
406
+ const runtime = nativeGatewayArtifact(bundle, process.platform, process.arch);
407
+ if (!runtime)
408
+ throw new CliError("KIT_RUNTIME_UNAVAILABLE", `This kit bundle has no native Isomorph runtime for ${process.platform}-${process.arch}.`);
409
+ const directory = join(kitPaths(root).local, "runtime");
410
+ const target = join(directory, `isomorph-app-gateway-${runtime.sha256.slice(0, 12)}${process.platform === "win32" ? ".exe" : ""}`);
411
+ if (!(await exists(target))) {
412
+ const bytes = await downloadSdk(runtime.url);
413
+ const digest = createHash("sha256").update(bytes).digest("hex");
414
+ if (digest !== runtime.sha256)
415
+ throw new CliError("KIT_BUNDLE_INCOMPATIBLE", `The native runtime digest ${digest} does not match the kit bundle ${runtime.sha256}.`);
416
+ await mkdir(directory, { recursive: true });
417
+ await writeFile(target, bytes, { mode: 0o755 });
418
+ }
419
+ return target;
420
+ }
421
+ export function nativeGatewayArtifact(bundle, platform, arch) {
422
+ if (platform === "darwin" && arch === "arm64")
423
+ return bundle.nativeRuntime?.darwinArm64;
424
+ if (platform === "linux" && arch === "x64")
425
+ return bundle.nativeRuntime?.linuxX64;
426
+ if (platform === "win32" && arch === "x64")
427
+ return bundle.nativeRuntime?.windowsX64;
428
+ return undefined;
429
+ }
430
+ /** The gateway writes a shell-sourceable file: `export KEY='value'`, a literal quote written as `'"'"'`. */
431
+ export function parseSessionEnv(text) {
432
+ const env = {};
433
+ for (const line of text.split("\n")) {
434
+ const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line.trim());
435
+ if (!match)
436
+ continue;
437
+ const raw = match[2];
438
+ env[match[1]] = raw.startsWith("'") && raw.endsWith("'") && raw.length >= 2 ? raw.slice(1, -1).replace(/'"'"'/g, "'") : raw;
439
+ }
440
+ return env;
441
+ }
442
+ // ---- SDK tarball ---------------------------------------------------------------------------
443
+ /**
444
+ * The SDK ships in the kit bundle, not the public registry. Stages the tarball
445
+ * named by ISOMORPH_KIT_SDK_TARBALL or downloaded from the manifest's sdk.url under
446
+ * `.isomorph/local/sdk/` (so package.json gets a relative `file:` dependency), checks
447
+ * its sha256 against the bundle and installs it with the app's other dependencies.
448
+ * Returns "present" when the staged tarball is already installed, "missing" when
449
+ * no tarball is available and node_modules lacks the package.
450
+ */
451
+ export async function ensureSdk(root, bundle, env, run, output, fetchImpl = fetch) {
452
+ const installed = await readFile(join(root, "node_modules", ...bundle.sdk.package.split("/"), "package.json")).then(() => true, () => false);
453
+ const stageDir = join(kitPaths(root).local, "sdk");
454
+ const staged = join(stageDir, `isomorph-app-sdk-${bundle.sdk.tarballSha256.slice(0, 12)}.tgz`);
455
+ const source = env.ISOMORPH_KIT_SDK_TARBALL?.trim();
456
+ if (!(await exists(staged))) {
457
+ let bytes;
458
+ if (source)
459
+ bytes = await readFile(source).catch(() => { throw new CliError("SDK_UNAVAILABLE", `ISOMORPH_KIT_SDK_TARBALL is not readable: ${source}`); });
460
+ else if (bundle.sdk.url) {
461
+ output(`Downloading ${bundle.sdk.package} from the kit bundle (sha256 ${bundle.sdk.tarballSha256.slice(0, 12)}…).`);
462
+ bytes = await downloadSdk(bundle.sdk.url, fetchImpl);
463
+ }
464
+ if (!bytes)
465
+ return installed ? "present" : "missing";
466
+ const digest = createHash("sha256").update(bytes).digest("hex");
467
+ if (bundle.sdk.tarballSha256 !== "unknown" && digest !== bundle.sdk.tarballSha256)
468
+ throw new CliError("KIT_BUNDLE_INCOMPATIBLE", `The SDK tarball digest ${digest} does not match the kit bundle ${bundle.sdk.tarballSha256}. Run \`isomorph init --upgrade\` or use the matching tarball.`);
469
+ await mkdir(stageDir, { recursive: true });
470
+ await writeFile(staged, bytes);
471
+ }
472
+ else if (installed && (await readFile(join(root, "package.json"), "utf8").catch(() => "")).includes(relative(root, staged)))
473
+ return "present";
474
+ output(`Installing ${bundle.sdk.package} from the kit bundle.`);
475
+ const npm = nodePackageCommand("npm", ["install", "--no-audit", "--no-fund", relative(root, staged)]);
476
+ const result = await run(npm.command, npm.args, { cwd: root, quiet: true });
477
+ if (result.code !== 0)
478
+ throw new CliError("SDK_INSTALL_FAILED", `npm could not install the kit SDK tarball: ${result.stderr.trim().split("\n").at(-1) ?? "npm error"}`);
479
+ return "installed";
480
+ }
481
+ /**
482
+ * Fetches the SDK tarball from the bundle's `sdk.url`. The published bundle
483
+ * stores it as a content-addressed OCI blob on a public registry, which answers
484
+ * an anonymous GET with a Bearer challenge naming a token endpoint that hands
485
+ * out pull tokens without credentials; the challenge is followed once and the
486
+ * request repeated. Redirects (a blob served from object storage) are followed;
487
+ * the bearer token is not forwarded across origins by fetch itself. The caller
488
+ * verifies the bytes against `sdk.tarballSha256` — a token or a redirect can
489
+ * change where the bytes come from, never which bytes are accepted.
490
+ */
491
+ export async function downloadSdk(url, fetchImpl = fetch) {
492
+ if (!/^https:\/\//.test(url))
493
+ throw new CliError("SDK_UNAVAILABLE", `The kit bundle's sdk.url must be https: ${url}`);
494
+ const attempt = (headers = {}) => fetchImpl(url, { redirect: "follow", headers }).catch(error => { throw new CliError("SDK_UNAVAILABLE", `The kit SDK tarball could not be downloaded from ${url}: ${error instanceof Error ? error.message : String(error)}`); });
495
+ let response = await attempt();
496
+ if (response.status === 401) {
497
+ const challenge = parseBearerChallenge(response.headers.get("www-authenticate"));
498
+ if (challenge && /^https:\/\//.test(challenge.realm)) {
499
+ const tokenUrl = new URL(challenge.realm);
500
+ if (challenge.service)
501
+ tokenUrl.searchParams.set("service", challenge.service);
502
+ if (challenge.scope)
503
+ tokenUrl.searchParams.set("scope", challenge.scope);
504
+ const tokenResponse = await fetchImpl(tokenUrl, { redirect: "follow" }).catch(() => undefined);
505
+ const body = tokenResponse?.ok ? await tokenResponse.json().catch(() => undefined) : undefined;
506
+ const token = typeof body?.token === "string" ? body.token : typeof body?.access_token === "string" ? body.access_token : undefined;
507
+ if (token)
508
+ response = await attempt({ authorization: `Bearer ${token}` });
509
+ }
510
+ }
511
+ if (!response.ok)
512
+ throw new CliError("SDK_UNAVAILABLE", `The kit SDK tarball could not be downloaded from ${url} (HTTP ${response.status}).`);
513
+ return new Uint8Array(await response.arrayBuffer());
514
+ }
515
+ /** `Bearer realm="…",service="…",scope="…"` (RFC 6750 / distribution token auth); anything else is no challenge. */
516
+ export function parseBearerChallenge(header) {
517
+ if (!header || !/^\s*bearer\s/i.test(header))
518
+ return undefined;
519
+ const params = {};
520
+ for (const match of header.slice(header.search(/\s/)).matchAll(/([a-z_]+)="([^"]*)"/gi))
521
+ params[match[1].toLowerCase()] = match[2];
522
+ if (!params.realm)
523
+ return undefined;
524
+ return { realm: params.realm, service: params.service, scope: params.scope };
525
+ }
526
+ const exists = (path) => readFile(path).then(() => true, () => false);