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.
- package/LICENSE +202 -0
- package/README.md +77 -0
- package/bin/cinatra.mjs +8 -0
- package/package.json +32 -0
- package/src/agents-install.mjs +801 -0
- package/src/checkout-resolve.mjs +236 -0
- package/src/cinatra-dev-extensions.mjs +338 -0
- package/src/clone-registry.mjs +623 -0
- package/src/clone-runtime.mjs +543 -0
- package/src/command-table.mjs +390 -0
- package/src/dev-apps.mjs +79 -0
- package/src/dev-cli-modules.mjs +91 -0
- package/src/dev-refresh.mjs +117 -0
- package/src/dev-repo-sync.mjs +297 -0
- package/src/extensions-dependency-gate.mjs +258 -0
- package/src/extensions-submit.mjs +137 -0
- package/src/index.mjs +9203 -0
- package/src/install.mjs +815 -0
- package/src/login.mjs +508 -0
- package/src/marketplace-mcp.mjs +100 -0
- package/src/mcp-public-base-url-shape.mjs +134 -0
- package/src/prod-extension-acquisition.mjs +679 -0
- package/src/seed-local-registry.mjs +538 -0
- package/src/tailscale-provision.mjs +219 -0
- package/src/teardown-config.mjs +113 -0
- package/src/worktree-collision-guard.mjs +157 -0
|
@@ -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
|
+
}
|
package/src/dev-apps.mjs
ADDED
|
@@ -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
|
+
}
|