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,219 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Pure provision-decision helpers.
3
+ //
4
+ // Plain ESM `.mjs` — NO TypeScript, NO `@/` aliases, NO `server-only`,
5
+ // NO `node:child_process`, NO DB, NO network. Same leaf-purity contract
6
+ // as `clone-runtime.mjs` so BOTH the plain-Node CLI (`runCloneStart`)
7
+ // and the `cinatra dev tunnel` verb import the exact same proven decision
8
+ // boundary, hermetically unit-testable without Docker / Tailscale.
9
+ //
10
+ // This module owns two safety-critical, reviewable concerns:
11
+ //
12
+ // MagicDNS hostname-collision guard
13
+ // After a node registers, the registered `Self.DNSName` hostname
14
+ // segment MUST equal `deriveDevTailscaleHostname(...)`. A Tailscale
15
+ // `-1` collision suffix yields a dead predicted URL → callers must
16
+ // fail loud and NOT write `publicBaseUrl`. The guard returns a typed
17
+ // result (never throws an untyped error).
18
+ //
19
+ // Write-vs-skip purity
20
+ // The decision to write `publicBaseUrl` depends ONLY on
21
+ // `(funnelUrl present)` AND `(hostname matches prediction)` — NEVER
22
+ // on a reachability/cert-warmup probe. `shouldWritePublicBaseUrl`
23
+ // takes a SINGLE object argument; that arity is the structural lock
24
+ // (regression-guarded in the test) that a probe arg can never be
25
+ // silently threaded in.
26
+ //
27
+ // `deriveDevTailscaleHostname` is the SINGLE source of truth for the
28
+ // predicted hostname — imported via the exact relative specifier
29
+ // `index.mjs` already resolves; this module NEVER re-derives it.
30
+ //
31
+ // Public surface:
32
+ // - TailscaleProvisionError (typed, `.code`)
33
+ // - extractTailscaleHostnameSegment(dnsNameOrUrl) → string ("" on bad input)
34
+ // - verifyRegisteredHostnameMatchesPrediction({ registered, dbUrl, schema })
35
+ // - shouldWritePublicBaseUrl({ funnelUrl, hostnameCheck })
36
+ // ---------------------------------------------------------------------------
37
+
38
+ // `deriveDevTailscaleHostname` (the single source of truth for the predicted
39
+ // hostname) lives in the gitignored `extensions/cinatra-ai/` clone-back target,
40
+ // ABSENT on a fresh checkout. It is loaded lazily inside
41
+ // `verifyRegisteredHostnameMatchesPrediction` so this module — and any host
42
+ // (CLI) module that statically imports it — resolves on an extension-empty
43
+ // checkout. By the time a provisioning caller invokes verify, `cinatra setup
44
+ // dev` has populated the extension.
45
+
46
+ /**
47
+ * Errors returned (not thrown) by this module are tagged with `.code`
48
+ * so callers can fail loud and map to UI without parsing strings.
49
+ *
50
+ * Mirrors the shape of `TailscaleApiError` in
51
+ * `packages/connector-tailscale/src/tailscale-api.mjs` but is a SIBLING
52
+ * type — we deliberately do NOT import from `index.mjs` (cycle) nor from
53
+ * the connector (keep this leaf dependency-free apart from the pure
54
+ * hostname-derivation single source of truth).
55
+ *
56
+ * Codes:
57
+ * - "tailscale.hostname_collision" registered segment !== prediction
58
+ * - "tailscale.hostname_unresolved" registered DNSName couldn't be parsed
59
+ */
60
+ export class TailscaleProvisionError extends Error {
61
+ /**
62
+ * @param {string} code
63
+ * @param {string} message
64
+ */
65
+ constructor(code, message) {
66
+ super(message);
67
+ this.name = "TailscaleProvisionError";
68
+ this.code = code;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Extract the Tailscale hostname label from a registered `Self.DNSName`
74
+ * or a full Funnel URL.
75
+ *
76
+ * Normalisation:
77
+ * 1. Coerce to string; strip a leading `https://` (or `http://`) scheme.
78
+ * 2. Strip a single trailing `/`.
79
+ * 3. Strip a single trailing `.` (MagicDNS FQDN trailing dot).
80
+ * 4. The input MUST end in `.ts.net`. No `.ts.net` suffix → "".
81
+ * 5. The shape is `<hostname>.<tailnet>.ts.net` where `<tailnet>` may
82
+ * itself contain dots (e.g. `acme.github` →
83
+ * `myhost.foo.ts.net`). There MUST be at least one hostname
84
+ * label AND a non-empty tailnet portion before `.ts.net`. The
85
+ * hostname is ONLY the first label (everything up to the first `.`).
86
+ * The tailnet remainder is NOT returned and NOT compared by callers.
87
+ * 6. Any malformed / garbage / no-suffix / zero-label input → "" (NEVER
88
+ * throws). The caller's verify step converts "" into a typed
89
+ * `tailscale.hostname_unresolved` error → fail-loud, no write.
90
+ *
91
+ * @param {string | null | undefined} dnsNameOrUrl
92
+ * @returns {string} hostname label, or "" when it cannot be resolved
93
+ */
94
+ export function extractTailscaleHostnameSegment(dnsNameOrUrl) {
95
+ let s = String(dnsNameOrUrl ?? "").trim();
96
+ if (!s) return "";
97
+
98
+ // 1. Strip scheme (https:// preferred; tolerate http://).
99
+ s = s.replace(/^https?:\/\//i, "");
100
+ // 2 + 3. Strip a single trailing slash, then a single trailing dot
101
+ // (order tolerates both `…ts.net/` and `…ts.net./`).
102
+ s = s.replace(/\/+$/, "");
103
+ s = s.replace(/\.+$/, "");
104
+ if (!s) return "";
105
+
106
+ // 4. Require the `.ts.net` tailnet TLD.
107
+ const SUFFIX = ".ts.net";
108
+ if (!s.toLowerCase().endsWith(SUFFIX)) return "";
109
+
110
+ // 5. Everything before `.ts.net` is `<hostname>.<tailnet…>`. Require a
111
+ // non-empty hostname label AND a non-empty tailnet portion (so a
112
+ // bare `foo.ts.net` with zero tailnet labels is rejected).
113
+ const beforeSuffix = s.slice(0, s.length - SUFFIX.length);
114
+ if (!beforeSuffix) return "";
115
+
116
+ const firstDot = beforeSuffix.indexOf(".");
117
+ if (firstDot <= 0) return ""; // no hostname label, or no tailnet label
118
+ const hostname = beforeSuffix.slice(0, firstDot);
119
+ const tailnet = beforeSuffix.slice(firstDot + 1);
120
+ if (!hostname || !tailnet) return "";
121
+
122
+ return hostname;
123
+ }
124
+
125
+ /**
126
+ * Collision guard. Compare the registered Tailscale hostname segment
127
+ * against the deterministic prediction from the SINGLE source of truth
128
+ * (`deriveDevTailscaleHostname`). Returns a typed result — NEVER throws.
129
+ *
130
+ * - segment === prediction → { ok: true, predicted, registered }
131
+ * - segment !== prediction → { ok: false, predicted, registered,
132
+ * error: TailscaleProvisionError(
133
+ * "tailscale.hostname_collision") }
134
+ * - segment unresolved ("" parsed) → { ok: false, predicted, registered:"",
135
+ * error: TailscaleProvisionError(
136
+ * "tailscale.hostname_unresolved") }
137
+ *
138
+ * `error` is ALWAYS a `TailscaleProvisionError` (has `.code`), never a
139
+ * bare `Error`.
140
+ *
141
+ * @param {object} args
142
+ * @param {string | null | undefined} args.registered registered Self.DNSName
143
+ * (trailing-dot / `.ts.net` / full `https://` URL forms all accepted)
144
+ * @param {string | null | undefined} args.dbUrl SUPABASE_DB_URL
145
+ * @param {string | null | undefined} args.schema SUPABASE_SCHEMA
146
+ * @returns {Promise<{ ok: boolean, predicted: string, registered: string,
147
+ * error?: TailscaleProvisionError }>}
148
+ */
149
+ export async function verifyRegisteredHostnameMatchesPrediction({
150
+ registered,
151
+ dbUrl,
152
+ schema,
153
+ }) {
154
+ // Single source of truth — NEVER re-derive here. Discovered + loaded lazily
155
+ // through the connector's `cinatra.devCliModules` manifest declaration
156
+ // (cinatra#151 Stage 5c) so this module imports cleanly when the gitignored
157
+ // connector source is absent and names no extension.
158
+ const { loadDevCliModule } = await import("./dev-cli-modules.mjs");
159
+ const { deriveDevTailscaleHostname } = await loadDevCliModule("tailscale-hostname");
160
+ const predicted = deriveDevTailscaleHostname({ dbUrl, schema });
161
+ const segment = extractTailscaleHostnameSegment(registered);
162
+
163
+ if (!segment) {
164
+ return {
165
+ ok: false,
166
+ predicted,
167
+ registered: "",
168
+ error: new TailscaleProvisionError(
169
+ "tailscale.hostname_unresolved",
170
+ `Could not resolve a Tailscale hostname from the registered ` +
171
+ `node identity (expected "<hostname>.<tailnet>.ts.net"). ` +
172
+ `Predicted hostname was "${predicted}". Refusing to write ` +
173
+ `publicBaseUrl.`,
174
+ ),
175
+ };
176
+ }
177
+
178
+ if (segment !== predicted) {
179
+ return {
180
+ ok: false,
181
+ predicted,
182
+ registered: segment,
183
+ error: new TailscaleProvisionError(
184
+ "tailscale.hostname_collision",
185
+ `Tailscale registered hostname "${segment}" does not match the ` +
186
+ `predicted hostname "${predicted}" (likely a MagicDNS ` +
187
+ `collision suffix). The predicted Funnel URL would be dead — ` +
188
+ `refusing to write publicBaseUrl.`,
189
+ ),
190
+ };
191
+ }
192
+
193
+ return { ok: true, predicted, registered: segment };
194
+ }
195
+
196
+ /**
197
+ * Write-vs-skip decision. Decides whether to write `publicBaseUrl`
198
+ * purely from `(funnelUrl present)` AND `(hostnameCheck.ok === true)`.
199
+ *
200
+ * DECOUPLING INVARIANT — this function takes a SINGLE object argument
201
+ * and DELIBERATELY accepts NO probe / reachability / cert-warmup
202
+ * parameter. Its arity (`.length === 1`) is the reviewable structural
203
+ * lock (regression-guarded in the test) that the write decision can
204
+ * never be silently re-coupled to a reachability probe.
205
+ *
206
+ * - funnelUrl truthy AND hostnameCheck.ok === true → true
207
+ * - funnelUrl falsy → false (always)
208
+ * - hostnameCheck missing / not ok → false (always)
209
+ *
210
+ * @param {object} args
211
+ * @param {string | null | undefined} args.funnelUrl derived Funnel URL
212
+ * @param {{ ok?: boolean } | null | undefined} args.hostnameCheck
213
+ * result of verifyRegisteredHostnameMatchesPrediction
214
+ * @returns {boolean}
215
+ */
216
+ export function shouldWritePublicBaseUrl({ funnelUrl, hostnameCheck }) {
217
+ if (!funnelUrl) return false;
218
+ return hostnameCheck != null && hostnameCheck.ok === true;
219
+ }
@@ -0,0 +1,113 @@
1
+ // Pure helpers for `cinatra teardown branch`. Extracted from index.mjs so the
2
+ // destructive-name resolution and guards can be tested hermetically — no DB,
3
+ // no Redis, no git. See packages/cli/tests/teardown-config.test.mjs.
4
+ //
5
+ // Bug history: `runTeardownBranch` previously derived the
6
+ // schema/queue names from the git branch alone, ignoring what the worktree's
7
+ // `.env.local` actually declared. When a worktree was set up with a custom
8
+ // `--slug` (or had `.env.local` SUPABASE_SCHEMA / BULLMQ_QUEUE_NAME written
9
+ // at provisioning time that differed from the branch-derived form), the
10
+ // teardown dropped a phantom schema and cleaned a phantom queue while the
11
+ // real ones were orphaned. This module makes the
12
+ // worktree's own `.env.local` the authoritative source of truth and only
13
+ // falls back to slug-derivation when those keys are absent.
14
+ //
15
+ // MAIN-REPO `.env.local` is deliberately
16
+ // NOT consulted for these target names — main's `SUPABASE_SCHEMA=cinatra`
17
+ // would point teardown at the live app schema. Worktree-only, else derived.
18
+
19
+ const SCHEMA_IDENTIFIER_SHAPE = /^[a-zA-Z_][a-zA-Z0-9_]{0,62}$/;
20
+ const QUEUE_NAME_SHAPE = /^cinatra-bg-[a-zA-Z0-9_-]+$/;
21
+ const PROTECTED_SCHEMAS = new Set([
22
+ "cinatra",
23
+ "public",
24
+ "information_schema",
25
+ "pg_catalog",
26
+ ]);
27
+ const PROTECTED_QUEUES = new Set([
28
+ "cinatra-bg-main",
29
+ "cinatra-bg-cinatra",
30
+ ]);
31
+
32
+ /**
33
+ * Resolve the destructive target names for `cinatra teardown branch`.
34
+ *
35
+ * @param {object} args
36
+ * @param {string} args.slug Sanitized branch slug (already validated upstream).
37
+ * @param {string|undefined} [args.envSchema] Worktree `.env.local`'s SUPABASE_SCHEMA (trimmed, or undefined).
38
+ * @param {string|undefined} [args.envQueue] Worktree `.env.local`'s BULLMQ_QUEUE_NAME (trimmed, or undefined).
39
+ * @param {string|undefined} [args.envSource] Path to the worktree `.env.local` (only used in error/summary text).
40
+ * @returns {{schemaName: string, queueName: string, schemaSource: string, queueSource: string}}
41
+ * @throws if either resolved name fails its shape regex or hits a protected name.
42
+ */
43
+ export function resolveTeardownNames({
44
+ slug,
45
+ envSchema,
46
+ envQueue,
47
+ envSource,
48
+ }) {
49
+ if (typeof slug !== "string" || slug.length === 0) {
50
+ throw new Error("resolveTeardownNames: slug is required");
51
+ }
52
+ const derivedSchema = `cinatra_${slug.replace(/-/g, "_")}`;
53
+ const derivedQueue = `cinatra-bg-${slug}`;
54
+ // Distinguish "key absent" (undefined → fall back to derived) from "key
55
+ // present but blank" (empty string → throw). A blank declaration is a
56
+ // malformed env; silently falling back would mask an operator typo, and
57
+ // for a destructive command that's exactly the failure mode this whole
58
+ // module exists to prevent. Callers must pass the trimmed value or
59
+ // undefined — never a coerced empty default.
60
+ if (envSchema === "") {
61
+ throw new Error(
62
+ `SUPABASE_SCHEMA is declared but blank in ${envSource ?? "worktree .env.local"}. ` +
63
+ `Remove the key to fall back to slug-derived defaults, or set a value.`,
64
+ );
65
+ }
66
+ if (envQueue === "") {
67
+ throw new Error(
68
+ `BULLMQ_QUEUE_NAME is declared but blank in ${envSource ?? "worktree .env.local"}. ` +
69
+ `Remove the key to fall back to slug-derived defaults, or set a value.`,
70
+ );
71
+ }
72
+ const schemaName = envSchema ?? derivedSchema;
73
+ const queueName = envQueue ?? derivedQueue;
74
+ const schemaSource = envSchema !== undefined ? (envSource ?? "worktree .env.local") : "derived from slug";
75
+ const queueSource = envQueue !== undefined ? (envSource ?? "worktree .env.local") : "derived from slug";
76
+ validateSchemaName(schemaName, schemaSource, slug);
77
+ validateQueueName(queueName, queueSource);
78
+ return { schemaName, queueName, schemaSource, queueSource };
79
+ }
80
+
81
+ /**
82
+ * Throws if the schema name is malformed or names a protected app schema.
83
+ * Pure — does no I/O.
84
+ */
85
+ export function validateSchemaName(schemaName, source, slug) {
86
+ if (!SCHEMA_IDENTIFIER_SHAPE.test(schemaName)) {
87
+ throw new Error(
88
+ `Refusing to drop schema "${schemaName}" (source: ${source}) — does not match Postgres identifier shape /^[a-zA-Z_][a-zA-Z0-9_]{0,62}$/.`,
89
+ );
90
+ }
91
+ if (PROTECTED_SCHEMAS.has(schemaName) || slug === "main") {
92
+ throw new Error(
93
+ `Refusing to drop protected schema "${schemaName}" (source: ${source}). This command is only for branch worktrees.`,
94
+ );
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Throws if the queue name is malformed or names a protected queue.
100
+ * Pure — does no I/O.
101
+ */
102
+ export function validateQueueName(queueName, source) {
103
+ if (!QUEUE_NAME_SHAPE.test(queueName)) {
104
+ throw new Error(
105
+ `Refusing to clean queue "${queueName}" (source: ${source}) — does not match cinatra-bg-<slug> shape.`,
106
+ );
107
+ }
108
+ if (PROTECTED_QUEUES.has(queueName)) {
109
+ throw new Error(
110
+ `Refusing to clean protected queue "${queueName}" (source: ${source}). This command is only for branch worktrees.`,
111
+ );
112
+ }
113
+ }
@@ -0,0 +1,157 @@
1
+ // Worktree-name collision guard.
2
+ //
3
+ // Pure logic for detecting whether a proposed worktree slug collides with an
4
+ // existing worktree directory or local branch in the same repo. Replaces the
5
+ // older planning-number collision guard; the new check is content-neutral —
6
+ // it only looks at name uniqueness, never at slot/identifier semantics.
7
+ //
8
+ // Public surface:
9
+ // - sanitizeWorktreeSlug(input)
10
+ // - findCollisions({ slug, repoRoot, listWorktrees, listBranches })
11
+ // - runCollisionCheck({ slug, repoRoot, ...inject })
12
+ // - makeDefaultGitImpl(repoRoot)
13
+ // - formatResult(result)
14
+
15
+ import { execFileSync } from "node:child_process";
16
+
17
+ const SLUG_REGEX = /^[a-z0-9][a-z0-9-]{0,29}$/;
18
+
19
+ export function sanitizeWorktreeSlug(input) {
20
+ if (typeof input !== "string") return null;
21
+ const lowered = input.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-");
22
+ const trimmed = lowered.replace(/^-+/, "").replace(/-+$/, "");
23
+ if (!trimmed) return null;
24
+ const capped = trimmed.slice(0, 30);
25
+ return SLUG_REGEX.test(capped) ? capped : null;
26
+ }
27
+
28
+ /**
29
+ * Find worktree/branch collisions for a proposed slug.
30
+ *
31
+ * `selfWorktreePath` / `selfBranch` (optional): the worktree path / branch
32
+ * the caller is operating IN. Matching the slug to the caller's own worktree
33
+ * or branch is NOT a collision — it's the resume case for `cinatra setup
34
+ * branch` re-running inside an already-provisioned worktree.
35
+ */
36
+ export function findCollisions({
37
+ slug,
38
+ listWorktrees,
39
+ listBranches,
40
+ selfWorktreePath,
41
+ selfBranch,
42
+ }) {
43
+ if (!slug) {
44
+ return { verdict: "INVALID", reason: "slug is empty or unsanitized" };
45
+ }
46
+ const worktrees = listWorktrees();
47
+ const branches = listBranches();
48
+
49
+ const wtCollision = worktrees.find(
50
+ (w) => w.path && w.path.split("/").pop() === slug
51
+ );
52
+ if (wtCollision) {
53
+ // Self-match — caller is operating inside this worktree. Not a collision.
54
+ if (selfWorktreePath && wtCollision.path === selfWorktreePath) {
55
+ return { verdict: "FREE", slug, kind: "self-worktree" };
56
+ }
57
+ return {
58
+ verdict: "COLLISION",
59
+ kind: "worktree",
60
+ slug,
61
+ path: wtCollision.path,
62
+ branch: wtCollision.branch,
63
+ };
64
+ }
65
+
66
+ const branchCollision = branches.find(
67
+ (b) => b === slug || b === `worktree-${slug}` || b === `cinatra-ai-${slug}`
68
+ );
69
+ if (branchCollision) {
70
+ // Self-match — caller is operating on this branch.
71
+ if (selfBranch && (branchCollision === selfBranch || branchCollision === `worktree-${selfBranch}`)) {
72
+ return { verdict: "FREE", slug, kind: "self-branch" };
73
+ }
74
+ return { verdict: "COLLISION", kind: "branch", slug, branch: branchCollision };
75
+ }
76
+
77
+ return { verdict: "FREE", slug };
78
+ }
79
+
80
+ export function makeDefaultGitImpl(repoRoot) {
81
+ return {
82
+ listWorktrees() {
83
+ try {
84
+ const out = execFileSync("git", ["-C", repoRoot, "worktree", "list", "--porcelain"], {
85
+ encoding: "utf8",
86
+ });
87
+ const entries = [];
88
+ let cur = {};
89
+ for (const line of out.split("\n")) {
90
+ if (line.startsWith("worktree ")) {
91
+ if (cur.path) entries.push(cur);
92
+ cur = { path: line.slice("worktree ".length).trim() };
93
+ } else if (line.startsWith("branch ")) {
94
+ cur.branch = line.slice("branch ".length).trim();
95
+ } else if (line.startsWith("HEAD ")) {
96
+ cur.head = line.slice("HEAD ".length).trim();
97
+ }
98
+ }
99
+ if (cur.path) entries.push(cur);
100
+ return entries;
101
+ } catch {
102
+ return [];
103
+ }
104
+ },
105
+ listBranches() {
106
+ try {
107
+ const out = execFileSync(
108
+ "git",
109
+ ["-C", repoRoot, "for-each-ref", "--format=%(refname:short)", "refs/heads/"],
110
+ { encoding: "utf8" }
111
+ );
112
+ return out.split("\n").map((s) => s.trim()).filter(Boolean);
113
+ } catch {
114
+ return [];
115
+ }
116
+ },
117
+ };
118
+ }
119
+
120
+ export function runCollisionCheck({
121
+ slug,
122
+ repoRoot,
123
+ listWorktrees,
124
+ listBranches,
125
+ selfWorktreePath,
126
+ selfBranch,
127
+ }) {
128
+ if (!listWorktrees || !listBranches) {
129
+ const impl = makeDefaultGitImpl(repoRoot ?? process.cwd());
130
+ listWorktrees ??= impl.listWorktrees;
131
+ listBranches ??= impl.listBranches;
132
+ }
133
+ const sanitized = sanitizeWorktreeSlug(slug);
134
+ if (!sanitized) {
135
+ return { verdict: "INVALID", reason: `slug ${JSON.stringify(slug)} fails ${SLUG_REGEX}` };
136
+ }
137
+ return findCollisions({
138
+ slug: sanitized,
139
+ listWorktrees,
140
+ listBranches,
141
+ selfWorktreePath,
142
+ selfBranch,
143
+ });
144
+ }
145
+
146
+ export function formatResult(result) {
147
+ if (!result) return "[collision-guard] (no result)";
148
+ if (result.verdict === "FREE") return `[collision-guard] FREE slug=${result.slug}`;
149
+ if (result.verdict === "INVALID") return `[collision-guard] INVALID ${result.reason}`;
150
+ if (result.verdict === "COLLISION") {
151
+ if (result.kind === "worktree") {
152
+ return `[collision-guard] COLLISION kind=worktree slug=${result.slug} path=${result.path}`;
153
+ }
154
+ return `[collision-guard] COLLISION kind=branch slug=${result.slug} branch=${result.branch}`;
155
+ }
156
+ return `[collision-guard] UNKNOWN ${JSON.stringify(result)}`;
157
+ }