@lenne.tech/cli
Version:
lenne.Tech CLI: lt
251 lines (250 loc) • 9.91 kB
JavaScript
;
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,
};