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,543 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Clone runtime helpers (host-native Next.js + per-clone WayFlow).
|
|
3
|
+
//
|
|
4
|
+
// Pure helpers wrapping the per-slug runtime-state directory at
|
|
5
|
+
// `~/.cinatra/clones/<slug>/` (pid file, log file, generated compose.yml,
|
|
6
|
+
// runtime lock file, Tailscale state directory). Also: process-liveness
|
|
7
|
+
// checks, compose-project name derivation, log truncation, port-band guard,
|
|
8
|
+
// Tailscale-authkey validation + redaction.
|
|
9
|
+
//
|
|
10
|
+
// Plain ESM `.mjs`, importable from the CLI without compilation.
|
|
11
|
+
// Hermetically testable — no side effects beyond fs/process when callers
|
|
12
|
+
// invoke the imperative helpers.
|
|
13
|
+
//
|
|
14
|
+
// Public surface (also re-exported as `__test` for hermetic vitest):
|
|
15
|
+
// - paths: cloneRuntimeDir, clonePidPath, cloneLogPath, cloneLockPath,
|
|
16
|
+
// cloneComposePath, cloneTailscaleStateDir, cloneTailscaleServePath
|
|
17
|
+
// - naming: cloneComposeProjectName, cloneTailscaleHostname
|
|
18
|
+
// - process: isPidAlive, processCommandLineMatches
|
|
19
|
+
// - lock: acquireRuntimeLock, releaseRuntimeLock, isRuntimeLockHeld
|
|
20
|
+
// - guard: assertPortBandOk, CLONE_NEXTJS_PORT_LIMIT, CLONE_WAYFLOW_PORT_LIMIT
|
|
21
|
+
// - secret: validateTailscaleAuthkey, redactTailscaleAuthkey,
|
|
22
|
+
// scrubTailscaleAuthkey
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
existsSync,
|
|
27
|
+
mkdirSync,
|
|
28
|
+
openSync,
|
|
29
|
+
closeSync,
|
|
30
|
+
readFileSync,
|
|
31
|
+
writeFileSync,
|
|
32
|
+
unlinkSync,
|
|
33
|
+
renameSync,
|
|
34
|
+
linkSync,
|
|
35
|
+
statSync,
|
|
36
|
+
truncateSync,
|
|
37
|
+
readlinkSync,
|
|
38
|
+
} from "node:fs";
|
|
39
|
+
import os from "node:os";
|
|
40
|
+
import path from "node:path";
|
|
41
|
+
import { execFileSync } from "node:child_process";
|
|
42
|
+
|
|
43
|
+
import {
|
|
44
|
+
CLONE_NEXTJS_PORT_BASE,
|
|
45
|
+
CLONE_WAYFLOW_PORT_BASE,
|
|
46
|
+
CLONE_MAX_INDEX,
|
|
47
|
+
isValidSlug,
|
|
48
|
+
canonicalizeWorktreePath,
|
|
49
|
+
} from "./clone-registry.mjs";
|
|
50
|
+
|
|
51
|
+
// --- constants -------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
// Inclusive upper bounds for the port-band ownership check. Slot indices are
|
|
54
|
+
// 0..CLONE_MAX_INDEX, so port = base + index falls in [base, base+max].
|
|
55
|
+
export const CLONE_NEXTJS_PORT_LIMIT = CLONE_NEXTJS_PORT_BASE + CLONE_MAX_INDEX;
|
|
56
|
+
export const CLONE_WAYFLOW_PORT_LIMIT = CLONE_WAYFLOW_PORT_BASE + CLONE_MAX_INDEX;
|
|
57
|
+
|
|
58
|
+
// --- runtime dir / paths --------------------------------------------------
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Per-clone runtime state directory. Defaults to `~/.cinatra/clones/<slug>`
|
|
62
|
+
* but is overridable via `{ home }` for hermetic tests.
|
|
63
|
+
*/
|
|
64
|
+
export function cloneRuntimeDir(slug, { home } = {}) {
|
|
65
|
+
if (!isValidSlug(slug)) {
|
|
66
|
+
throw new Error(`Invalid clone slug "${slug}".`);
|
|
67
|
+
}
|
|
68
|
+
const root = home ?? os.homedir();
|
|
69
|
+
return path.join(root, ".cinatra", "clones", slug);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function clonePidPath(slug, opts) {
|
|
73
|
+
return path.join(cloneRuntimeDir(slug, opts), "nextjs.pid");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function cloneLogPath(slug, opts) {
|
|
77
|
+
return path.join(cloneRuntimeDir(slug, opts), "nextjs.log");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function cloneLockPath(slug, opts) {
|
|
81
|
+
return path.join(cloneRuntimeDir(slug, opts), "clone.lock");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function cloneComposePath(slug, opts) {
|
|
85
|
+
return path.join(cloneRuntimeDir(slug, opts), "compose.yml");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function cloneTailscaleStateDir(slug, opts) {
|
|
89
|
+
return path.join(cloneRuntimeDir(slug, opts), "tailscale-state");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function cloneTailscaleServePath(slug, opts) {
|
|
93
|
+
return path.join(cloneRuntimeDir(slug, opts), "tailscale-serve.json");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Ensure the runtime dir exists (idempotent, 0700). */
|
|
97
|
+
export function ensureCloneRuntimeDir(slug, opts) {
|
|
98
|
+
const dir = cloneRuntimeDir(slug, opts);
|
|
99
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
100
|
+
return dir;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --- naming ----------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Compose project name for a per-clone stack. Includes the slot index so
|
|
107
|
+
* slug-renames don't collide across recreated registry rows.
|
|
108
|
+
* Compose v2 accepts `[a-z0-9_-]+`, starting with a letter or digit.
|
|
109
|
+
*/
|
|
110
|
+
export function cloneComposeProjectName(slug, index) {
|
|
111
|
+
if (!isValidSlug(slug)) throw new Error(`Invalid clone slug "${slug}".`);
|
|
112
|
+
if (typeof index !== "number" || index < 0 || index > CLONE_MAX_INDEX) {
|
|
113
|
+
throw new Error(`Invalid slot index ${index}; expected 0..${CLONE_MAX_INDEX}.`);
|
|
114
|
+
}
|
|
115
|
+
const sanitized = slug.replace(/[^a-z0-9-]/g, "-");
|
|
116
|
+
return `cinatra-clone-${sanitized}-${index}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Tailscale device hostname. Stable across container restarts (the state
|
|
121
|
+
* volume preserves the node identity). Slot-index suffix mirrors the compose
|
|
122
|
+
* project name to disambiguate renamed registry rows.
|
|
123
|
+
*/
|
|
124
|
+
export function cloneTailscaleHostname(slug, index) {
|
|
125
|
+
if (!isValidSlug(slug)) throw new Error(`Invalid clone slug "${slug}".`);
|
|
126
|
+
if (typeof index !== "number" || index < 0 || index > CLONE_MAX_INDEX) {
|
|
127
|
+
throw new Error(`Invalid slot index ${index}; expected 0..${CLONE_MAX_INDEX}.`);
|
|
128
|
+
}
|
|
129
|
+
return `cinatra-${slug.replace(/[^a-z0-9-]/g, "-")}-${index}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// --- process ---------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
/** Returns true iff the pid currently exists. ESRCH → false. */
|
|
135
|
+
export function isPidAlive(pid) {
|
|
136
|
+
if (typeof pid !== "number" || !Number.isFinite(pid) || pid <= 0) return false;
|
|
137
|
+
try {
|
|
138
|
+
process.kill(pid, 0);
|
|
139
|
+
return true;
|
|
140
|
+
} catch (err) {
|
|
141
|
+
// ESRCH = no such process → genuinely dead. EPERM = the process EXISTS
|
|
142
|
+
// but is owned by another user (we can't signal it) → still ALIVE.
|
|
143
|
+
// Treating EPERM (or any non-ESRCH error) as dead would bypass every
|
|
144
|
+
// fail-closed guard that gates on liveness and let prune DROP the DB
|
|
145
|
+
// under a live-but-unverifiable clone.
|
|
146
|
+
return !!err && err.code !== "ESRCH";
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// `runCloneStart` spawns `spawn("pnpm", ["dev"], { detached: true })`. The
|
|
151
|
+
// recorded pid is therefore the package-manager wrapper — its `ps` command
|
|
152
|
+
// line is e.g. `node /…/pnpm dev`, NOT `next`. The actual `next dev` /
|
|
153
|
+
// `next-server` are child processes sharing the spawned process group. A
|
|
154
|
+
// `mustContain: ["next"]` check against the wrapper pid ALWAYS fails, which
|
|
155
|
+
// can make `clone stop`/`status` and the prune in-flight guard treat
|
|
156
|
+
// a healthy running clone as "not ours" (leaked Next.js + a prune that could
|
|
157
|
+
// DROP a live clone DB). Recognise the dev-runner wrapper too.
|
|
158
|
+
//
|
|
159
|
+
// Deliberately NARROW: only the exact spawned form `<pm> dev` (we run
|
|
160
|
+
// `spawn("pnpm", ["dev"])`, so `ps` shows `pnpm dev` or `node …/pnpm dev`)
|
|
161
|
+
// and the Next.js child (`next dev` / `next-server`). It must NOT match
|
|
162
|
+
// unrelated same-worktree commands like `pnpm exec tool --mode dev`,
|
|
163
|
+
// `npm run dev:docs`, or a path containing "next" (e.g. `…/nextcloud/…`) —
|
|
164
|
+
// stop / `prune --force-stop` signal a process group based on this, so a
|
|
165
|
+
// loose match could kill an unrelated process. `dev` must be a whole token
|
|
166
|
+
// (followed by whitespace or end-of-string), and `next` must not be a
|
|
167
|
+
// substring of a longer path segment.
|
|
168
|
+
// The `next` branch requires `next dev` or `next-server` (whole token) —
|
|
169
|
+
// NOT bare `next` (which would also match `next build` / `next lint` /
|
|
170
|
+
// `next start` and let `clone stop` signal an unrelated same-worktree
|
|
171
|
+
// `next build` via a reused pid file).
|
|
172
|
+
const CLONE_DEV_PROC_RE =
|
|
173
|
+
/(?:^|[\s/])(?:pnpm|npm|yarn|bun)\s+dev(?:\s|$)|(?:^|[\s/])next(?:-server|\s+dev)(?:\s|$)/;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Best-effort check that a pid belongs to a process we'd recognise as the
|
|
177
|
+
* clone's host-native Next.js dev server (or the package-manager wrapper we
|
|
178
|
+
* spawned it through). Compares the command line (via `ps`) and, when
|
|
179
|
+
* `cwdMustEqual` is given, the process cwd (symlink-canonicalised both
|
|
180
|
+
* sides).
|
|
181
|
+
*
|
|
182
|
+
* Returns:
|
|
183
|
+
* { alive: false } — pid not running.
|
|
184
|
+
* { alive: true, ours: true } — looks like our process.
|
|
185
|
+
* { alive: true, ours: false, why } — alive, POSITIVELY a different
|
|
186
|
+
* process (command not a clone dev process, or cwd resolved to a
|
|
187
|
+
* different path).
|
|
188
|
+
* { alive: true, ours: false, indeterminate: true, why } — alive but we
|
|
189
|
+
* could NOT verify (ps/lsof/proc lookup failed, or platform
|
|
190
|
+
* unsupported). A destructive caller (prune) must fail CLOSED on this;
|
|
191
|
+
* a signalling caller (stop) must NOT signal it.
|
|
192
|
+
*/
|
|
193
|
+
export function processCommandLineMatches(pid, { cwdMustEqual } = {}) {
|
|
194
|
+
if (!isPidAlive(pid)) return { alive: false };
|
|
195
|
+
|
|
196
|
+
let cmd = "";
|
|
197
|
+
try {
|
|
198
|
+
cmd = execFileSync("ps", ["-p", String(pid), "-o", "command="], {
|
|
199
|
+
encoding: "utf8",
|
|
200
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
201
|
+
}).trim();
|
|
202
|
+
} catch {
|
|
203
|
+
// Could not VERIFY (vs. a positive mismatch) — caller must decide
|
|
204
|
+
// whether to fail closed (e.g. destructive prune) on an unverified
|
|
205
|
+
// live pid.
|
|
206
|
+
return { alive: true, ours: false, indeterminate: true, why: "ps failed (likely permission)" };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// The process is "ours" by command line ONLY if it is the spawned
|
|
210
|
+
// dev-runner wrapper / Next.js process per the narrow CLONE_DEV_PROC_RE.
|
|
211
|
+
// No loose substring fallback — a path like `…/nextcloud/…` must not
|
|
212
|
+
// count as ours. The cwd check below is the authoritative per-clone
|
|
213
|
+
// discriminator; this is the process-shape sanity gate.
|
|
214
|
+
if (!CLONE_DEV_PROC_RE.test(cmd)) {
|
|
215
|
+
return {
|
|
216
|
+
alive: true,
|
|
217
|
+
ours: false,
|
|
218
|
+
why: `command line is not a clone dev process: ${cmd}`,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (typeof cwdMustEqual === "string") {
|
|
223
|
+
let cwd = null;
|
|
224
|
+
try {
|
|
225
|
+
// macOS: lsof; Linux: /proc/<pid>/cwd readlink.
|
|
226
|
+
if (process.platform === "darwin") {
|
|
227
|
+
// `-a` ANDs the `-p`/`-d` filters. WITHOUT `-a`, `lsof -p <pid>` is
|
|
228
|
+
// an OR filter that dumps EVERY process's cwd — the parse below then
|
|
229
|
+
// grabbed the first system process's path (typically `/`), so the
|
|
230
|
+
// cwd guard silently never matched on macOS. With `-a -p <pid>` the
|
|
231
|
+
// output is a single `p<pid>` / `fcwd` / `n<path>` block.
|
|
232
|
+
const out = execFileSync("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], {
|
|
233
|
+
encoding: "utf8",
|
|
234
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
235
|
+
});
|
|
236
|
+
// lsof -Fn emits the cwd as an "n<path>" line. Anchor to the n line
|
|
237
|
+
// that follows this pid's record so a multi-line dump can't mislead.
|
|
238
|
+
const match = out.split("\n").find((line) => line.startsWith("n"));
|
|
239
|
+
cwd = match ? match.slice(1) : null;
|
|
240
|
+
} else if (process.platform === "linux") {
|
|
241
|
+
// Linux readlink of a REMOVED cwd yields exactly "<path> (deleted)".
|
|
242
|
+
// A clone whose worktree was `rm -rf`'d (the `prune --stale` case)
|
|
243
|
+
// is still OUR process — strip ONLY this kernel-emitted suffix so
|
|
244
|
+
// the path still matches slot.worktreePath instead of reading as
|
|
245
|
+
// "not ours" and letting prune DROP the DB under a live clone.
|
|
246
|
+
// Explicitly gated to linux so the
|
|
247
|
+
// `(deleted)` strip can never touch a path from another source.
|
|
248
|
+
cwd = readlinkSync(`/proc/${pid}/cwd`).replace(/ \(deleted\)$/, "");
|
|
249
|
+
} else {
|
|
250
|
+
// Unsupported platform for cwd resolution — cannot verify.
|
|
251
|
+
return { alive: true, ours: false, indeterminate: true, why: "cwd lookup unsupported on platform" };
|
|
252
|
+
}
|
|
253
|
+
} catch {
|
|
254
|
+
return { alive: true, ours: false, indeterminate: true, why: "cwd lookup failed" };
|
|
255
|
+
}
|
|
256
|
+
if (canonicalizeWorktreePath(cwd) !== canonicalizeWorktreePath(cwdMustEqual)) {
|
|
257
|
+
return { alive: true, ours: false, why: `cwd mismatch: got ${cwd} want ${cwdMustEqual}` };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return { alive: true, ours: true };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// --- runtime lock ----------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Acquire the per-clone runtime lock. Best-effort: open-O_EXCL on the lock
|
|
268
|
+
* file. Throws if another process holds it. The caller is responsible for
|
|
269
|
+
* calling `releaseRuntimeLock(slug)` from a `finally` block on EVERY failure
|
|
270
|
+
* path.
|
|
271
|
+
*/
|
|
272
|
+
// A null-owner lock (no readable pid) younger than this is treated as a
|
|
273
|
+
// foreign acquirer's transient state, NOT stealable — back off and retry.
|
|
274
|
+
// Only an OLD null-owner lock counts as abandoned/corrupt and stealable.
|
|
275
|
+
// (Our own acquires are atomic link-publishes of a fully-written file, so
|
|
276
|
+
// they never present an empty/no-pid window.)
|
|
277
|
+
const RUNTIME_LOCK_NULL_STALE_MS = 30_000;
|
|
278
|
+
|
|
279
|
+
function sleepSyncMs(ms) {
|
|
280
|
+
const until = Date.now() + ms;
|
|
281
|
+
while (Date.now() < until) { /* brief sync backoff for a short CLI lock */ }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function acquireRuntimeLock(slug, opts) {
|
|
285
|
+
ensureCloneRuntimeDir(slug, opts);
|
|
286
|
+
const lockPath = cloneLockPath(slug, opts);
|
|
287
|
+
// Bounded retry: each iteration either acquires (atomic link-publish of a
|
|
288
|
+
// fully-written file → no empty-file window) or steals a provably
|
|
289
|
+
// dead/abandoned lock. Capped so pathological contention can't spin
|
|
290
|
+
// forever (~50 * 50ms ≈ 2.5s tolerance for a foreign partial write).
|
|
291
|
+
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
292
|
+
// Publish atomically: write the pid to a temp file FIRST, then
|
|
293
|
+
// `linkSync` it into place (atomic, no-clobber). The lock file is
|
|
294
|
+
// therefore fully populated the instant it becomes observable — a
|
|
295
|
+
// racer can never see it pid-less and wrongly judge it stealable.
|
|
296
|
+
const tmpPath = `${lockPath}.acq.${process.pid}.${Date.now()}`;
|
|
297
|
+
try {
|
|
298
|
+
writeFileSync(tmpPath, `${process.pid}\n${new Date().toISOString()}\n`, { mode: 0o600 });
|
|
299
|
+
} catch (err) {
|
|
300
|
+
try { unlinkSync(tmpPath); } catch { /* best-effort */ }
|
|
301
|
+
throw err;
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
linkSync(tmpPath, lockPath);
|
|
305
|
+
try { unlinkSync(tmpPath); } catch { /* best-effort */ }
|
|
306
|
+
return; // acquired
|
|
307
|
+
} catch (err) {
|
|
308
|
+
try { unlinkSync(tmpPath); } catch { /* best-effort */ }
|
|
309
|
+
if (!err || err.code !== "EEXIST") throw err;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const ownerPid = readLockOwnerPid(lockPath);
|
|
313
|
+
if (ownerPid != null && isPidAlive(ownerPid)) {
|
|
314
|
+
throw new Error(
|
|
315
|
+
`clone start: runtime lock held by pid ${ownerPid} at ${lockPath}. ` +
|
|
316
|
+
`Either wait for the other invocation to finish, or run 'cinatra clone status --slug ${slug}' to investigate.`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
if (ownerPid == null) {
|
|
320
|
+
// No readable pid. With atomic link-publish this is NOT a transient
|
|
321
|
+
// window for any well-behaved acquirer, so it's a foreign/corrupt
|
|
322
|
+
// lock — but only treat it as stealable once it has aged past the
|
|
323
|
+
// stale threshold; a younger one might still be a misbehaving peer.
|
|
324
|
+
let ageMs = Infinity;
|
|
325
|
+
try { ageMs = Date.now() - statSync(lockPath).mtimeMs; }
|
|
326
|
+
catch { continue; /* vanished — retry acquire */ }
|
|
327
|
+
if (ageMs <= RUNTIME_LOCK_NULL_STALE_MS) {
|
|
328
|
+
sleepSyncMs(50);
|
|
329
|
+
continue; // back off; do NOT steal a fresh pid-less lock
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Owner is provably dead, or a stale/corrupt pid-less lock → steal via
|
|
334
|
+
// atomic rename (only ONE racer can rename a given inode; the rest get
|
|
335
|
+
// ENOENT and just retry). Never an unconditional unlink.
|
|
336
|
+
const stealPath = `${lockPath}.steal.${process.pid}.${Date.now()}`;
|
|
337
|
+
try {
|
|
338
|
+
renameSync(lockPath, stealPath);
|
|
339
|
+
} catch {
|
|
340
|
+
continue; // already rotated/removed by someone else — retry acquire
|
|
341
|
+
}
|
|
342
|
+
// Re-verify on the MOVED file. If a fresh holder recreated lockPath
|
|
343
|
+
// between our check and the rename, the file we grabbed now has a LIVE
|
|
344
|
+
// owner — restore it (no-clobber) and treat the lock as held.
|
|
345
|
+
const stolenOwner = readLockOwnerPid(stealPath);
|
|
346
|
+
if (stolenOwner != null && isPidAlive(stolenOwner)) {
|
|
347
|
+
try {
|
|
348
|
+
linkSync(stealPath, lockPath); // fails EEXIST if a newer holder took it
|
|
349
|
+
} catch {
|
|
350
|
+
/* a newer holder owns lockPath — discard our stolen copy */
|
|
351
|
+
}
|
|
352
|
+
try { unlinkSync(stealPath); } catch { /* best-effort */ }
|
|
353
|
+
throw new Error(
|
|
354
|
+
`clone start: runtime lock held by pid ${stolenOwner} at ${lockPath}. ` +
|
|
355
|
+
`Either wait for the other invocation to finish, or run 'cinatra clone status --slug ${slug}' to investigate.`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
// Genuinely stale — discard and loop to re-publish.
|
|
359
|
+
try { unlinkSync(stealPath); } catch { /* already gone */ }
|
|
360
|
+
}
|
|
361
|
+
throw new Error(
|
|
362
|
+
`clone start: could not acquire the runtime lock at ${lockPath} after repeated ` +
|
|
363
|
+
`contention. Run 'cinatra clone status --slug ${slug}' to investigate.`,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function releaseRuntimeLock(slug, opts) {
|
|
368
|
+
const lockPath = cloneLockPath(slug, opts);
|
|
369
|
+
try {
|
|
370
|
+
unlinkSync(lockPath);
|
|
371
|
+
} catch (err) {
|
|
372
|
+
if (err && err.code !== "ENOENT") throw err;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function isRuntimeLockHeld(slug, opts) {
|
|
377
|
+
return existsSync(cloneLockPath(slug, opts));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function readLockOwnerPid(lockPath) {
|
|
381
|
+
try {
|
|
382
|
+
const raw = readFileSync(lockPath, "utf8");
|
|
383
|
+
const first = raw.split("\n", 1)[0]?.trim();
|
|
384
|
+
if (!first) return null;
|
|
385
|
+
const pid = Number.parseInt(first, 10);
|
|
386
|
+
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
387
|
+
} catch {
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// --- port-band guard -------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Refuse to start a clone whose registered port falls outside the
|
|
396
|
+
* clone-on-demand port bands. Catches corrupt/legacy registry rows.
|
|
397
|
+
*/
|
|
398
|
+
export function assertPortBandOk(port, kind) {
|
|
399
|
+
if (typeof port !== "number" || !Number.isFinite(port)) {
|
|
400
|
+
throw new Error(`Clone runtime: port for ${kind} is not a number: ${port}`);
|
|
401
|
+
}
|
|
402
|
+
if (kind === "nextjs") {
|
|
403
|
+
if (port < CLONE_NEXTJS_PORT_BASE || port > CLONE_NEXTJS_PORT_LIMIT) {
|
|
404
|
+
throw new Error(
|
|
405
|
+
`Clone runtime: Next.js port ${port} outside band ` +
|
|
406
|
+
`${CLONE_NEXTJS_PORT_BASE}-${CLONE_NEXTJS_PORT_LIMIT}. Registry corrupt?`,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (kind === "wayflow") {
|
|
412
|
+
if (port < CLONE_WAYFLOW_PORT_BASE || port > CLONE_WAYFLOW_PORT_LIMIT) {
|
|
413
|
+
throw new Error(
|
|
414
|
+
`Clone runtime: WayFlow port ${port} outside band ` +
|
|
415
|
+
`${CLONE_WAYFLOW_PORT_BASE}-${CLONE_WAYFLOW_PORT_LIMIT}. Registry corrupt?`,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
throw new Error(`Clone runtime: unknown port kind "${kind}".`);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// --- log management --------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Truncate the per-clone Next.js log so each `clone start` begins with an
|
|
427
|
+
* empty log. Operators tail the file in another terminal; cumulative debug
|
|
428
|
+
* history is the operator's responsibility.
|
|
429
|
+
*/
|
|
430
|
+
export function truncateCloneLog(slug, opts) {
|
|
431
|
+
const logPath = cloneLogPath(slug, opts);
|
|
432
|
+
ensureCloneRuntimeDir(slug, opts);
|
|
433
|
+
if (existsSync(logPath)) {
|
|
434
|
+
truncateSync(logPath, 0);
|
|
435
|
+
} else {
|
|
436
|
+
writeFileSync(logPath, "", { mode: 0o600 });
|
|
437
|
+
}
|
|
438
|
+
return logPath;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// --- Tailscale authkey -----------------------------------------------------
|
|
442
|
+
|
|
443
|
+
const TAILSCALE_AUTHKEY_PREFIX = "tskey-auth-";
|
|
444
|
+
const TAILSCALE_AUTHKEY_RE = /^tskey-auth-[A-Za-z0-9_-]+$/;
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Validate a Tailscale auth key shape. The CLI rejects keys that don't start
|
|
448
|
+
* with `tskey-auth-` — Tailscale's documented form — so we fail fast with a
|
|
449
|
+
* useful pointer to the auth-keys docs.
|
|
450
|
+
*
|
|
451
|
+
* @returns {string} the validated key
|
|
452
|
+
*/
|
|
453
|
+
export function validateTailscaleAuthkey(key) {
|
|
454
|
+
if (typeof key !== "string" || key.length === 0) {
|
|
455
|
+
throw new Error(
|
|
456
|
+
"TS_AUTHKEY is required to expose a clone via Tailscale Funnel. " +
|
|
457
|
+
"Generate one at https://login.tailscale.com/configuration/settings/keys (ephemeral + preauthorised recommended).",
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
if (!TAILSCALE_AUTHKEY_RE.test(key)) {
|
|
461
|
+
throw new Error(
|
|
462
|
+
`TS_AUTHKEY format invalid (expected '${TAILSCALE_AUTHKEY_PREFIX}…'; got ${redactTailscaleAuthkey(key)}). ` +
|
|
463
|
+
`See https://tailscale.com/kb/1085/auth-keys`,
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
return key;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Redact a Tailscale auth key for logging / display. Returns a fixed-length
|
|
471
|
+
* marker that reveals only the last 4 chars of the key, so an operator can
|
|
472
|
+
* cross-check that the right key is being used without exposing the secret.
|
|
473
|
+
*/
|
|
474
|
+
export function redactTailscaleAuthkey(key) {
|
|
475
|
+
if (typeof key !== "string") return "<not-a-string>";
|
|
476
|
+
if (key.length === 0) return "<empty>";
|
|
477
|
+
// Keep just enough suffix to disambiguate without leaking the secret.
|
|
478
|
+
const tail = key.slice(-4);
|
|
479
|
+
return `${TAILSCALE_AUTHKEY_PREFIX}…${tail}`;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Scrub a Tailscale auth key out of arbitrary string content. Used when
|
|
484
|
+
* surfacing docker compose stderr or rendered-compose previews so a key
|
|
485
|
+
* accidentally interpolated by some other path doesn't leak.
|
|
486
|
+
*/
|
|
487
|
+
export function scrubTailscaleAuthkey(content, key) {
|
|
488
|
+
if (typeof content !== "string" || content.length === 0) return content ?? "";
|
|
489
|
+
if (typeof key !== "string" || key.length < 8) return content;
|
|
490
|
+
return content.split(key).join(redactTailscaleAuthkey(key));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// --- argv helpers ----------------------------------------------------------
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Reject any form of `--tailscale-authkey` in argv. Both the space form
|
|
497
|
+
* (`--tailscale-authkey foo`) and equals form (`--tailscale-authkey=foo`)
|
|
498
|
+
* leak the secret through `argv`, `ps`, and shell history — so the CLI
|
|
499
|
+
* refuses outright and tells operators to set `TS_AUTHKEY` in env.
|
|
500
|
+
*/
|
|
501
|
+
export function rejectTailscaleAuthkeyFlag(argv) {
|
|
502
|
+
if (!Array.isArray(argv)) return;
|
|
503
|
+
if (argv.some((tok) => tok === "--tailscale-authkey" || (typeof tok === "string" && tok.startsWith("--tailscale-authkey=")))) {
|
|
504
|
+
throw new Error(
|
|
505
|
+
"--tailscale-authkey is not accepted; pass TS_AUTHKEY via env to keep the secret out of shell history and process args.",
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Flags that take a value (i.e. argv[i+1] is the value, not a positional).
|
|
512
|
+
* Prevents `--worktree-path /tmp/wt` from being interpreted as a
|
|
513
|
+
* positional slug = "/tmp/wt".
|
|
514
|
+
*/
|
|
515
|
+
export const CLONE_VALUE_FLAGS = Object.freeze(
|
|
516
|
+
new Set(["--worktree-path", "--slug", "--source-env"]),
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Find a positional slug-shaped argument, skipping the values of known
|
|
521
|
+
* value-taking flags. Returns null if no candidate matches the slug regex.
|
|
522
|
+
*/
|
|
523
|
+
export function findPositionalSlug(argv) {
|
|
524
|
+
if (!Array.isArray(argv)) return null;
|
|
525
|
+
for (let i = 0; i < argv.length; i++) {
|
|
526
|
+
const tok = argv[i];
|
|
527
|
+
if (typeof tok !== "string") continue;
|
|
528
|
+
if (tok.startsWith("--") || tok.startsWith("-")) {
|
|
529
|
+
if (CLONE_VALUE_FLAGS.has(tok)) i++; // skip the value
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
if (/^[a-z0-9][a-z0-9-]{0,29}$/.test(tok)) return tok;
|
|
533
|
+
}
|
|
534
|
+
return null;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// --- __test re-export ------------------------------------------------------
|
|
538
|
+
|
|
539
|
+
export const __test = {
|
|
540
|
+
CLONE_NEXTJS_PORT_LIMIT,
|
|
541
|
+
CLONE_WAYFLOW_PORT_LIMIT,
|
|
542
|
+
TAILSCALE_AUTHKEY_RE,
|
|
543
|
+
};
|