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,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
|
+
}
|