cinatra 0.1.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.
@@ -0,0 +1,815 @@
1
+ // ---------------------------------------------------------------------------
2
+ // `cinatra install` — from-zero dev/prod bootstrap (cinatra#255 §3.1, Class C).
3
+ //
4
+ // This is the ONLY command that runs BEFORE any cinatra checkout exists: it is
5
+ // the headline reason the dependency-light `cinatra` core is published. Invoked
6
+ // as `npx cinatra install`, it downloads cinatra, checks requirements first,
7
+ // clones ONLY the repos the host declares, creates the env, brings up infra, and
8
+ // runs setup inside the freshly-cloned target.
9
+ //
10
+ // DELIBERATELY self-contained: node builtins + `git`/`docker`/`corepack`
11
+ // subprocesses + the two pre-install-safe sync modules
12
+ // (`cinatra-dev-extensions.mjs`, `dev-apps.mjs`). It does NOT import the heavy
13
+ // `index.mjs` graph (pg, pacote, the MCP SDK, …) and — critically — does NOT
14
+ // call `getRepoRoot()`: there is no checkout to anchor on when bootstrapping
15
+ // from zero. It operates purely on the `--dir` target it is about to create,
16
+ // and hands setup off to the TARGET's own `bin/cinatra.mjs` as a subprocess
17
+ // (exactly what `pnpm setup:dev` does), so the target resolves its own repo
18
+ // root via its own cwd-walk.
19
+ //
20
+ // Flow (dev):
21
+ // preflight (FIRST, before any download) → resolve target dir →
22
+ // clone/update host at --ref (record the resolved SHA) → create/reconcile
23
+ // .env.local → bring up + wait for docker infra → sync cinatra.devExtensions
24
+ // → `corepack pnpm install` → run `setup dev` in the target (which itself
25
+ // clones cinatra.devApps and provisions DB/Nango/MCP/OAuth).
26
+ //
27
+ // Flow (prod) mirrors scripts/setup.sh's prod branch: install → acquire-prod →
28
+ // install → setup prod. The required-extension set is acquired by `setup prod`
29
+ // itself, so install does not pre-sync devExtensions in prod mode.
30
+ // ---------------------------------------------------------------------------
31
+
32
+ import { randomBytes } from "node:crypto";
33
+ import {
34
+ copyFileSync,
35
+ existsSync,
36
+ mkdirSync,
37
+ readFileSync,
38
+ readdirSync,
39
+ writeFileSync,
40
+ } from "node:fs";
41
+ import path from "node:path";
42
+ import process from "node:process";
43
+ import { spawnSync } from "node:child_process";
44
+ import { fileURLToPath } from "node:url";
45
+
46
+ import { syncCinatraDevExtensions } from "./cinatra-dev-extensions.mjs";
47
+
48
+ // Absolute path to THIS published `cinatra` CLI's own bin entry. After the
49
+ // monorepo's `packages/cli` is removed (cinatra#402, P2), the freshly-cloned
50
+ // TARGET checkout no longer ships `packages/cli/bin/cinatra.mjs`, so the
51
+ // acquire-prod + setup-in-target subprocesses MUST be driven by the published
52
+ // CLI's own bin (the very binary running `cinatra install`), pointed at the
53
+ // target via `cwd` + `CINATRA_REPO_ROOT`. Resolved module-relatively (src/ →
54
+ // ../bin/cinatra.mjs) so it is deterministic regardless of how the CLI was
55
+ // launched (npx, global bin, symlink shim).
56
+ const PUBLISHED_CLI_BIN = fileURLToPath(new URL("../bin/cinatra.mjs", import.meta.url));
57
+
58
+ export const DEFAULT_REPO_URL = "https://github.com/cinatra-ai/cinatra.git";
59
+ export const DEFAULT_INSTALL_DIRNAME = "cinatra";
60
+ const MIN_NODE_MAJOR = 24;
61
+
62
+ // Git protocols install is willing to fetch over. `https`/`ssh`/`git`/`file`
63
+ // cover the documented `--repo-url` override (HTTPS-token / SSH); anything
64
+ // else (e.g. `ext::`) is rejected up front, and the same allowlist is pinned
65
+ // into the git child env via GIT_ALLOW_PROTOCOL so a malicious submodule/url
66
+ // can never widen it.
67
+ const ALLOWED_GIT_PROTOCOLS = ["https", "ssh", "git", "file"];
68
+ const GIT_ALLOW_PROTOCOL = ALLOWED_GIT_PROTOCOLS.join(":");
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Small process helpers (self-contained — index.mjs equivalents are not
72
+ // exported, and install.mjs must stay import-light).
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /** Run a command inheriting stdio; throw `message` on non-zero exit. */
76
+ function runOrThrow(command, args, message, { cwd, env } = {}) {
77
+ const result = spawnSync(command, args, {
78
+ stdio: "inherit",
79
+ env: env ?? process.env,
80
+ ...(cwd ? { cwd } : {}),
81
+ });
82
+ if (result.error) throw new Error(`${message} (${result.error.message})`);
83
+ if (result.status !== 0) throw new Error(message);
84
+ }
85
+
86
+ /** True iff `command <probeArgs>` exits 0 (used for the preflight). */
87
+ function commandExists(command, probeArgs = ["--version"]) {
88
+ try {
89
+ const r = spawnSync(command, probeArgs, { stdio: "ignore", env: process.env });
90
+ return r.status === 0;
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
95
+
96
+ /** Capture trimmed stdout of a command, or null on any failure. */
97
+ function capture(command, args, { cwd, env } = {}) {
98
+ try {
99
+ const r = spawnSync(command, args, {
100
+ encoding: "utf8",
101
+ env: env ?? process.env,
102
+ ...(cwd ? { cwd } : {}),
103
+ });
104
+ if (r.status !== 0) return null;
105
+ return (r.stdout ?? "").trim();
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ /** The env passed to every git child: pin the protocol allowlist + disable
112
+ * any interactive credential/SSH prompt so a missing-access clone fails fast
113
+ * instead of blocking on a hidden TTY prompt. */
114
+ function gitEnv() {
115
+ return {
116
+ ...process.env,
117
+ GIT_ALLOW_PROTOCOL,
118
+ GIT_TERMINAL_PROMPT: "0",
119
+ // Only relevant for ssh remotes; harmless otherwise.
120
+ GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND ?? "ssh -o BatchMode=yes",
121
+ };
122
+ }
123
+
124
+ function git(args, { cwd } = {}) {
125
+ return spawnSync("git", args, {
126
+ encoding: "utf8",
127
+ env: gitEnv(),
128
+ ...(cwd ? { cwd } : {}),
129
+ });
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // Flag parsing.
134
+ // ---------------------------------------------------------------------------
135
+
136
+ /** Read `--flag value`; null when absent. Throws when the value is itself a
137
+ * flag-shaped token (`--ref --dir x`) — a classic foot-gun that would
138
+ * otherwise silently consume the next flag as a value. */
139
+ function readOption(argv, flag) {
140
+ const i = argv.indexOf(flag);
141
+ if (i === -1) return null;
142
+ const value = argv[i + 1];
143
+ if (value === undefined || value.startsWith("--")) {
144
+ throw new Error(`${flag} requires a value (got ${value === undefined ? "end of arguments" : `"${value}"`}).`);
145
+ }
146
+ return value;
147
+ }
148
+
149
+ const VALID_MODES = new Set(["dev", "prod"]);
150
+ // A git ref we are willing to `checkout`: a branch/tag name or a commit sha.
151
+ // Conservative — no whitespace, no leading dash (option-injection), no `..`,
152
+ // no refspec/glob metacharacters. Covers `main`, dotted release tags, and
153
+ // 7-40 hex shas.
154
+ const SAFE_REF_RE = /^(?!-)[A-Za-z0-9._\/-]+$/;
155
+
156
+ export function parseInstallArgs(argv = []) {
157
+ const dirOpt = readOption(argv, "--dir");
158
+ const refOpt = readOption(argv, "--ref");
159
+ const repoUrlOpt = readOption(argv, "--repo-url");
160
+ const modeOpt = readOption(argv, "--mode");
161
+
162
+ const ref = refOpt ?? "main";
163
+ if (!SAFE_REF_RE.test(ref) || ref.includes("..")) {
164
+ throw new Error(
165
+ `Invalid --ref "${ref}". Use a branch, tag, or commit sha ` +
166
+ `(letters/digits/dot/dash/underscore/slash; no leading dash, no "..").`,
167
+ );
168
+ }
169
+
170
+ let mode = "dev";
171
+ if (modeOpt != null) {
172
+ if (!VALID_MODES.has(modeOpt)) {
173
+ throw new Error(`Invalid --mode "${modeOpt}". Use "dev" or "prod".`);
174
+ }
175
+ mode = modeOpt;
176
+ }
177
+
178
+ const repoUrl = repoUrlOpt ?? DEFAULT_REPO_URL;
179
+ assertSafeRepoUrl(repoUrl);
180
+
181
+ return {
182
+ dir: dirOpt, // null → resolved later (prompt on TTY, else default).
183
+ ref,
184
+ repoUrl,
185
+ mode,
186
+ yes: argv.includes("--yes"),
187
+ force: argv.includes("--force"),
188
+ resetEnv: argv.includes("--reset-env"),
189
+ skipDevApps: argv.includes("--skip-dev-apps"),
190
+ noSetup: argv.includes("--no-setup"),
191
+ noInfra: argv.includes("--no-infra"),
192
+ // --no-install ⇒ clone + env only; pnpm install + setup both skipped
193
+ // (setup needs the installed deps, so skipping install implies skipping setup).
194
+ noInstall: argv.includes("--no-install"),
195
+ };
196
+ }
197
+
198
+ /** Reject a `--repo-url` whose protocol is not in the allowlist (and reject a
199
+ * flag-shaped value). SCP-style `git@host:org/repo` is accepted (it is ssh). */
200
+ export function assertSafeRepoUrl(url) {
201
+ if (typeof url !== "string" || url.length === 0 || url.startsWith("-")) {
202
+ throw new Error(`Invalid --repo-url "${url}".`);
203
+ }
204
+ // scp-like shorthand: user@host:path (no "://") → ssh.
205
+ if (!url.includes("://") && /^[^/]+@[^/]+:/.test(url)) return;
206
+ let proto;
207
+ try {
208
+ proto = new URL(url).protocol.replace(/:$/, "");
209
+ } catch {
210
+ throw new Error(`Invalid --repo-url "${url}" (not a parseable URL).`);
211
+ }
212
+ if (!ALLOWED_GIT_PROTOCOLS.includes(proto)) {
213
+ throw new Error(
214
+ `Refusing --repo-url with protocol "${proto}". Allowed: ${ALLOWED_GIT_PROTOCOLS.join(", ")}.`,
215
+ );
216
+ }
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Preflight — runs FIRST, before any download. Collects EVERY failure so the
221
+ // operator sees the full remediation list at once, not one-at-a-time.
222
+ // ---------------------------------------------------------------------------
223
+
224
+ /** A non-null target dir's parent must be writable (the dir itself may not
225
+ * exist yet). Returns a remediation string on failure, else null. */
226
+ function checkTargetWritable(targetDir) {
227
+ const parent = path.dirname(path.resolve(targetDir));
228
+ try {
229
+ if (!existsSync(parent)) {
230
+ return `Parent directory ${parent} does not exist — create it first (mkdir -p ${parent}).`;
231
+ }
232
+ const probe = path.join(parent, `.cinatra-install-write-probe-${process.pid}`);
233
+ writeFileSync(probe, "");
234
+ spawnSync("rm", ["-f", probe]); // best-effort cleanup of the probe file.
235
+ return null;
236
+ } catch (err) {
237
+ return `Cannot write into ${parent}: ${err.message}. Choose a --dir under a writable location.`;
238
+ }
239
+ }
240
+
241
+ export function runPreflight({ mode = "dev", targetDir = null, noInfra = false, deps = {} } = {}) {
242
+ const exists = deps.commandExists ?? commandExists;
243
+ const nodeVersion = deps.nodeVersion ?? process.versions.node;
244
+ const failures = [];
245
+ const warnings = [];
246
+
247
+ // Node major.
248
+ const major = Number.parseInt(String(nodeVersion).split(".")[0], 10);
249
+ if (!Number.isFinite(major) || major < MIN_NODE_MAJOR) {
250
+ failures.push(
251
+ `Node.js ${nodeVersion} detected — Cinatra requires Node.js ${MIN_NODE_MAJOR}.x or newer ` +
252
+ `(the Better Auth bootstrap relies on native TS type-stripping). Install Node ${MIN_NODE_MAJOR}+ and retry.`,
253
+ );
254
+ }
255
+
256
+ if (!exists("git")) {
257
+ failures.push("git is not installed. Install git (https://git-scm.com/downloads) and retry.");
258
+ }
259
+
260
+ // A package manager via Corepack. We invoke pnpm through `corepack pnpm`, so
261
+ // corepack is what we truly need; a bare `pnpm` on PATH is an accepted
262
+ // fallback.
263
+ const hasCorepack = exists("corepack", ["--version"]);
264
+ const hasPnpm = exists("pnpm", ["--version"]);
265
+ if (!hasCorepack && !hasPnpm) {
266
+ failures.push(
267
+ "Neither Corepack nor pnpm is available. Corepack ships with Node 24 — run `corepack enable`, " +
268
+ "or install pnpm (`npm install -g pnpm`), then retry.",
269
+ );
270
+ }
271
+
272
+ // Docker + Compose are required for dev infra (postgres/redis/nango) and for
273
+ // the prod stack. `--no-infra` lets an operator point at external infra, so
274
+ // a missing Docker becomes a WARNING (not a hard failure) in that mode.
275
+ const hasDocker = exists("docker", ["--version"]);
276
+ const hasCompose = hasDocker && (deps.composeAvailable ?? composeAvailable)();
277
+ const dockerBucket = noInfra ? warnings : failures;
278
+ if (!hasDocker) {
279
+ dockerBucket.push(
280
+ "Docker is not installed. Install Docker Desktop (https://docs.docker.com/get-docker/) and retry" +
281
+ (noInfra ? " — or ensure your external Postgres/Redis/Nango are reachable (--no-infra)." : "."),
282
+ );
283
+ } else if (!hasCompose) {
284
+ dockerBucket.push(
285
+ "Docker Compose v2 is not available (`docker compose version` failed). Update Docker Desktop and retry" +
286
+ (noInfra ? " (or rely on external infra with --no-infra)." : "."),
287
+ );
288
+ }
289
+
290
+ if (!exists("curl")) {
291
+ // curl is used for the Nango readiness probe; warn (the wait can fall back),
292
+ // never block.
293
+ warnings.push("curl is not installed — the Nango readiness wait may be less reliable.");
294
+ }
295
+
296
+ if (targetDir) {
297
+ const writableErr = (deps.checkTargetWritable ?? checkTargetWritable)(targetDir);
298
+ if (writableErr) failures.push(writableErr);
299
+ }
300
+
301
+ return { ok: failures.length === 0, failures, warnings, mode };
302
+ }
303
+
304
+ function composeAvailable() {
305
+ try {
306
+ const r = spawnSync("docker", ["compose", "version"], { stdio: "ignore", env: process.env });
307
+ return r.status === 0;
308
+ } catch {
309
+ return false;
310
+ }
311
+ }
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // Target-dir resolution + checkout state.
315
+ // ---------------------------------------------------------------------------
316
+
317
+ // A real cinatra checkout — the pnpm workspace file AND the never-removed
318
+ // internal `@cinatra-ai/migrations` package manifest (by exact name). Mirrors
319
+ // `isCinatraRepoRoot` in index.mjs: it does NOT gate on `packages/cli` (that
320
+ // package goes external at P1/P2, cinatra#402, and this sentinel must survive
321
+ // its removal) nor on the bin-colliding root package name `cinatra`. Any
322
+ // read/parse error fails closed.
323
+ function isCinatraCheckout(dir) {
324
+ try {
325
+ if (!existsSync(path.join(dir, "pnpm-workspace.yaml"))) return false;
326
+ const migrationsPkg = path.join(dir, "packages", "migrations", "package.json");
327
+ if (!existsSync(migrationsPkg)) return false;
328
+ return JSON.parse(readFileSync(migrationsPkg, "utf8"))?.name === "@cinatra-ai/migrations";
329
+ } catch {
330
+ return false;
331
+ }
332
+ }
333
+
334
+ function isEmptyDir(dir) {
335
+ try {
336
+ return readdirSync(dir).filter((n) => n !== ".DS_Store").length === 0;
337
+ } catch {
338
+ return true; // absent counts as empty (we'll create it).
339
+ }
340
+ }
341
+
342
+ function workingTreeIsDirty(dir) {
343
+ const out = capture("git", ["-C", dir, "status", "--porcelain"], { env: gitEnv() });
344
+ return out == null ? false : out.length > 0;
345
+ }
346
+
347
+ /** Normalize a git remote for equality comparison: lowercase, drop a trailing
348
+ * `.git`/slash, and fold the scp-shorthand `git@host:org/repo` into the same
349
+ * `host/org/repo` shape an `ssh://`/`https://` URL produces. Best-effort —
350
+ * returns the trimmed lowercased string when it cannot parse a URL. */
351
+ export function normalizeRemote(url) {
352
+ if (typeof url !== "string") return "";
353
+ let s = url.trim();
354
+ // scp shorthand → ssh URL shape for comparison.
355
+ const scp = s.match(/^[^/]+@([^:]+):(.+)$/);
356
+ if (scp && !s.includes("://")) s = `ssh://${scp[1]}/${scp[2]}`;
357
+ try {
358
+ const u = new URL(s);
359
+ s = `${u.host}${u.pathname}`;
360
+ } catch {
361
+ /* leave as-is */
362
+ }
363
+ return s.toLowerCase().replace(/\.git$/, "").replace(/\/+$/, "");
364
+ }
365
+
366
+ // ---------------------------------------------------------------------------
367
+ // Env creation/reconciliation — mirrors scripts/setup.sh's env block, but in
368
+ // node (no openssl dependency: randomBytes is cross-platform).
369
+ // ---------------------------------------------------------------------------
370
+
371
+ const RUNTIME_MODE = { dev: "development", prod: "production" };
372
+
373
+ // The setup subprocess overlays `process.env` over the target's `.env.local`
374
+ // (collectEnvironment in index.mjs), so an EXPORTED runtime-mode var would win
375
+ // over the `--mode` we just wrote. These are the keys it reads; install refuses
376
+ // an ambient value that contradicts `--mode` BEFORE doing any heavy work.
377
+ const RUNTIME_MODE_ENV_KEYS = ["CINATRA_RUNTIME_MODE", "APP_RUNTIME_MODE"];
378
+
379
+ function normalizeRuntimeModeValue(value) {
380
+ const v = String(value ?? "").trim().toLowerCase();
381
+ if (v.startsWith("prod")) return "production";
382
+ if (v.startsWith("dev")) return "development";
383
+ return null;
384
+ }
385
+
386
+ /** Throw if an exported runtime-mode var contradicts `--mode` (it would silently
387
+ * win when setup overlays process.env over .env.local). */
388
+ export function assertAmbientModeMatches(mode, env = process.env) {
389
+ const wantMode = RUNTIME_MODE[mode];
390
+ for (const key of RUNTIME_MODE_ENV_KEYS) {
391
+ const raw = env[key];
392
+ if (typeof raw === "string" && raw.trim().length > 0) {
393
+ const ambient = normalizeRuntimeModeValue(raw);
394
+ if (ambient && ambient !== wantMode) {
395
+ throw new Error(
396
+ `Exported ${key}=${raw.trim()} conflicts with --mode ${mode} (${wantMode}). ` +
397
+ `setup overlays the shell environment over .env.local, so this would mis-mode the install. ` +
398
+ `Unset ${key} (or align it with --mode) and retry.`,
399
+ );
400
+ }
401
+ }
402
+ }
403
+ }
404
+
405
+ function readEnvMode(envPath) {
406
+ try {
407
+ for (const line of readFileSync(envPath, "utf8").split("\n")) {
408
+ const m = line.match(/^CINATRA_RUNTIME_MODE=(.*)$/);
409
+ if (m) return m[1].replace(/['"]/g, "").trim();
410
+ }
411
+ } catch {
412
+ /* absent */
413
+ }
414
+ return null;
415
+ }
416
+
417
+ /** Create `.env.local` from `.env.example` (fresh secret + runtime mode), or —
418
+ * if it already exists — refuse a mode mismatch (like setup.sh) and otherwise
419
+ * leave it untouched unless `--reset-env`. */
420
+ export function ensureEnvLocal({ targetDir, mode, resetEnv = false, log = console.log }) {
421
+ const envPath = path.join(targetDir, ".env.local");
422
+ const examplePath = path.join(targetDir, ".env.example");
423
+ const wantMode = RUNTIME_MODE[mode];
424
+
425
+ if (existsSync(envPath) && !resetEnv) {
426
+ const current = readEnvMode(envPath);
427
+ const normalized = current
428
+ ? current.startsWith("prod")
429
+ ? "production"
430
+ : current.startsWith("dev")
431
+ ? "development"
432
+ : current
433
+ : null;
434
+ if (normalized && normalized !== wantMode) {
435
+ throw new Error(
436
+ `.env.local has CINATRA_RUNTIME_MODE=${normalized} but --mode ${mode} was requested. ` +
437
+ `Update or remove ${envPath}, or pass --reset-env to regenerate it.`,
438
+ );
439
+ }
440
+ log(` .env.local already exists (${normalized ?? "mode unset"}) — preserving it (pass --reset-env to regenerate).`);
441
+ return { created: false, envPath };
442
+ }
443
+
444
+ if (!existsSync(examplePath)) {
445
+ throw new Error(`Cannot create .env.local — ${examplePath} is missing from the cloned checkout.`);
446
+ }
447
+ copyFileSync(examplePath, envPath);
448
+ const secret = randomBytes(32).toString("hex");
449
+ let body = readFileSync(envPath, "utf8");
450
+ body = upsertEnvKey(body, "BETTER_AUTH_SECRET", secret);
451
+ body = upsertEnvKey(body, "CINATRA_RUNTIME_MODE", wantMode);
452
+ writeFileSync(envPath, body);
453
+ log(` .env.local created from .env.example with a fresh BETTER_AUTH_SECRET and CINATRA_RUNTIME_MODE=${wantMode}.`);
454
+ return { created: true, envPath };
455
+ }
456
+
457
+ /** Replace the value of `KEY=` in-place if the key line exists, else append. */
458
+ function upsertEnvKey(body, key, value) {
459
+ const re = new RegExp(`^${key}=.*$`, "m");
460
+ if (re.test(body)) return body.replace(re, `${key}=${value}`);
461
+ const sep = body.endsWith("\n") || body.length === 0 ? "" : "\n";
462
+ return `${body}${sep}${key}=${value}\n`;
463
+ }
464
+
465
+ // ---------------------------------------------------------------------------
466
+ // Infra (docker compose up + wait), pre-install-safe.
467
+ // ---------------------------------------------------------------------------
468
+
469
+ function bringUpInfra({ targetDir, log = console.log }) {
470
+ log("- Starting infrastructure (Postgres + Redis + Nango)…");
471
+ runOrThrow(
472
+ "docker",
473
+ ["compose", "-f", "docker-compose.yml", "-f", "docker-compose.dev.yml", "up", "-d"],
474
+ "docker compose up failed — is the Docker daemon running?",
475
+ { cwd: targetDir },
476
+ );
477
+ waitForCompose(targetDir, "postgres", ["pg_isready", "-U", "postgres"], "Postgres", log);
478
+ waitForCompose(targetDir, "redis", ["redis-cli", "ping"], "Redis", log);
479
+ waitForNango(log);
480
+ }
481
+
482
+ function waitForCompose(targetDir, service, readyCmd, label, log, maxAttempts = 60) {
483
+ for (let i = 0; i < maxAttempts; i += 1) {
484
+ const r = spawnSync("docker", ["compose", "exec", "-T", service, ...readyCmd], {
485
+ stdio: "ignore",
486
+ cwd: targetDir,
487
+ });
488
+ if (r.status === 0) {
489
+ log(` ${label} is ready.`);
490
+ return;
491
+ }
492
+ spawnSync("sleep", ["1"]);
493
+ }
494
+ throw new Error(`${label} did not become ready within ${maxAttempts} seconds.`);
495
+ }
496
+
497
+ function waitForNango(log, maxAttempts = 60) {
498
+ for (let i = 0; i < maxAttempts; i += 1) {
499
+ const r = spawnSync("curl", ["-sf", "http://127.0.0.1:3003/health"], {
500
+ stdio: "ignore",
501
+ timeout: 3000,
502
+ });
503
+ if (r.status === 0) {
504
+ log(" Nango is ready.");
505
+ return;
506
+ }
507
+ spawnSync("sleep", ["2"]);
508
+ }
509
+ // Non-fatal: Nango can lag; setup re-probes. Warn, don't abort.
510
+ log(" ⚠ Nango did not report healthy in time — continuing; `cinatra setup` will re-check.");
511
+ }
512
+
513
+ // ---------------------------------------------------------------------------
514
+ // pnpm + setup subprocess.
515
+ // ---------------------------------------------------------------------------
516
+
517
+ function pnpmInstall({ targetDir, usePnpmDirect, log = console.log }) {
518
+ log("- Installing dependencies (pnpm install)…");
519
+ if (usePnpmDirect) {
520
+ runOrThrow("pnpm", ["install"], "pnpm install failed.", { cwd: targetDir });
521
+ } else {
522
+ runOrThrow("corepack", ["pnpm", "install"], "corepack pnpm install failed.", { cwd: targetDir });
523
+ }
524
+ }
525
+
526
+ /** Acquire the prod required-extension set via the PUBLISHED CLI's own bin,
527
+ * pointed at the target checkout (mirrors scripts/setup.sh prod: extensions
528
+ * acquire-prod → pnpm install → setup prod). The published bin is used (not a
529
+ * target-local `packages/cli/bin`) because that path is removed at P2. */
530
+ function acquireProdExtensions({ targetDir, log = console.log }) {
531
+ log("- Acquiring required extensions (pinned + integrity-verified)…");
532
+ runOrThrow(
533
+ process.execPath,
534
+ [PUBLISHED_CLI_BIN, "extensions", "acquire-prod"],
535
+ "extensions acquire-prod failed.",
536
+ {
537
+ cwd: targetDir,
538
+ // Pin the repo root so the child anchors on the freshly-cloned target,
539
+ // not an ambient checkout the published bin happens to sit near.
540
+ env: { ...process.env, CINATRA_REPO_ROOT: targetDir },
541
+ },
542
+ );
543
+ }
544
+
545
+ function runSetupInTarget({ targetDir, mode, skipDevApps, log = console.log }) {
546
+ const setupArgs = [PUBLISHED_CLI_BIN, "setup", mode];
547
+ if (mode === "dev" && skipDevApps) setupArgs.push("--skip-dev-apps");
548
+ log(`- Running \`cinatra setup ${mode}\` inside ${targetDir}…`);
549
+ runOrThrow(process.execPath, setupArgs, `cinatra setup ${mode} failed inside the target.`, {
550
+ cwd: targetDir,
551
+ // Defensive belt-and-braces: the cwd-walk already resolves the root, but
552
+ // pin CINATRA_REPO_ROOT so a stray ambient value can't redirect the child,
553
+ // and pin the runtime mode to the one we just wrote into .env.local so an
554
+ // (unset-but-later-exported) ambient value can never mis-mode the child.
555
+ env: {
556
+ ...process.env,
557
+ CINATRA_REPO_ROOT: targetDir,
558
+ CINATRA_RUNTIME_MODE: RUNTIME_MODE[mode],
559
+ },
560
+ });
561
+ }
562
+
563
+ // ---------------------------------------------------------------------------
564
+ // Interactive helpers.
565
+ // ---------------------------------------------------------------------------
566
+
567
+ async function promptLine(question, fallback) {
568
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return fallback;
569
+ const { createInterface } = await import("node:readline/promises");
570
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
571
+ try {
572
+ const answer = (await rl.question(question)).trim();
573
+ return answer.length ? answer : fallback;
574
+ } finally {
575
+ rl.close();
576
+ }
577
+ }
578
+
579
+ async function confirm(question, { yes }) {
580
+ if (yes) return true;
581
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
582
+ const answer = await promptLine(`${question} [y/N]: `, "");
583
+ return /^y(es)?$/i.test(answer);
584
+ }
585
+
586
+ // ---------------------------------------------------------------------------
587
+ // The command.
588
+ // ---------------------------------------------------------------------------
589
+
590
+ export async function runInstall(argv = [], { log = console.log } = {}) {
591
+ const opts = parseInstallArgs(argv);
592
+
593
+ // 1. Resolve the target dir (prompt on a TTY when not given).
594
+ let targetDir = opts.dir;
595
+ if (!targetDir) {
596
+ const def = path.resolve(process.cwd(), DEFAULT_INSTALL_DIRNAME);
597
+ const answer = await promptLine(
598
+ `Where should Cinatra be installed? [${def}]: `,
599
+ def,
600
+ );
601
+ targetDir = answer;
602
+ }
603
+ targetDir = path.resolve(targetDir);
604
+
605
+ // 2. PREFLIGHT FIRST — before any download. Fail fast with the full list.
606
+ log("Checking requirements…");
607
+ const pre = runPreflight({ mode: opts.mode, targetDir, noInfra: opts.noInfra });
608
+ for (const w of pre.warnings) log(` ⚠ ${w}`);
609
+ if (!pre.ok) {
610
+ const lines = pre.failures.map((f) => ` ✗ ${f}`).join("\n");
611
+ throw new Error(`Requirements check failed:\n${lines}`);
612
+ }
613
+ log(" Requirements OK.");
614
+
615
+ // Refuse an exported runtime-mode that contradicts --mode BEFORE any download
616
+ // (setup would otherwise overlay it over .env.local and mis-mode the install).
617
+ assertAmbientModeMatches(opts.mode);
618
+
619
+ // 3. Resolve install location state + idempotent/dirty/force semantics.
620
+ const targetExists = existsSync(targetDir);
621
+ const alreadyCheckout = targetExists && isCinatraCheckout(targetDir);
622
+ if (targetExists && !isEmptyDir(targetDir) && !alreadyCheckout && !opts.force) {
623
+ throw new Error(
624
+ `Target ${targetDir} already exists and is not a cinatra checkout (and is not empty). ` +
625
+ `Choose another --dir, or pass --force only if you are certain (it will clone INTO it).`,
626
+ );
627
+ }
628
+
629
+ // 4. Clone or update the host repo at --ref; record the resolved SHA.
630
+ const resolvedSha = await cloneOrUpdateHost({
631
+ targetDir,
632
+ repoUrl: opts.repoUrl,
633
+ ref: opts.ref,
634
+ alreadyCheckout,
635
+ force: opts.force,
636
+ yes: opts.yes,
637
+ log,
638
+ });
639
+
640
+ // Re-verify we now have a real checkout before touching its package.json.
641
+ if (!isCinatraCheckout(targetDir)) {
642
+ throw new Error(
643
+ `After cloning, ${targetDir} is not a valid cinatra checkout ` +
644
+ `(missing pnpm-workspace.yaml or packages/migrations/package.json). The --ref "${opts.ref}" may be invalid.`,
645
+ );
646
+ }
647
+ log(`✓ Cinatra checked out at ${targetDir} @ ${resolvedSha} (ref: ${opts.ref}).`);
648
+
649
+ // 5. Create/reconcile the env (BEFORE infra so a mode mismatch fails fast).
650
+ log("- Configuring environment…");
651
+ ensureEnvLocal({ targetDir, mode: opts.mode, resetEnv: opts.resetEnv, log });
652
+
653
+ // 6. Bring up + wait for docker infra (skippable for external infra).
654
+ if (opts.noInfra) {
655
+ log("- Skipping infrastructure startup (--no-infra). Ensure Postgres/Redis/Nango are reachable before setup.");
656
+ } else {
657
+ bringUpInfra({ targetDir, log });
658
+ }
659
+
660
+ // 7. Clone ONLY the declared companion repos, THEN install, THEN setup.
661
+ // Ordering mirrors scripts/setup.sh: the root declares `workspace:*` deps
662
+ // on the extension packages, so the extensions must be on disk before the
663
+ // first `pnpm install` resolves the workspace.
664
+ const usePnpmDirect = !commandExists("corepack", ["--version"]) && commandExists("pnpm", ["--version"]);
665
+
666
+ if (opts.mode === "dev") {
667
+ log("- Cloning declared companion extension repos (cinatra.devExtensions)…");
668
+ const extResult = await syncCinatraDevExtensions({
669
+ repoRoot: targetDir,
670
+ targetRoot: targetDir,
671
+ argv: [],
672
+ env: process.env,
673
+ log,
674
+ });
675
+ if (extResult?.skipped) {
676
+ log(` Dev extensions: skipped (${extResult.reason}).`);
677
+ }
678
+
679
+ if (opts.noInstall) {
680
+ log("- Skipping dependency install + setup (--no-install). Checkout + env are ready; run `pnpm install && cinatra setup dev` inside the target when ready.");
681
+ } else {
682
+ pnpmInstall({ targetDir, usePnpmDirect, log });
683
+ if (opts.noSetup) {
684
+ log("- Skipping setup (--no-setup). Checkout + deps are ready; run `cinatra setup dev` inside the target when ready.");
685
+ } else {
686
+ // devApps are cloned by `setup dev` itself; passing --skip-dev-apps
687
+ // through honors the operator's choice. (We do NOT sync devApps here to
688
+ // avoid double-cloning.)
689
+ runSetupInTarget({ targetDir, mode: "dev", skipDevApps: opts.skipDevApps, log });
690
+ }
691
+ }
692
+ } else if (opts.noInstall) {
693
+ log("- Skipping dependency install + setup (--no-install). Run `pnpm install && cinatra extensions acquire-prod && pnpm install && cinatra setup prod` inside the target when ready.");
694
+ } else {
695
+ // prod: install → acquire-prod → install → setup prod (mirrors setup.sh).
696
+ pnpmInstall({ targetDir, usePnpmDirect, log });
697
+ acquireProdExtensions({ targetDir, log });
698
+ pnpmInstall({ targetDir, usePnpmDirect, log });
699
+ if (opts.noSetup) {
700
+ log("- Skipping setup (--no-setup). Run `cinatra setup prod` inside the target when ready.");
701
+ } else {
702
+ runSetupInTarget({ targetDir, mode: "prod", skipDevApps: false, log });
703
+ }
704
+ }
705
+
706
+ // 8. Done.
707
+ log("");
708
+ log("✓ Cinatra install complete.");
709
+ log(` Directory: ${targetDir}`);
710
+ log(` Ref / commit: ${opts.ref} (${resolvedSha})`);
711
+ log(` Mode: ${opts.mode}`);
712
+ log("");
713
+ log(" Next:");
714
+ log(` cd ${targetDir}`);
715
+ log(" pnpm dev # start the app at http://localhost:3000");
716
+ log(" The first user to register becomes the admin.");
717
+ return { targetDir, ref: opts.ref, sha: resolvedSha, mode: opts.mode };
718
+ }
719
+
720
+ /** Clone a fresh host repo or update an existing checkout to `ref`; return the
721
+ * resolved commit SHA. Refuses a dirty checkout unless --force (stash-then-
722
+ * reset), and refuses to update a checkout whose origin is a different repo. */
723
+ async function cloneOrUpdateHost({ targetDir, repoUrl, ref, alreadyCheckout, force, yes, log }) {
724
+ if (alreadyCheckout) {
725
+ log(`- Existing cinatra checkout at ${targetDir} — updating to ref "${ref}"…`);
726
+ const currentRef = capture("git", ["-C", targetDir, "rev-parse", "--short", "HEAD"], { env: gitEnv() });
727
+ if (currentRef) log(` Current commit: ${currentRef}`);
728
+
729
+ // Verify the existing checkout's origin matches --repo-url. On a re-run an
730
+ // operator may have passed a different (or default) --repo-url; updating a
731
+ // checkout from a DIFFERENT remote would be a silent surprise. Fail loud.
732
+ const existingOrigin = capture("git", ["-C", targetDir, "remote", "get-url", "origin"], { env: gitEnv() });
733
+ if (existingOrigin && normalizeRemote(existingOrigin) !== normalizeRemote(repoUrl)) {
734
+ throw new Error(
735
+ `Refusing to update ${targetDir}: its origin is "${existingOrigin}" but --repo-url is "${repoUrl}". ` +
736
+ `Point --repo-url at the existing origin, or choose a fresh --dir.`,
737
+ );
738
+ }
739
+
740
+ if (workingTreeIsDirty(targetDir)) {
741
+ if (!force) {
742
+ throw new Error(
743
+ `Refusing to update ${targetDir}: the working tree has uncommitted changes. ` +
744
+ `Commit/stash them, or re-run with --force (which stashes them first).`,
745
+ );
746
+ }
747
+ log(" --force: stashing local changes (including untracked) before update…");
748
+ const stash = git(["-C", targetDir, "stash", "push", "--include-untracked", "-m", "cinatra install --force"]);
749
+ if (stash.status !== 0) {
750
+ throw new Error(`git stash failed; refusing to hard-update a dirty tree: ${(stash.stderr ?? "").trim()}`);
751
+ }
752
+ log(` Local changes stashed — recover via: git -C ${targetDir} stash list && git -C ${targetDir} stash pop`);
753
+ }
754
+
755
+ const fetch = git(["-C", targetDir, "fetch", "origin", ref, "--tags"]);
756
+ if (fetch.status !== 0) {
757
+ throw new Error(`git fetch origin ${ref} failed: ${(fetch.stderr ?? "").trim()}`);
758
+ }
759
+ } else {
760
+ if (existsSync(targetDir) && !isEmptyDir(targetDir)) {
761
+ // Non-empty + --force was confirmed above; clone into it is unsafe, so
762
+ // require an explicit confirmation that we may clone into a NON-empty dir.
763
+ const ok = await confirm(`Clone INTO non-empty ${targetDir}? Existing contents may collide.`, { yes });
764
+ if (!ok) throw new Error(`Aborted: ${targetDir} is not empty. Choose an empty --dir.`);
765
+ }
766
+ mkdirSync(targetDir, { recursive: true });
767
+ log(`- Cloning ${repoUrl} into ${targetDir}…`);
768
+ const clone = git(["clone", "--", repoUrl, targetDir]);
769
+ if (clone.status !== 0) {
770
+ throw new Error(
771
+ `git clone failed: ${(clone.stderr ?? "").trim()}\n` +
772
+ ` Check network access and that you can read ${repoUrl} ` +
773
+ `(use --repo-url for an SSH/token remote if the repo is private).`,
774
+ );
775
+ }
776
+ }
777
+
778
+ // Resolve `ref` to a commit and check it out detached-then-by-name so both a
779
+ // branch and a raw sha work. We checkout the FETCH_HEAD/ref so an update lands
780
+ // on the requested ref deterministically.
781
+ const checkout = git(["-C", targetDir, "checkout", ref]);
782
+ if (checkout.status !== 0) {
783
+ // Fall back to FETCH_HEAD (covers a freshly-fetched sha/tag not yet a local ref).
784
+ const fh = git(["-C", targetDir, "checkout", "FETCH_HEAD"]);
785
+ if (fh.status !== 0) {
786
+ throw new Error(
787
+ `Could not check out ref "${ref}": ${(checkout.stderr ?? "").trim()}. ` +
788
+ `Verify the branch/tag/sha exists in ${repoUrl}.`,
789
+ );
790
+ }
791
+ } else if (alreadyCheckout) {
792
+ // For a branch update, fast-forward to the fetched tip. A divergent local
793
+ // branch fails --ff-only; surface it (with the --force remediation) rather
794
+ // than silently returning the stale HEAD as "updated".
795
+ const ff = git(["-C", targetDir, "merge", "--ff-only", "FETCH_HEAD"]);
796
+ if (ff.status !== 0) {
797
+ if (!force) {
798
+ throw new Error(
799
+ `Could not fast-forward ${targetDir} to the fetched "${ref}" tip ` +
800
+ `(the local branch has diverged): ${(ff.stderr ?? "").trim()}. ` +
801
+ `Reconcile manually, or re-run with --force to hard-reset to the fetched tip.`,
802
+ );
803
+ }
804
+ log(" --force: local branch diverged — hard-resetting to the fetched tip…");
805
+ const reset = git(["-C", targetDir, "reset", "--hard", "FETCH_HEAD"]);
806
+ if (reset.status !== 0) {
807
+ throw new Error(`git reset --hard FETCH_HEAD failed: ${(reset.stderr ?? "").trim()}`);
808
+ }
809
+ }
810
+ }
811
+
812
+ const sha = capture("git", ["-C", targetDir, "rev-parse", "HEAD"], { env: gitEnv() });
813
+ if (!sha) throw new Error(`Could not resolve the checked-out commit in ${targetDir}.`);
814
+ return sha;
815
+ }