UNPKG

@lenne.tech/cli

Version:

lenne.Tech CLI: lt

251 lines (250 loc) 9.91 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; Object.defineProperty(exports, "__esModule", { value: true }); exports.paths = exports.TEST_SESSION_FILE = void 0; exports.allocateInternalPort = allocateInternalPort; exports.clearSession = clearSession; exports.detectSlugConflict = detectSlugConflict; exports.isPidAlive = isPidAlive; exports.isValidPid = isValidPid; exports.loadRegistry = loadRegistry; exports.loadSession = loadSession; exports.saveRegistry = saveRegistry; exports.saveSession = saveSession; exports.takenInternalPorts = takenInternalPorts; exports.withRegistryLock = withRegistryLock; /** * State persistence for `lt dev`. * * Two stores: * - Central registry at `~/.lenneTech/projects.json` — index of all * known projects, used by `lt dev status --all`, the Claude Code * plugin hook, and conflict detection. * - Per-project state at `<root>/.lt-dev/state.json` — PIDs of the * currently running `lt dev up` session. * * Both files are JSON, atomically written, and schema-versioned. */ const fs_1 = require("fs"); const os_1 = require("os"); const path_1 = require("path"); const REGISTRY_PATH = process.env.LT_DEV_REGISTRY_PATH || (0, path_1.join)((0, os_1.homedir)(), '.lenneTech', 'projects.json'); const SESSION_DIR = '.lt-dev'; const SESSION_FILE = 'state.json'; /** Session file for the ephemeral `lt dev test` stack (runs parallel to the dev session). */ exports.TEST_SESSION_FILE = 'state.test.json'; /** * Allocate a free internal port for a Caddy upstream. * * Strategy: try sequential ports starting from `start`, skipping any * that are already in use. The range 4000-4999 is conventional for * lt dev internal ports — well above the deprecated 3000/3001 range * and safely below most reserved/system ranges. */ function allocateInternalPort(start, taken) { for (let p = start; p < start + 1000; p++) { if (!taken.has(p)) return p; } throw new Error(`No free internal port in range [${start}, ${start + 1000})`); } /** Remove session state file (called by `lt dev down`). */ function clearSession(root, sessionFile = SESSION_FILE) { const file = (0, path_1.join)(root, SESSION_DIR, sessionFile); if ((0, fs_1.existsSync)(file)) { try { (0, fs_1.rmSync)(file); } catch (_a) { /* best-effort */ } } } /** * Detect when `slug` is registered to a DIFFERENT checkout than `root`. Two * clones of the same project share a package.json "name" → the same slug → the * same Caddy block / internal ports / database, so running both via `lt dev` * collides (and one's `lt dev down` can unroute the other). Returns null when * there is no conflict (no registry entry, or the entry belongs to THIS checkout). */ function detectSlugConflict(slug, root) { const entry = loadRegistry().projects[slug]; if (!(entry === null || entry === void 0 ? void 0 : entry.path) || sameRealPath(entry.path, root)) return null; const session = loadSession(entry.path); const otherSessionAlive = !!session && [session.pids.api, session.pids.app].some((p) => typeof p === 'number' && isPidAlive(p)); return { otherPath: entry.path, otherSessionAlive }; } /** Check whether a process with the given PID is currently alive. */ function isPidAlive(pid) { if (!isValidPid(pid)) return false; try { process.kill(pid, 0); return true; } catch (_a) { return false; } } /** Validate a PID — positive integer, within plausible range. */ function isValidPid(pid) { return typeof pid === 'number' && Number.isInteger(pid) && pid > 0 && pid < 4194304; } /** Load the central registry; returns an empty one if missing or unreadable. */ function loadRegistry() { if (!(0, fs_1.existsSync)(REGISTRY_PATH)) return { projects: {}, version: 1 }; try { const parsed = JSON.parse((0, fs_1.readFileSync)(REGISTRY_PATH, 'utf8')); if (parsed && typeof parsed === 'object' && parsed.version === 1 && typeof parsed.projects === 'object') { return parsed; } } catch (_a) { /* fall through */ } return { projects: {}, version: 1 }; } /** Load session state for a project root. */ function loadSession(root, sessionFile = SESSION_FILE) { const file = (0, path_1.join)(root, SESSION_DIR, sessionFile); if (!(0, fs_1.existsSync)(file)) return null; try { const parsed = JSON.parse((0, fs_1.readFileSync)(file, 'utf8')); if (parsed && typeof parsed === 'object' && typeof parsed.pids === 'object' && typeof parsed.startedAt === 'string') { // Validate PIDs const pids = {}; if (isValidPid(parsed.pids.api)) pids.api = parsed.pids.api; if (isValidPid(parsed.pids.app)) pids.app = parsed.pids.app; return { pids, startedAt: parsed.startedAt }; } } catch (_a) { /* fall through */ } return null; } /** Atomically persist the registry. */ function saveRegistry(reg) { (0, fs_1.mkdirSync)((0, path_1.dirname)(REGISTRY_PATH), { recursive: true }); const tmp = `${REGISTRY_PATH}.tmp`; (0, fs_1.writeFileSync)(tmp, JSON.stringify(reg, null, 2), 'utf8'); // rename is atomic on POSIX (0, fs_1.writeFileSync)(REGISTRY_PATH, (0, fs_1.readFileSync)(tmp, 'utf8'), 'utf8'); try { (0, fs_1.rmSync)(tmp); } catch (_a) { /* best-effort */ } } /** Persist session state for a project root. */ function saveSession(root, state, sessionFile = SESSION_FILE) { const dir = (0, path_1.join)(root, SESSION_DIR); (0, fs_1.mkdirSync)(dir, { recursive: true }); (0, fs_1.writeFileSync)((0, path_1.join)(dir, sessionFile), JSON.stringify(state, null, 2), 'utf8'); } /** Collect all internal ports already claimed across the registry. */ function takenInternalPorts(reg, excludeSlug) { const ports = new Set(); for (const [slug, entry] of Object.entries(reg.projects)) { if (slug === excludeSlug) continue; if (entry.internalPorts.api) ports.add(entry.internalPorts.api); if (entry.internalPorts.app) ports.add(entry.internalPorts.app); } return ports; } /** True if two paths resolve to the same location (normalising symlinks, e.g. /var → /private/var). */ function sameRealPath(a, b) { try { return (0, fs_1.realpathSync)(a) === (0, fs_1.realpathSync)(b); } catch (_a) { return a === b; } } const LOCK_PATH = `${REGISTRY_PATH}.lock`; /** * Run `fn` while holding an EXCLUSIVE lock on the registry, so concurrent * `lt dev` invocations — e.g. two parallel `lt dev test` in different ticket * worktrees — cannot read-modify-write the registry, or allocate the SAME * internal ports, at the same time. (Without this, two simultaneous test runs * both read the registry before either saves, both pick the same free ports, * and the second server fails to bind — the port-allocation race.) * * The lock is a single atomically-created lock file (`openSync(..,'wx')`); a * stale lock left by a crashed process (older than `staleMs`) is reclaimed. * Keep `fn` SHORT — allocation + reservation only, NEVER across a build/spawn. */ function withRegistryLock(fn_1) { return __awaiter(this, arguments, void 0, function* (fn, opts = {}) { var _a, _b; const staleMs = (_a = opts.staleMs) !== null && _a !== void 0 ? _a : 30000; const timeoutMs = (_b = opts.timeoutMs) !== null && _b !== void 0 ? _b : 20000; const start = Date.now(); (0, fs_1.mkdirSync)((0, path_1.dirname)(LOCK_PATH), { recursive: true }); let fd = null; while (fd === null) { try { fd = (0, fs_1.openSync)(LOCK_PATH, 'wx'); // atomic exclusive create — throws if held } catch (_c) { try { if (Date.now() - (0, fs_1.statSync)(LOCK_PATH).mtimeMs > staleMs) (0, fs_1.unlinkSync)(LOCK_PATH); // reclaim a crashed holder } catch (_d) { /* lock vanished between calls — just retry */ } if (Date.now() - start > timeoutMs) throw new Error(`registry lock busy for >${timeoutMs}ms (${LOCK_PATH})`); yield delay(40 + Math.floor(Math.random() * 60)); // jittered backoff } } try { return yield fn(); } finally { try { (0, fs_1.closeSync)(fd); } catch (_e) { /* ignore */ } try { (0, fs_1.unlinkSync)(LOCK_PATH); } catch (_f) { /* ignore */ } } }); } /** Promise-based delay for the lock retry loop. */ function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** Path constants exported for tests + status displays. */ exports.paths = { registry: REGISTRY_PATH, sessionDir: SESSION_DIR, sessionFile: SESSION_FILE, };