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,623 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Pure logic + a best-effort file lock for the per-branch clone-on-demand
|
|
3
|
+
// system. A "clone" is a full deep-fork dev environment (separate Postgres
|
|
4
|
+
// database `cinatra_clone_<slug>`, dedicated ports). Clones are created
|
|
5
|
+
// DORMANT and started on demand by later slices — so they reserve a port
|
|
6
|
+
// band while NOTHING is listening. `findFreePort()` (which only sees live
|
|
7
|
+
// sockets) cannot allocate clone ports; this registry is the source of
|
|
8
|
+
// truth instead.
|
|
9
|
+
//
|
|
10
|
+
// Registry file: ~/.cinatra/clones.json
|
|
11
|
+
// { "version": 1, "clones": { "<slug>": { index, nextjsPort, wayflowPort,
|
|
12
|
+
// dbName, worktreePath, state, createdAt } } }
|
|
13
|
+
//
|
|
14
|
+
// Public surface (also re-exported as `__test` for hermetic vitest):
|
|
15
|
+
// - constants: CLONE_NEXTJS_PORT_BASE, CLONE_WAYFLOW_PORT_BASE,
|
|
16
|
+
// CLONE_MAX_INDEX, SEED_DB_NAME
|
|
17
|
+
// - slug/name/port: cloneSlugFromBranch, cloneDbName, portsForIndex,
|
|
18
|
+
// isProtectedDbName, isValidSlug
|
|
19
|
+
// - registry I/O: defaultRegistryPath, readRegistry, requireUsableRegistry,
|
|
20
|
+
// writeRegistry
|
|
21
|
+
// - lock: withRegistryLock
|
|
22
|
+
// - slot ops (pure): allocateSlot, markSlotReady, releaseSlot, getClone,
|
|
23
|
+
// listClones
|
|
24
|
+
//
|
|
25
|
+
// Registry safety invariants:
|
|
26
|
+
// - readRegistry distinguishes missing/ok/malformed; mutating callers
|
|
27
|
+
// go through requireUsableRegistry which REFUSES malformed (the bad
|
|
28
|
+
// file is left in place for manual repair, never auto-reset).
|
|
29
|
+
// - withRegistryLock serialises read→allocate→write so two concurrent
|
|
30
|
+
// `setup clone` runs cannot both grab index 0.
|
|
31
|
+
// - allocateSlot throws if a slug already maps to a DIFFERENT worktree
|
|
32
|
+
// (idempotent only when the worktreePath matches).
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
existsSync,
|
|
37
|
+
mkdirSync,
|
|
38
|
+
openSync,
|
|
39
|
+
closeSync,
|
|
40
|
+
readFileSync,
|
|
41
|
+
realpathSync,
|
|
42
|
+
writeFileSync,
|
|
43
|
+
renameSync,
|
|
44
|
+
unlinkSync,
|
|
45
|
+
linkSync,
|
|
46
|
+
statSync,
|
|
47
|
+
fstatSync,
|
|
48
|
+
} from "node:fs";
|
|
49
|
+
import os from "node:os";
|
|
50
|
+
import path from "node:path";
|
|
51
|
+
import process from "node:process";
|
|
52
|
+
|
|
53
|
+
// --- constants -------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
export const CLONE_NEXTJS_PORT_BASE = 3100;
|
|
56
|
+
export const CLONE_WAYFLOW_PORT_BASE = 3200;
|
|
57
|
+
export const CLONE_MAX_INDEX = 19; // indices 0..19 → 20 clone slots
|
|
58
|
+
export const SEED_DB_NAME = "cinatra_seed";
|
|
59
|
+
|
|
60
|
+
const REGISTRY_VERSION = 1;
|
|
61
|
+
const LOCK_STALE_MS = 60_000; // steal a lock whose file mtime is older than this
|
|
62
|
+
const LOCK_RETRY_MS = 100;
|
|
63
|
+
const LOCK_TIMEOUT_MS = 10_000;
|
|
64
|
+
|
|
65
|
+
// --- slug / name / port ----------------------------------------------------
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Derive a clone slug from a git branch name. Mirrors `sanitizeBranchSlug` in
|
|
69
|
+
* index.mjs but ALSO strips a leading `worktree-` segment so worktree branches
|
|
70
|
+
* collapse to their clone-specific slug.
|
|
71
|
+
* Returns "" when nothing usable remains.
|
|
72
|
+
*/
|
|
73
|
+
export function cloneSlugFromBranch(branch) {
|
|
74
|
+
let candidate = String(branch ?? "").trim();
|
|
75
|
+
if (!candidate) return "";
|
|
76
|
+
if (candidate.startsWith("cinatra-ai-")) {
|
|
77
|
+
candidate = candidate.slice("cinatra-ai-".length);
|
|
78
|
+
} else if (candidate.startsWith("worktree-")) {
|
|
79
|
+
candidate = candidate.slice("worktree-".length);
|
|
80
|
+
}
|
|
81
|
+
return candidate
|
|
82
|
+
.toLowerCase()
|
|
83
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
84
|
+
.replace(/^-+|-+$/g, "")
|
|
85
|
+
.slice(0, 30);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A slug is valid iff it matches the same shape `cinatra setup branch` enforces. */
|
|
89
|
+
export function isValidSlug(slug) {
|
|
90
|
+
return typeof slug === "string" && /^[a-z0-9][a-z0-9-]{0,29}$/.test(slug);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Postgres database name for a clone. Dashes → underscores (pg identifier rules). */
|
|
94
|
+
export function cloneDbName(slug) {
|
|
95
|
+
if (!isValidSlug(slug)) {
|
|
96
|
+
throw new Error(`Invalid clone slug "${slug}". Must match /^[a-z0-9][a-z0-9-]{0,29}$/.`);
|
|
97
|
+
}
|
|
98
|
+
return `cinatra_clone_${slug.replace(/-/g, "_")}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// A clone database name is EXACTLY `cinatra_clone_` + a slug transformed by
|
|
102
|
+
// `cloneDbName` (dashes → underscores). `isValidSlug` constrains slugs to
|
|
103
|
+
// /^[a-z0-9][a-z0-9-]{0,29}$/, so the transformed suffix is
|
|
104
|
+
// /^[a-z0-9][a-z0-9_]{0,29}$/. The destructive-prune guard must match this
|
|
105
|
+
// EXACT shape — `^cinatra_clone_[a-z0-9_]+$` was too loose (it accepted
|
|
106
|
+
// `cinatra_clone__`, `cinatra_clone__prod`, an over-long suffix, etc.), and
|
|
107
|
+
// this is the last line of defense before `DROP DATABASE`.
|
|
108
|
+
const CLONE_DB_NAME_RE = /^cinatra_clone_[a-z0-9][a-z0-9_]{0,29}$/;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Hard guard for the destructive `clone prune` path. Returns true for any
|
|
112
|
+
* database name that must NEVER be dropped: the maintenance/app DBs, the
|
|
113
|
+
* seed template, the pg system templates, and — critically — ANY name that
|
|
114
|
+
* is not shaped EXACTLY like a `cloneDbName(slug)` output. So a typo, a
|
|
115
|
+
* corrupted registry entry, or a resolution bug fails closed.
|
|
116
|
+
*/
|
|
117
|
+
export function isProtectedDbName(name) {
|
|
118
|
+
if (typeof name !== "string" || name.length === 0) return true;
|
|
119
|
+
const reserved = new Set([
|
|
120
|
+
"postgres",
|
|
121
|
+
"cinatra",
|
|
122
|
+
SEED_DB_NAME,
|
|
123
|
+
"template0",
|
|
124
|
+
"template1",
|
|
125
|
+
]);
|
|
126
|
+
if (reserved.has(name)) return true;
|
|
127
|
+
// Anything not shaped EXACTLY like a clone DB is protected (fail closed).
|
|
128
|
+
return !CLONE_DB_NAME_RE.test(name);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** { nextjsPort, wayflowPort } for a clone index. Throws outside 0..CLONE_MAX_INDEX. */
|
|
132
|
+
export function portsForIndex(index) {
|
|
133
|
+
if (!Number.isInteger(index) || index < 0 || index > CLONE_MAX_INDEX) {
|
|
134
|
+
throw new Error(`Clone index ${index} out of range (0..${CLONE_MAX_INDEX}).`);
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
nextjsPort: CLONE_NEXTJS_PORT_BASE + index,
|
|
138
|
+
wayflowPort: CLONE_WAYFLOW_PORT_BASE + index,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --- registry file I/O -----------------------------------------------------
|
|
143
|
+
|
|
144
|
+
export function defaultRegistryPath() {
|
|
145
|
+
return path.join(os.homedir(), ".cinatra", "clones.json");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function emptyRegistry() {
|
|
149
|
+
return { version: REGISTRY_VERSION, clones: {} };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const CLONE_STATES = new Set(["provisioning", "ready"]);
|
|
153
|
+
|
|
154
|
+
// Structural validation of one clone slot. A registry entry that does not
|
|
155
|
+
// match this shape is treated as registry corruption — `readRegistry`
|
|
156
|
+
// classifies the whole file `malformed` so `requireUsableRegistry` refuses to
|
|
157
|
+
// mutate. A shallow `clones`-is-an-object check can let malformed slot values
|
|
158
|
+
// through, after which `allocateSlot` builds `usedIndexes` from raw values and
|
|
159
|
+
// risks duplicate port allocation or runtime crashes.
|
|
160
|
+
function isValidCloneSlot(slug, slot) {
|
|
161
|
+
if (!isValidSlug(slug)) return false;
|
|
162
|
+
if (!slot || typeof slot !== "object" || Array.isArray(slot)) return false;
|
|
163
|
+
const { index, nextjsPort, wayflowPort, dbName, worktreePath, state, createdAt } = slot;
|
|
164
|
+
if (!Number.isInteger(index) || index < 0 || index > CLONE_MAX_INDEX) return false;
|
|
165
|
+
if (nextjsPort !== CLONE_NEXTJS_PORT_BASE + index) return false;
|
|
166
|
+
if (wayflowPort !== CLONE_WAYFLOW_PORT_BASE + index) return false;
|
|
167
|
+
if (dbName !== cloneDbName(slug)) return false;
|
|
168
|
+
if (typeof worktreePath !== "string" || worktreePath.length === 0) return false;
|
|
169
|
+
if (!CLONE_STATES.has(state)) return false;
|
|
170
|
+
if (typeof createdAt !== "string" || createdAt.length === 0) return false;
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Validate every clone entry AND cross-entry index uniqueness.
|
|
175
|
+
function areRegistryEntriesValid(clones) {
|
|
176
|
+
const seenIndexes = new Set();
|
|
177
|
+
for (const [slug, slot] of Object.entries(clones)) {
|
|
178
|
+
if (!isValidCloneSlot(slug, slot)) return false;
|
|
179
|
+
if (seenIndexes.has(slot.index)) return false; // two slugs claiming one index
|
|
180
|
+
seenIndexes.add(slot.index);
|
|
181
|
+
}
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Read the registry file. NEVER throws.
|
|
187
|
+
* Returns { status, registry, raw }:
|
|
188
|
+
* - status "missing" → file absent; registry = fresh empty registry
|
|
189
|
+
* - status "ok" → parsed; registry = the parsed object
|
|
190
|
+
* - status "malformed" → unreadable/invalid JSON/wrong shape; registry = null,
|
|
191
|
+
* raw = the bytes on disk (so callers can preserve them)
|
|
192
|
+
*/
|
|
193
|
+
export function readRegistry(filePath) {
|
|
194
|
+
if (!existsSync(filePath)) {
|
|
195
|
+
return { status: "missing", registry: emptyRegistry(), raw: null };
|
|
196
|
+
}
|
|
197
|
+
let raw;
|
|
198
|
+
try {
|
|
199
|
+
raw = readFileSync(filePath, "utf8");
|
|
200
|
+
} catch (err) {
|
|
201
|
+
return { status: "malformed", registry: null, raw: null, error: err };
|
|
202
|
+
}
|
|
203
|
+
let parsed;
|
|
204
|
+
try {
|
|
205
|
+
parsed = JSON.parse(raw);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
return { status: "malformed", registry: null, raw, error: err };
|
|
208
|
+
}
|
|
209
|
+
if (
|
|
210
|
+
!parsed ||
|
|
211
|
+
typeof parsed !== "object" ||
|
|
212
|
+
typeof parsed.clones !== "object" ||
|
|
213
|
+
parsed.clones === null ||
|
|
214
|
+
Array.isArray(parsed.clones)
|
|
215
|
+
) {
|
|
216
|
+
return { status: "malformed", registry: null, raw };
|
|
217
|
+
}
|
|
218
|
+
// Deep-validate every clone entry — a syntactically-valid JSON file can
|
|
219
|
+
// still carry structurally-invalid slots (string index, missing fields,
|
|
220
|
+
// mismatched dbName, duplicate indexes). Those must be classified malformed,
|
|
221
|
+
// not silently reused.
|
|
222
|
+
if (!areRegistryEntriesValid(parsed.clones)) {
|
|
223
|
+
return { status: "malformed", registry: null, raw };
|
|
224
|
+
}
|
|
225
|
+
if (typeof parsed.version !== "number") {
|
|
226
|
+
parsed.version = REGISTRY_VERSION;
|
|
227
|
+
}
|
|
228
|
+
return { status: "ok", registry: parsed, raw };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Read the registry for a MUTATING command. Throws on a malformed registry
|
|
233
|
+
* because silently resetting it can hand out a port band that an existing
|
|
234
|
+
* dormant clone already owns). The bad file is left untouched on disk.
|
|
235
|
+
*/
|
|
236
|
+
export function requireUsableRegistry(filePath) {
|
|
237
|
+
const result = readRegistry(filePath);
|
|
238
|
+
if (result.status === "malformed") {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`Clone registry at ${filePath} is malformed and was NOT modified. ` +
|
|
241
|
+
`Inspect/repair it by hand (or delete it only if you are sure no dormant ` +
|
|
242
|
+
`clones exist), then retry.`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
return result.registry;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Atomic write: temp file in the same dir + rename. Creates ~/.cinatra/ if absent. */
|
|
249
|
+
export function writeRegistry(filePath, data) {
|
|
250
|
+
const dir = path.dirname(filePath);
|
|
251
|
+
mkdirSync(dir, { recursive: true });
|
|
252
|
+
const payload = JSON.stringify({ ...data, version: data.version ?? REGISTRY_VERSION }, null, 2) + "\n";
|
|
253
|
+
const tmp = path.join(dir, `.clones.${process.pid}.${Date.now()}.tmp`);
|
|
254
|
+
writeFileSync(tmp, payload, { mode: 0o600 });
|
|
255
|
+
renameSync(tmp, filePath);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// --- file lock -------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Is the process recorded in a registry-lock file still alive? The lock body
|
|
262
|
+
* is written as `"<pid> <iso>\n"`. A LIVE holder must never be judged stale
|
|
263
|
+
* (it may legitimately be mid-long-operation), so staleness requires BOTH an
|
|
264
|
+
* old mtime AND a dead holder pid. Unreadable / unparsable → treat as "not
|
|
265
|
+
* provably alive" so a corrupt lock can still be reclaimed via the mtime
|
|
266
|
+
* gate.
|
|
267
|
+
*/
|
|
268
|
+
function lockHolderAlive(lockPath) {
|
|
269
|
+
let pid = null;
|
|
270
|
+
try {
|
|
271
|
+
const first = readFileSync(lockPath, "utf8").trim().split(/\s+/)[0];
|
|
272
|
+
pid = Number.parseInt(first, 10);
|
|
273
|
+
} catch {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
277
|
+
try {
|
|
278
|
+
process.kill(pid, 0);
|
|
279
|
+
return true;
|
|
280
|
+
} catch (err) {
|
|
281
|
+
// EPERM → the process exists but is owned by another user: still alive.
|
|
282
|
+
return err && err.code === "EPERM";
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Run `fn` while holding an exclusive lock on `<filePath>.lock`.
|
|
288
|
+
*
|
|
289
|
+
* temp+rename prevents torn writes but not lost updates — two
|
|
290
|
+
* `setup clone` processes can both read, both allocate index 0, last rename
|
|
291
|
+
* wins. Every read→allocate→write sequence runs inside this lock.
|
|
292
|
+
*
|
|
293
|
+
* Best-effort, single-host: `openSync(..., "wx")` is the mutex; a lock whose
|
|
294
|
+
* file mtime is older than LOCK_STALE_MS is considered abandoned and stolen.
|
|
295
|
+
* `fn` may be async; the lock is always released in `finally`.
|
|
296
|
+
*/
|
|
297
|
+
export async function withRegistryLock(filePath, fn) {
|
|
298
|
+
const lockPath = `${filePath}.lock`;
|
|
299
|
+
mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
300
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
301
|
+
let fd = null;
|
|
302
|
+
|
|
303
|
+
while (fd === null) {
|
|
304
|
+
try {
|
|
305
|
+
fd = openSync(lockPath, "wx");
|
|
306
|
+
} catch (err) {
|
|
307
|
+
if (err && err.code === "EEXIST") {
|
|
308
|
+
// Lock held — check for staleness, else wait and retry. Stale
|
|
309
|
+
// requires BOTH an old mtime AND a dead holder pid: a live holder
|
|
310
|
+
// running a long operation must never have its lock stolen.
|
|
311
|
+
let stale = false;
|
|
312
|
+
let staleIno = null;
|
|
313
|
+
try {
|
|
314
|
+
const st = statSync(lockPath);
|
|
315
|
+
staleIno = st.ino;
|
|
316
|
+
const mtimeOld = Date.now() - st.mtimeMs > LOCK_STALE_MS;
|
|
317
|
+
stale = mtimeOld && !lockHolderAlive(lockPath);
|
|
318
|
+
} catch {
|
|
319
|
+
// Lock vanished between openSync and statSync — retry immediately.
|
|
320
|
+
}
|
|
321
|
+
if (stale) {
|
|
322
|
+
// Inode-stable steal gate: only steal if the file at lockPath is
|
|
323
|
+
// STILL the exact inode we judged stale. A fresh holder that
|
|
324
|
+
// acquired between the stat above and now is a NEW file (new
|
|
325
|
+
// inode) — we must NOT rename its lock away. This closes the
|
|
326
|
+
// "rob a live fresh holder" race: a changed (or vanished) inode
|
|
327
|
+
// means back off and let the loop re-contend.
|
|
328
|
+
try {
|
|
329
|
+
if (statSync(lockPath).ino !== staleIno) {
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
} catch {
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
// TOCTOU-safe steal: an unconditional `unlinkSync(lockPath)` here
|
|
336
|
+
// can delete a *fresh* lock if the stale holder exited and a new
|
|
337
|
+
// holder acquired between the stat above and the unlink — two
|
|
338
|
+
// processes would then enter the critical section. Instead,
|
|
339
|
+
// atomically rename the exact file we judged stale out of the
|
|
340
|
+
// way; `renameSync` moves a single inode and fails (ENOENT) if
|
|
341
|
+
// it's already gone/rotated. Re-verify staleness on the moved
|
|
342
|
+
// file; if it turned out fresh (we raced a new holder), restore
|
|
343
|
+
// it when the slot is free, then retry the normal acquire.
|
|
344
|
+
const stealPath = `${lockPath}.steal.${process.pid}.${Date.now()}`;
|
|
345
|
+
try {
|
|
346
|
+
renameSync(lockPath, stealPath);
|
|
347
|
+
} catch {
|
|
348
|
+
// Already removed/rotated by someone else — just retry.
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
// Re-verify on the MOVED file: stale needs old mtime AND a dead
|
|
352
|
+
// holder. If we raced a brand-new holder (it wrote a fresh lock
|
|
353
|
+
// with a live pid between our gate and the rename), the moved file
|
|
354
|
+
// is NOT stale and must be restored.
|
|
355
|
+
let stolenStillStale = true;
|
|
356
|
+
try {
|
|
357
|
+
const stolenMtimeOld =
|
|
358
|
+
Date.now() - statSync(stealPath).mtimeMs > LOCK_STALE_MS;
|
|
359
|
+
stolenStillStale = stolenMtimeOld && !lockHolderAlive(stealPath);
|
|
360
|
+
} catch {
|
|
361
|
+
/* moved file vanished — treat as stale/handled */
|
|
362
|
+
}
|
|
363
|
+
if (stolenStillStale) {
|
|
364
|
+
try {
|
|
365
|
+
unlinkSync(stealPath);
|
|
366
|
+
} catch {
|
|
367
|
+
/* already gone — fine */
|
|
368
|
+
}
|
|
369
|
+
} else {
|
|
370
|
+
// We grabbed a still-fresh lock. Restore it ONLY if no newer
|
|
371
|
+
// holder has taken the slot — `linkSync` is atomic and fails
|
|
372
|
+
// EEXIST if `lockPath` now exists (no-clobber; `renameSync`
|
|
373
|
+
// would silently overwrite a newer holder's lock). Either way
|
|
374
|
+
// drop our temp copy.
|
|
375
|
+
try {
|
|
376
|
+
linkSync(stealPath, lockPath);
|
|
377
|
+
} catch {
|
|
378
|
+
/* lockPath taken by a newer holder — discard our copy */
|
|
379
|
+
}
|
|
380
|
+
try {
|
|
381
|
+
unlinkSync(stealPath);
|
|
382
|
+
} catch {
|
|
383
|
+
/* best-effort */
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
if (Date.now() > deadline) {
|
|
389
|
+
throw new Error(
|
|
390
|
+
`Timed out after ${LOCK_TIMEOUT_MS}ms waiting for the clone registry lock ` +
|
|
391
|
+
`(${lockPath}). If no other 'cinatra clone' command is running, delete the ` +
|
|
392
|
+
`lock file and retry.`,
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
await new Promise((r) => setTimeout(r, LOCK_RETRY_MS));
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
throw err;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Capture the inode of the lock file WE created. If another process later
|
|
403
|
+
// judges our lock stale and steals it (unlink + recreate), the path points
|
|
404
|
+
// at a different inode — and we must NOT unlink that new holder's lock on
|
|
405
|
+
// our way out. An unconditional unlink in `finally` lets a resumed stale
|
|
406
|
+
// holder delete the active holder's lock.
|
|
407
|
+
let ourInode = null;
|
|
408
|
+
try {
|
|
409
|
+
ourInode = fstatSync(fd).ino;
|
|
410
|
+
} catch {
|
|
411
|
+
/* fstat failed — fall back to best-effort unlink in finally */
|
|
412
|
+
}
|
|
413
|
+
try {
|
|
414
|
+
writeFileSync(fd, `${process.pid} ${new Date().toISOString()}\n`);
|
|
415
|
+
} catch {
|
|
416
|
+
/* diagnostics only — never fail the lock over this */
|
|
417
|
+
}
|
|
418
|
+
try {
|
|
419
|
+
return await fn();
|
|
420
|
+
} finally {
|
|
421
|
+
try {
|
|
422
|
+
closeSync(fd);
|
|
423
|
+
} catch {
|
|
424
|
+
/* already closed */
|
|
425
|
+
}
|
|
426
|
+
try {
|
|
427
|
+
// Only remove the lock if it is still OURS — same inode we created.
|
|
428
|
+
// ourInode === null (fstat failed) falls back to an unconditional
|
|
429
|
+
// unlink, preserving the prior best-effort behavior.
|
|
430
|
+
if (ourInode === null || statSync(lockPath).ino === ourInode) {
|
|
431
|
+
unlinkSync(lockPath);
|
|
432
|
+
}
|
|
433
|
+
} catch {
|
|
434
|
+
/* lock already removed (e.g. stolen as stale) — nothing to do */
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// --- slot operations (pure) ------------------------------------------------
|
|
440
|
+
|
|
441
|
+
function cloneRegistry(registry) {
|
|
442
|
+
return {
|
|
443
|
+
version: registry.version ?? REGISTRY_VERSION,
|
|
444
|
+
clones: { ...registry.clones },
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Allocate (or return the existing) registry slot for `slug`.
|
|
450
|
+
*
|
|
451
|
+
* Pure — returns { registry, slot } with a NEW registry object; the caller
|
|
452
|
+
* persists it via writeRegistry inside withRegistryLock.
|
|
453
|
+
*
|
|
454
|
+
* - slug present AND same worktreePath → returns the existing slot unchanged
|
|
455
|
+
* (idempotent re-run, regardless of `state`).
|
|
456
|
+
* - slug present AND different worktreePath → THROWS; never alias
|
|
457
|
+
* two worktrees onto one clone DB).
|
|
458
|
+
* - slug absent → lowest free index 0..CLONE_MAX_INDEX, state "provisioning"
|
|
459
|
+
* (the caller flips it to "ready" only after the DB + .env.local
|
|
460
|
+
* succeed; a leftover "provisioning" entry is a resumable/cleanable ghost,
|
|
461
|
+
* not a silent success).
|
|
462
|
+
*/
|
|
463
|
+
export function allocateSlot(registry, slug, { worktreePath }) {
|
|
464
|
+
if (!isValidSlug(slug)) {
|
|
465
|
+
throw new Error(`Invalid clone slug "${slug}". Must match /^[a-z0-9][a-z0-9-]{0,29}$/.`);
|
|
466
|
+
}
|
|
467
|
+
if (typeof worktreePath !== "string" || worktreePath.length === 0) {
|
|
468
|
+
throw new Error("allocateSlot requires a non-empty worktreePath.");
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const existing = registry.clones[slug];
|
|
472
|
+
if (existing) {
|
|
473
|
+
if (existing.worktreePath !== worktreePath) {
|
|
474
|
+
throw new Error(
|
|
475
|
+
`Clone slug "${slug}" already maps to worktree ${existing.worktreePath} — ` +
|
|
476
|
+
`refusing to alias it onto ${worktreePath}. Use a distinct --slug, or ` +
|
|
477
|
+
`prune the existing clone first.`,
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
return { registry: cloneRegistry(registry), slot: existing };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const usedIndexes = new Set(
|
|
484
|
+
Object.values(registry.clones).map((c) => c.index),
|
|
485
|
+
);
|
|
486
|
+
let index = -1;
|
|
487
|
+
for (let i = 0; i <= CLONE_MAX_INDEX; i += 1) {
|
|
488
|
+
if (!usedIndexes.has(i)) {
|
|
489
|
+
index = i;
|
|
490
|
+
break;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (index === -1) {
|
|
494
|
+
throw new Error(
|
|
495
|
+
`All ${CLONE_MAX_INDEX + 1} clone slots are in use. Run 'cinatra clone prune' on a ` +
|
|
496
|
+
`clone you no longer need.`,
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const { nextjsPort, wayflowPort } = portsForIndex(index);
|
|
501
|
+
const slot = {
|
|
502
|
+
index,
|
|
503
|
+
nextjsPort,
|
|
504
|
+
wayflowPort,
|
|
505
|
+
dbName: cloneDbName(slug),
|
|
506
|
+
worktreePath,
|
|
507
|
+
state: "provisioning",
|
|
508
|
+
createdAt: new Date().toISOString(),
|
|
509
|
+
};
|
|
510
|
+
const next = cloneRegistry(registry);
|
|
511
|
+
next.clones[slug] = slot;
|
|
512
|
+
return { registry: next, slot };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Flip a slot to state "ready" after provisioning succeeds. Returns a new registry. */
|
|
516
|
+
export function markSlotReady(registry, slug) {
|
|
517
|
+
const existing = registry.clones[slug];
|
|
518
|
+
if (!existing) {
|
|
519
|
+
throw new Error(`Cannot mark unknown clone slug "${slug}" ready.`);
|
|
520
|
+
}
|
|
521
|
+
const next = cloneRegistry(registry);
|
|
522
|
+
next.clones[slug] = { ...existing, state: "ready" };
|
|
523
|
+
return next;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Remove a slot. Returns { registry, removed } — `removed` is the dropped slot or null. */
|
|
527
|
+
export function releaseSlot(registry, slug) {
|
|
528
|
+
const removed = registry.clones[slug] ?? null;
|
|
529
|
+
const next = cloneRegistry(registry);
|
|
530
|
+
delete next.clones[slug];
|
|
531
|
+
return { registry: next, removed };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
export function getClone(registry, slug) {
|
|
535
|
+
return registry.clones[slug] ?? null;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export function listClones(registry) {
|
|
539
|
+
return Object.entries(registry.clones)
|
|
540
|
+
.map(([slug, slot]) => ({ slug, ...slot }))
|
|
541
|
+
.sort((a, b) => a.index - b.index);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Worktree-path lookup helpers for the EnterWorktree / ExitWorktree hooks.
|
|
545
|
+
// The realpath fallback to `path.resolve` is critical so a worktree directory
|
|
546
|
+
// removed before the ExitWorktree hook fires can still be matched against the
|
|
547
|
+
// stored slot.
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Canonicalise an absolute worktree path. Returns the realpath when the
|
|
551
|
+
* path exists on disk; otherwise returns `path.resolve(p)` so callers
|
|
552
|
+
* can still string-compare against a stored absolute path (the typical
|
|
553
|
+
* stale-clone / ExitWorktree-after-removal scenario).
|
|
554
|
+
*/
|
|
555
|
+
export function canonicalizeWorktreePath(p) {
|
|
556
|
+
if (typeof p !== "string" || p.length === 0) return null;
|
|
557
|
+
try {
|
|
558
|
+
return realpathSync(p);
|
|
559
|
+
} catch {
|
|
560
|
+
return path.resolve(p);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Find the registry slot whose stored worktreePath matches the input.
|
|
566
|
+
* Matches on either realpath (when both resolve) OR the absolute-normalised
|
|
567
|
+
* string (covers the case where the worktree dir has been removed).
|
|
568
|
+
*
|
|
569
|
+
* @returns {{ slug: string, slot: object } | null}
|
|
570
|
+
*/
|
|
571
|
+
export function findCloneByWorktreePath(registry, worktreePath) {
|
|
572
|
+
if (!registry || typeof worktreePath !== "string") return null;
|
|
573
|
+
const inputReal = canonicalizeWorktreePath(worktreePath);
|
|
574
|
+
const inputResolved = path.resolve(worktreePath);
|
|
575
|
+
for (const [slug, slot] of Object.entries(registry.clones)) {
|
|
576
|
+
if (typeof slot?.worktreePath !== "string") continue;
|
|
577
|
+
const slotReal = canonicalizeWorktreePath(slot.worktreePath);
|
|
578
|
+
const slotResolved = path.resolve(slot.worktreePath);
|
|
579
|
+
if (inputReal === slotReal || inputResolved === slotResolved) {
|
|
580
|
+
return { slug, slot };
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return null;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* A slot is stale iff its worktreePath does NOT resolve to an existing
|
|
588
|
+
* directory. There is NO `$HOME` / repo-root exclusion — Cinatra worktrees
|
|
589
|
+
* live under `$HOME`, so excluding `$HOME` makes the rule useless.
|
|
590
|
+
* The existence-of-directory check is the canonical liveness signal.
|
|
591
|
+
*/
|
|
592
|
+
export function isWorktreePathStale(slot) {
|
|
593
|
+
if (typeof slot?.worktreePath !== "string") return true;
|
|
594
|
+
try {
|
|
595
|
+
return !statSync(slot.worktreePath).isDirectory();
|
|
596
|
+
} catch {
|
|
597
|
+
return true;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// --- test surface ----------------------------------------------------------
|
|
602
|
+
|
|
603
|
+
export const __test = {
|
|
604
|
+
CLONE_NEXTJS_PORT_BASE,
|
|
605
|
+
CLONE_WAYFLOW_PORT_BASE,
|
|
606
|
+
CLONE_MAX_INDEX,
|
|
607
|
+
SEED_DB_NAME,
|
|
608
|
+
cloneSlugFromBranch,
|
|
609
|
+
isValidSlug,
|
|
610
|
+
cloneDbName,
|
|
611
|
+
isProtectedDbName,
|
|
612
|
+
portsForIndex,
|
|
613
|
+
defaultRegistryPath,
|
|
614
|
+
readRegistry,
|
|
615
|
+
requireUsableRegistry,
|
|
616
|
+
writeRegistry,
|
|
617
|
+
withRegistryLock,
|
|
618
|
+
allocateSlot,
|
|
619
|
+
markSlotReady,
|
|
620
|
+
releaseSlot,
|
|
621
|
+
getClone,
|
|
622
|
+
listClones,
|
|
623
|
+
};
|