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,390 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Declarative command table for the `cinatra` CLI (cinatra#255 Stage-1).
3
+ //
4
+ // Plain ESM `.mjs`, NO imports, NO heavy deps — importable from anywhere
5
+ // (including the eager-`pg`-free unit tests). This module owns the DECLARATIVE
6
+ // shape of the command surface (the descriptors) and the PURE matching +
7
+ // help-index logic; `index.mjs` owns the HANDLERS (keyed by `id`) that close
8
+ // over the run* implementations and their lazy `import()`s.
9
+ //
10
+ // Why the split: the dispatcher in `index.mjs` was a hand-maintained ~200-line
11
+ // `if`-chain and the help banner (`printHelp`) was a separate hand-maintained
12
+ // string — the two drifted independently. The descriptors below are the single
13
+ // source of truth for "what commands exist"; the matcher replaces the if-chain
14
+ // (first-match-wins, identical semantics) and `buildHelpIndex` lets a drift test
15
+ // assert the help banner and the dispatcher stay in lockstep.
16
+ //
17
+ // IMPORTANT — behavior-preserving contract (do not "improve" without care):
18
+ // * Ordering is significant. `matchDescriptor` scans the array top-to-bottom
19
+ // and returns the FIRST match, mirroring the original if-chain exactly. Do
20
+ // not reorder for aesthetics, and do not switch to a trie / longest-match.
21
+ // * Match kinds mirror the original guards precisely:
22
+ // - "command" : matches on `argv[0]` ALONE, ignoring `mode`
23
+ // (e.g. `status`, `doctor` — `cinatra status x` still
24
+ // routed to status, as the original `command===` did).
25
+ // - "command+mode" : matches `argv[0]` AND `argv[1]`.
26
+ // - "command+mode+sub": matches `argv[0]`, `argv[1]`, AND `argv[2]`
27
+ // (the `rest[0]` 3-token guards: `mcp llm-access …`).
28
+ // * Handlers receive the LEGACY `rest = argv.slice(2)` (NOT a descriptor-
29
+ // relative remainder). The 3-token handlers re-slice themselves
30
+ // (`mcp llm-access verify` uses `rest.slice(1)`), exactly as before.
31
+ // * `hidden: true` marks dispatch-only descriptors that have no standalone
32
+ // help row (the env-driven `setup` no-mode entry and the removed
33
+ // `mcp tunnel` stub). The dispatcher still routes them; the help banner does
34
+ // not advertise them. So descriptors are NOT 1:1 with help rows.
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /**
38
+ * @typedef {Object} CommandDescriptor
39
+ * @property {string} id Stable handler key (index.mjs HANDLERS[id]).
40
+ * @property {string[]} path The literal token(s) that route to this command.
41
+ * @property {"command"|"command-no-mode"|"command+mode"|"command+mode+sub"} match Match kind.
42
+ * @property {boolean} [hidden] Dispatch-only (no standalone help row) when true.
43
+ * @property {string} [summary] One-line description for the help index.
44
+ */
45
+
46
+ /**
47
+ * The canonical command surface, in dispatch order. The order MUST match the
48
+ * original `runCli` if-chain so first-match-wins semantics are preserved.
49
+ *
50
+ * @type {CommandDescriptor[]}
51
+ */
52
+ export const COMMAND_DESCRIPTORS = [
53
+ {
54
+ id: "install",
55
+ path: ["install"],
56
+ match: "command",
57
+ summary: "Bootstrap a Cinatra dev/prod instance from zero (clone, env, infra, setup).",
58
+ },
59
+ {
60
+ id: "login",
61
+ path: ["login"],
62
+ match: "command",
63
+ summary: "Sign in to a Cinatra instance (browser OAuth) and cache the token.",
64
+ },
65
+ {
66
+ id: "status",
67
+ path: ["status"],
68
+ match: "command",
69
+ summary: "Show current setup state (auth tables, user count, MCP config).",
70
+ },
71
+ {
72
+ id: "skills.reset-repo",
73
+ path: ["skills", "reset-repo"],
74
+ match: "command+mode",
75
+ summary: "Force-push the local skills store to the connected GitHub repo (dev only).",
76
+ },
77
+ {
78
+ id: "extensions.purge",
79
+ path: ["extensions", "purge"],
80
+ match: "command+mode",
81
+ summary: "Fully remove an extension everywhere (dev only; loopback; destructive).",
82
+ },
83
+ {
84
+ id: "extensions.acquire-prod",
85
+ path: ["extensions", "acquire-prod"],
86
+ match: "command+mode",
87
+ summary: "Download the production required-extension set into extensions/.",
88
+ },
89
+ {
90
+ id: "extensions.submit",
91
+ path: ["extensions", "submit"],
92
+ match: "command+mode",
93
+ summary: "Submit a built extension tarball to the Cinatra Marketplace for review.",
94
+ },
95
+ {
96
+ id: "mcp.tunnel",
97
+ path: ["mcp", "tunnel"],
98
+ match: "command+mode",
99
+ hidden: true, // Removed feature — routes to a guidance error, not advertised.
100
+ },
101
+ {
102
+ id: "backup.create",
103
+ path: ["backup", "create"],
104
+ match: "command+mode",
105
+ summary: "Export a full backup bundle to data/backups/.",
106
+ },
107
+ {
108
+ id: "backup.import",
109
+ path: ["backup", "import"],
110
+ match: "command+mode",
111
+ summary: "Import a backup bundle (destructive — requires --yes).",
112
+ },
113
+ {
114
+ id: "backup.export-api-configs",
115
+ path: ["backup", "export-api-configs"],
116
+ match: "command+mode",
117
+ summary: "Export connector_config:* + openai_connection metadata to JSON.",
118
+ },
119
+ {
120
+ id: "backup.import-api-configs",
121
+ path: ["backup", "import-api-configs"],
122
+ match: "command+mode",
123
+ summary: "Import API configs from an export-api-configs JSON file.",
124
+ },
125
+ {
126
+ id: "setup",
127
+ path: ["setup"],
128
+ match: "command-no-mode", // ONLY when no `mode` token follows (env-driven dev|prod).
129
+ hidden: true, // No standalone help row.
130
+ },
131
+ {
132
+ id: "setup.dev|prod",
133
+ path: ["setup", "dev|prod"],
134
+ match: "command+mode",
135
+ summary: "Prepare Better Auth, schema, Nango, MCP server, and OAuth clients.",
136
+ },
137
+ {
138
+ id: "setup.nango",
139
+ path: ["setup", "nango"],
140
+ match: "command+mode",
141
+ summary: "Configure Nango administration only.",
142
+ },
143
+ {
144
+ id: "setup.branch",
145
+ path: ["setup", "branch"],
146
+ match: "command+mode",
147
+ summary: "Provision an isolated dev environment for the current git worktree.",
148
+ },
149
+ {
150
+ id: "teardown.branch",
151
+ path: ["teardown", "branch"],
152
+ match: "command+mode",
153
+ summary: "Remove the isolated Postgres schema for the current git worktree.",
154
+ },
155
+ {
156
+ id: "setup.clone",
157
+ path: ["setup", "clone"],
158
+ match: "command+mode",
159
+ summary: "Create + provision a dormant deep-fork clone.",
160
+ },
161
+ {
162
+ id: "clone.refresh-seed",
163
+ path: ["clone", "refresh-seed"],
164
+ match: "command+mode",
165
+ summary: "(Re)build the cinatra_seed template database.",
166
+ },
167
+ {
168
+ id: "clone.prune",
169
+ path: ["clone", "prune"],
170
+ match: "command+mode",
171
+ summary: "Destroy a clone (drops its DB, cleans Redis, releases the slot).",
172
+ },
173
+ {
174
+ id: "clone.list",
175
+ path: ["clone", "list"],
176
+ match: "command+mode",
177
+ summary: "List registered clones (slug, ports, database, state, worktree).",
178
+ },
179
+ {
180
+ id: "clone.start",
181
+ path: ["clone", "start"],
182
+ match: "command+mode",
183
+ summary: "Start a registered clone.",
184
+ },
185
+ {
186
+ id: "clone.stop",
187
+ path: ["clone", "stop"],
188
+ match: "command+mode",
189
+ summary: "Stop a registered clone.",
190
+ },
191
+ {
192
+ id: "clone.status",
193
+ path: ["clone", "status"],
194
+ match: "command+mode",
195
+ summary: "Show a clone's predicted-vs-registered runtime status.",
196
+ },
197
+ {
198
+ id: "clone.slug-for-worktree",
199
+ path: ["clone", "slug-for-worktree"],
200
+ match: "command+mode",
201
+ summary: "Registry lookup for shell hooks (resolve a worktree to its slug).",
202
+ },
203
+ {
204
+ id: "db.migrate",
205
+ path: ["db", "migrate"],
206
+ match: "command+mode",
207
+ summary: "Apply the additive bootstrap + versioned core migration chain.",
208
+ },
209
+ {
210
+ id: "dev.refresh",
211
+ path: ["dev", "refresh"],
212
+ match: "command+mode",
213
+ summary: "Reconcile your local dev environment (deps + dev DB schema).",
214
+ },
215
+ {
216
+ id: "dev.tunnel",
217
+ path: ["dev", "tunnel"],
218
+ match: "command+mode",
219
+ summary: "Manage the dev-main Tailscale Funnel (start|stop|status).",
220
+ },
221
+ {
222
+ id: "reset.dev",
223
+ path: ["reset", "dev"],
224
+ match: "command+mode",
225
+ summary: "Reset the development environment (requires --yes; dev only).",
226
+ },
227
+ {
228
+ id: "mcp.llm-access.setup",
229
+ path: ["mcp", "llm-access", "setup"],
230
+ match: "command+mode+sub",
231
+ summary: "Provision OAuth clients for OpenAI, Anthropic, and Gemini (dev only).",
232
+ },
233
+ {
234
+ id: "mcp.llm-access.refresh",
235
+ path: ["mcp", "llm-access", "refresh"],
236
+ match: "command+mode+sub",
237
+ summary: "Rotate all LLM provider client secrets.",
238
+ },
239
+ {
240
+ id: "doctor",
241
+ path: ["doctor"],
242
+ match: "command",
243
+ summary: "READ-ONLY content-editor write-path self-check (the \"done\" gate).",
244
+ },
245
+ {
246
+ id: "mcp.llm-access.verify",
247
+ path: ["mcp", "llm-access", "verify"],
248
+ match: "command+mode+sub",
249
+ summary: "Alias for `cinatra doctor`.",
250
+ },
251
+ {
252
+ id: "agents.install",
253
+ path: ["agents", "install"],
254
+ match: "command+mode",
255
+ summary: "Resolve and install an agent package tree from Verdaccio.",
256
+ },
257
+ {
258
+ id: "agent.export",
259
+ path: ["agent", "export"],
260
+ match: "command+mode",
261
+ summary: "Export an agent template to a portable ZIP archive.",
262
+ },
263
+ {
264
+ id: "agent.import",
265
+ path: ["agent", "import"],
266
+ match: "command+mode",
267
+ summary: "Import an agent template from a ZIP archive created by `agent export`.",
268
+ },
269
+ ];
270
+
271
+ /**
272
+ * Find the first descriptor that matches `argv`, mirroring the original
273
+ * if-chain's first-match-wins semantics. Returns the descriptor, or `null` when
274
+ * nothing matches (the caller then applies its `agents`-no-mode fallback and the
275
+ * unknown-command throw).
276
+ *
277
+ * `path` tokens may use the `a|b` alternation shape (e.g. `setup dev|prod`); a
278
+ * token matches the argv slot when the slot equals the token outright OR is one
279
+ * of the pipe-separated alternatives.
280
+ *
281
+ * @param {CommandDescriptor[]} descriptors
282
+ * @param {string[]} argv
283
+ * @returns {CommandDescriptor|null}
284
+ */
285
+ export function matchDescriptor(descriptors, argv) {
286
+ const [command, mode, sub] = argv;
287
+ for (const d of descriptors) {
288
+ if (descriptorMatches(d, command, mode, sub)) {
289
+ return d;
290
+ }
291
+ }
292
+ return null;
293
+ }
294
+
295
+ /**
296
+ * @param {CommandDescriptor} d
297
+ * @param {string|undefined} command
298
+ * @param {string|undefined} mode
299
+ * @param {string|undefined} sub
300
+ * @returns {boolean}
301
+ */
302
+ function descriptorMatches(d, command, mode, sub) {
303
+ switch (d.match) {
304
+ case "command":
305
+ return tokenMatches(d.path[0], command);
306
+ case "command-no-mode":
307
+ // Matches the bare command ONLY when no `mode` token follows it, mirroring
308
+ // the original `command === "setup" && !mode` guard. `!mode` was truthy for
309
+ // both `undefined` and an empty-string token, so mirror that exactly.
310
+ return tokenMatches(d.path[0], command) && !mode;
311
+ case "command+mode":
312
+ return tokenMatches(d.path[0], command) && tokenMatches(d.path[1], mode);
313
+ case "command+mode+sub":
314
+ return (
315
+ tokenMatches(d.path[0], command) &&
316
+ tokenMatches(d.path[1], mode) &&
317
+ tokenMatches(d.path[2], sub)
318
+ );
319
+ default:
320
+ return false;
321
+ }
322
+ }
323
+
324
+ /**
325
+ * A single path token matches an argv slot. For a plain token, the slot must
326
+ * equal it. For an `a|b` alternation token, the slot must be one of the
327
+ * EXPANDED alternatives — never the literal `"a|b"` string. This mirrors the
328
+ * original `mode === "dev" || mode === "prod"` guard exactly: `cinatra setup dev`
329
+ * and `cinatra setup prod` route, but a literal `cinatra setup "dev|prod"` does
330
+ * NOT (it falls through to the unknown-command path, as before).
331
+ *
332
+ * @param {string} token
333
+ * @param {string|undefined} slot
334
+ * @returns {boolean}
335
+ */
336
+ function tokenMatches(token, slot) {
337
+ if (slot === undefined) return false;
338
+ if (token.includes("|")) {
339
+ // Alternation: match ONLY the expanded alternatives, not the literal token.
340
+ return token.split("|").includes(slot);
341
+ }
342
+ if (token === slot) return true;
343
+ return false;
344
+ }
345
+
346
+ /**
347
+ * A deterministic, human-readable index of the (visible) command surface,
348
+ * derived purely from the descriptors. The drift test snapshots this and
349
+ * asserts every visible command also appears in `printHelp`'s usage block (and
350
+ * vice-versa), so the dispatcher and the banner can never silently diverge.
351
+ *
352
+ * Hidden (dispatch-only) descriptors are excluded — they have no help row.
353
+ *
354
+ * @param {CommandDescriptor[]} descriptors
355
+ * @returns {{ id: string, command: string, summary: string }[]}
356
+ */
357
+ export function buildHelpIndex(descriptors) {
358
+ return descriptors
359
+ .filter((d) => !d.hidden)
360
+ .map((d) => ({
361
+ id: d.id,
362
+ command: d.path.join(" "),
363
+ summary: d.summary ?? "",
364
+ }));
365
+ }
366
+
367
+ /**
368
+ * True when `argv` carries a help request (`--help` or `-h`) as a recognized
369
+ * affordance. The dispatcher uses this to SHORT-CIRCUIT to a usage print BEFORE
370
+ * any handler (and therefore any side effect) runs — this is the guard that
371
+ * stops `cinatra install --help` from kicking off a real from-zero install
372
+ * (cinatra#255 footgun: `--help` was an unknown flag the per-command parsers
373
+ * silently ignored, so the destructive handler executed).
374
+ *
375
+ * Scanning stops at the conventional `--` end-of-flags separator, so a literal
376
+ * `-h` / `--help` that a future command might accept as a positional VALUE
377
+ * (after `--`) is not mistaken for a help request. A `--help`/`-h` BEFORE `--`
378
+ * is always treated as help (the conventional meaning, and no current command
379
+ * takes either token as a value).
380
+ *
381
+ * @param {string[]} argv
382
+ * @returns {boolean}
383
+ */
384
+ export function hasHelpFlag(argv) {
385
+ for (const token of argv) {
386
+ if (token === "--") break; // end-of-flags: anything after is positional.
387
+ if (token === "--help" || token === "-h") return true;
388
+ }
389
+ return false;
390
+ }
@@ -0,0 +1,79 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { defaultRepoSyncDeps, envOverrideVarFor, syncOneRepo } from "./dev-repo-sync.mjs";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Dev-app clone sync.
8
+ //
9
+ // The WordPress plugin (cinatra-ai/wordpress-plugin) and Drupal module
10
+ // (cinatra-ai/drupal-module) are EXTERNAL apps' integration code — they live in
11
+ // their own git repos and ship to WordPress.org / Drupal.org, NOT cinatra's
12
+ // marketplace. For the dev docker stack, `cinatra setup {dev,branch,clone}`
13
+ // clones / fast-forwards them into fixed paths under `dev/` (declared in
14
+ // package.json `cinatra.devApps`). The source of truth is the companion repos,
15
+ // NOT this tree (the clone paths are gitignored).
16
+ //
17
+ // Flags: --skip-dev-apps (skip entirely),
18
+ // --force-dev-apps (override a DIRTY tree only).
19
+ // Per-repo URL overrides via env: CINATRA_<NAME>_REPO_URL (HTTPS or SSH).
20
+ //
21
+ // The five-state tree-safety model + git utilities live in `dev-repo-sync.mjs`.
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export function readDevAppsConfig(repoRoot, readFile = readFileSync) {
25
+ try {
26
+ const pkg = JSON.parse(readFile(path.join(repoRoot, "package.json"), "utf8"));
27
+ const config = pkg?.cinatra?.devApps;
28
+ return config && typeof config === "object" ? config : null;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Sync all configured dev apps into `targetRoot`.
36
+ * - repoRoot: where package.json (the config) lives.
37
+ * - targetRoot: where the clones are materialized (repo root for `setup dev`,
38
+ * the worktree path for `setup branch` / `setup clone`).
39
+ */
40
+ export async function syncDevApps({
41
+ repoRoot,
42
+ targetRoot,
43
+ argv = [],
44
+ env = process.env,
45
+ log = console.log,
46
+ deps,
47
+ } = {}) {
48
+ if (argv.includes("--skip-dev-apps")) {
49
+ log("- Dev apps: skipped (--skip-dev-apps).");
50
+ return { skipped: true, reason: "flag" };
51
+ }
52
+ const config = readDevAppsConfig(repoRoot, deps?.readFile);
53
+ if (!config || Object.keys(config).length === 0) {
54
+ return { skipped: true, reason: "no-config" };
55
+ }
56
+ const force = argv.includes("--force-dev-apps");
57
+ const realDeps = deps ?? defaultRepoSyncDeps();
58
+ const results = [];
59
+ log("- Dev apps:");
60
+ for (const [pkgName, spec] of Object.entries(config)) {
61
+ const url = env[envOverrideVarFor(pkgName)] || spec.url;
62
+ const branch = spec.branch || "main";
63
+ const dest = path.resolve(targetRoot, spec.path);
64
+ results.push(
65
+ syncOneRepo({
66
+ pkgName,
67
+ url,
68
+ branch,
69
+ dest,
70
+ force,
71
+ deps: realDeps,
72
+ log,
73
+ forceFlagHint: "--force-dev-apps",
74
+ stashLabel: "cinatra --force-dev-apps",
75
+ }),
76
+ );
77
+ }
78
+ return { results };
79
+ }
@@ -0,0 +1,91 @@
1
+ // Dev-CLI module discovery (cinatra#151 Stage 5c).
2
+ //
3
+ // Extensions contribute modules to the dev CLI by DECLARING them in their
4
+ // manifest: `cinatra.devCliModules: { "<key>": "./relative/module.mjs" }`.
5
+ // The CLI discovers a key by scanning `extensions/<scope>/<name>/package.json`
6
+ // — it never names a concrete extension package or path. The tailscale
7
+ // provisioning handlers consume the "tailscale-api" / "tailscale-hostname"
8
+ // keys declared by the tailscale connector's manifest.
9
+ //
10
+ // Absence posture (UNCHANGED from the retired literal lazy imports): the
11
+ // extensions tree is a gitignored clone-back target, ABSENT on a fresh
12
+ // checkout until `cinatra setup dev` populates it. When no present extension
13
+ // declares the requested key, the loader throws an Error with
14
+ // `.code = "ERR_MODULE_NOT_FOUND"` — the exact failure class the inline
15
+ // `import()` of a missing path produced — so every caller's existing
16
+ // graceful-degradation guard keeps working.
17
+
18
+ import { readdirSync, readFileSync } from "node:fs";
19
+ import path from "node:path";
20
+ import { pathToFileURL, fileURLToPath } from "node:url";
21
+
22
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
+ // packages/cli/src -> repo root
24
+ const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
25
+
26
+ /**
27
+ * Find the module file declared under `cinatra.devCliModules[key]` by any
28
+ * extension present on disk. Returns an absolute path or null.
29
+ *
30
+ * Deterministic: scopes and package dirs are scanned in sorted order; the
31
+ * first declarer wins (in practice each key has exactly one declarer — a
32
+ * duplicate would indicate two extensions claiming the same CLI surface, and
33
+ * the first sorted one is used).
34
+ */
35
+ export function discoverDevCliModulePath(key, repoRoot = REPO_ROOT) {
36
+ const extRoot = path.join(repoRoot, "extensions");
37
+ let scopes;
38
+ try {
39
+ scopes = readdirSync(extRoot).sort();
40
+ } catch {
41
+ return null;
42
+ }
43
+ for (const scope of scopes) {
44
+ let dirs;
45
+ try {
46
+ dirs = readdirSync(path.join(extRoot, scope)).sort();
47
+ } catch {
48
+ continue;
49
+ }
50
+ for (const dir of dirs) {
51
+ let pkg;
52
+ try {
53
+ pkg = JSON.parse(
54
+ readFileSync(path.join(extRoot, scope, dir, "package.json"), "utf8"),
55
+ );
56
+ } catch {
57
+ continue; // not a package dir
58
+ }
59
+ const declared = pkg?.cinatra?.devCliModules;
60
+ if (!declared || typeof declared !== "object") continue;
61
+ const rel = declared[key];
62
+ if (typeof rel !== "string" || rel.length === 0) continue;
63
+ // Confine the declared path inside the declaring extension dir
64
+ // (a manifest is repo-external data; never let it traverse out).
65
+ const base = path.join(extRoot, scope, dir);
66
+ const resolved = path.resolve(base, rel);
67
+ if (resolved !== base && !resolved.startsWith(base + path.sep)) continue;
68
+ return resolved;
69
+ }
70
+ }
71
+ return null;
72
+ }
73
+
74
+ /**
75
+ * Dynamic-import the module declared under `cinatra.devCliModules[key]`.
76
+ * Throws ERR_MODULE_NOT_FOUND (as `.code`) when no present extension
77
+ * declares the key — same failure class as the retired literal import of a
78
+ * missing extension path, preserving every caller's degradation guard.
79
+ */
80
+ export async function loadDevCliModule(key, repoRoot = REPO_ROOT) {
81
+ const modulePath = discoverDevCliModulePath(key, repoRoot);
82
+ if (!modulePath) {
83
+ const err = new Error(
84
+ `Cannot find module for dev-CLI key "${key}" — no extension present under extensions/ ` +
85
+ `declares cinatra.devCliModules["${key}"] (the extensions tree is populated by \`cinatra setup dev\`).`,
86
+ );
87
+ err.code = "ERR_MODULE_NOT_FOUND";
88
+ throw err;
89
+ }
90
+ return import(pathToFileURL(modulePath).href);
91
+ }
@@ -0,0 +1,117 @@
1
+ // Pure, side-effect-free decision helpers for `cinatra dev refresh`.
2
+ //
3
+ // `cinatra dev refresh` reconciles a contributor's local dev environment
4
+ // (dependencies + dev database schema) to the code they have checked out. It is
5
+ // the idempotent, non-destructive subset of `scripts/setup.sh` minus .env.local
6
+ // creation: the human owns git (pull / checkout), the command never touches it.
7
+ //
8
+ // The flow orchestration (docker / pnpm install / runSetup) lives in index.mjs
9
+ // because it depends on that file's internal helpers. The decision logic that is
10
+ // worth testing in isolation lives here so it can be unit-tested without a live
11
+ // docker stack or database.
12
+
13
+ const DEFAULT_SCHEMA = "cinatra";
14
+ const DEFAULT_QUEUE = "cinatra-background-jobs";
15
+ const DOCKER_FLAG_PREFIX = "--docker=";
16
+
17
+ /**
18
+ * Parse `dev refresh` flags into a normalized docker mode.
19
+ * - `--no-docker` → "off" (takes precedence over --docker=)
20
+ * - `--docker=always` → "always"
21
+ * - `--docker=auto` / absent → "auto"
22
+ * Any other `--docker=<value>` throws.
23
+ */
24
+ export function parseDevRefreshFlags(argv = []) {
25
+ // Reject anything that is not a recognized flag so typos (`--dockr=always`) or a
26
+ // dropped flag (`--rebuild-shell`) fail loudly instead of silently no-opping to
27
+ // the default — a silent `--dockr=always` would run `auto` and surprise the user.
28
+ for (const arg of argv) {
29
+ if (arg === "--no-docker" || arg.startsWith(DOCKER_FLAG_PREFIX)) {
30
+ continue;
31
+ }
32
+ throw new Error(
33
+ `Unknown flag "${arg}" for cinatra dev refresh. Supported flags: --docker=auto|always, --no-docker.`,
34
+ );
35
+ }
36
+
37
+ const dockerArg = argv.find((arg) => arg.startsWith(DOCKER_FLAG_PREFIX));
38
+ if (dockerArg) {
39
+ const value = dockerArg.slice(DOCKER_FLAG_PREFIX.length);
40
+ if (value !== "auto" && value !== "always") {
41
+ throw new Error(
42
+ `Invalid ${DOCKER_FLAG_PREFIX}${value}. Expected --docker=auto, --docker=always, or --no-docker.`,
43
+ );
44
+ }
45
+ }
46
+ // A malformed --docker= value is always rejected above, so typos fail loudly even
47
+ // when combined with --no-docker. Otherwise --no-docker is the most conservative
48
+ // choice and wins over a valid --docker=.
49
+ if (argv.includes("--no-docker")) {
50
+ return { dockerMode: "off" };
51
+ }
52
+ if (dockerArg) {
53
+ return { dockerMode: dockerArg.slice(DOCKER_FLAG_PREFIX.length) };
54
+ }
55
+ return { dockerMode: "auto" };
56
+ }
57
+
58
+ function hostnameOf(url) {
59
+ if (!url) return null;
60
+ try {
61
+ return new URL(url).hostname;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * True when SUPABASE_DB_URL points at the bundled local stack (or is unset —
69
+ * a fresh dev checkout defaults to the local docker Postgres). External
70
+ * (non-localhost) database URLs return false so `auto` mode leaves infra alone.
71
+ */
72
+ export function looksLikeBundledStack(env = {}) {
73
+ const host = hostnameOf(env.SUPABASE_DB_URL);
74
+ if (!host) return true;
75
+ // Node's URL parser returns IPv6 hosts wrapped in brackets, e.g. "[::1]".
76
+ return host === "127.0.0.1" || host === "localhost" || host === "::1" || host === "[::1]";
77
+ }
78
+
79
+ /**
80
+ * True when this checkout is an isolated worktree/clone that borrows the shared
81
+ * main docker stack rather than owning it. Bringing the bundled compose stack up
82
+ * from such a checkout would port-conflict with the main dev server, so `auto`
83
+ * mode must skip docker here. Detected via the markers `cinatra setup branch` /
84
+ * `setup clone` write into the worktree `.env.local`.
85
+ */
86
+ export function isIsolatedWorktree(env = {}) {
87
+ const schema = (env.SUPABASE_SCHEMA || "").trim();
88
+ if (schema && schema !== DEFAULT_SCHEMA) return true;
89
+ if ((env.CINATRA_CLONE_SLUG || "").trim()) return true;
90
+ const queue = (env.BULLMQ_QUEUE_NAME || "").trim();
91
+ if (queue && queue !== DEFAULT_QUEUE) return true;
92
+ return false;
93
+ }
94
+
95
+ /**
96
+ * Decide whether `dev refresh` should run `docker compose up -d`, and why.
97
+ * Returns `{ run, reason }` so the orchestrator can print an explanation either way.
98
+ * - off → never
99
+ * - always → always (forced; the orchestrator treats failure as fatal)
100
+ * - auto → only when this checkout owns the bundled local stack
101
+ */
102
+ export function describeDockerDecision({ dockerMode, env = {} }) {
103
+ if (dockerMode === "off") return { run: false, reason: "--no-docker" };
104
+ if (dockerMode === "always") return { run: true, reason: "--docker=always" };
105
+ if (isIsolatedWorktree(env)) {
106
+ return { run: false, reason: "isolated worktree/clone (it borrows the shared main stack)" };
107
+ }
108
+ if (!looksLikeBundledStack(env)) {
109
+ return { run: false, reason: "external infrastructure (SUPABASE_DB_URL is not localhost)" };
110
+ }
111
+ return { run: true, reason: "bundled local stack" };
112
+ }
113
+
114
+ /** Convenience boolean form of {@link describeDockerDecision}. */
115
+ export function shouldRunDocker({ dockerMode, env = {} }) {
116
+ return describeDockerDecision({ dockerMode, env }).run;
117
+ }