UNPKG

@lenne.tech/cli

Version:

lenne.Tech CLI: lt

345 lines (344 loc) 13.5 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.checkPortInUse = checkPortInUse; exports.killProcessGroup = killProcessGroup; exports.listenSnapshot = listenSnapshot; exports.rotateLogFile = rotateLogFile; exports.runChildInherit = runChildInherit; exports.runChildToFile = runChildToFile; exports.spawnDetached = spawnDetached; exports.terminateProcessGroup = terminateProcessGroup; exports.waitForHttp = waitForHttp; /** * Process + port helpers for `lt dev`. * * - `spawnDetached`: detached child whose stdout/stderr go to a log file. * The Claude Code session does NOT block waiting for it, and `lt dev down` * can SIGTERM the entire process group via `process.kill(-pid, …)`. * - `listenSnapshot` / `checkPortInUse`: thin lsof wrappers used by * `lt dev doctor` to detect port collisions. */ const child_process_1 = require("child_process"); const fs_1 = require("fs"); const path_1 = require("path"); const dev_state_1 = require("./dev-state"); /** * Check via `lsof` whether a single TCP port is bound by a LISTEN socket. * Returns null if lsof is unavailable. */ function checkPortInUse(port) { return __awaiter(this, void 0, void 0, function* () { return new Promise((resolve) => { var _a; const child = (0, child_process_1.spawn)('lsof', ['-iTCP', `-sTCP:LISTEN`, '-nP', `-iTCP:${port}`], { stdio: ['ignore', 'pipe', 'pipe'], }); let stdout = ''; let errored = false; (_a = child.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (b) => (stdout += String(b))); child.on('error', () => (errored = true)); child.on('close', () => { if (errored) return resolve(null); const lines = stdout.split('\n').filter((l) => l && !l.startsWith('COMMAND')); const matching = lines.find((l) => new RegExp(`[: ]${port}\\s.*\\(LISTEN\\)`).test(l) || l.includes(`:${port} (LISTEN)`)); if (!matching) return resolve({ inUse: false }); const parts = matching.trim().split(/\s+/); resolve({ command: parts[0], inUse: true, pid: Number(parts[1]) }); }); }); }); } /** Send SIGTERM to a detached process group; falls back to single-PID kill. */ function killProcessGroup(pid) { if (!(0, dev_state_1.isValidPid)(pid)) return false; try { process.kill(-pid, 'SIGTERM'); return true; } catch (_a) { try { process.kill(pid, 'SIGTERM'); return true; } catch (_b) { return false; } } } /** * Multi-port lsof snapshot — single subprocess for N ports. * Returns map<port, {command, pid}> for ports that are in use. */ function listenSnapshot(ports) { return __awaiter(this, void 0, void 0, function* () { const result = new Map(); if (ports.length === 0) return result; return new Promise((resolve) => { var _a; const portArgs = ports.flatMap((p) => ['-iTCP', `-iTCP:${p}`]); const child = (0, child_process_1.spawn)('lsof', ['-sTCP:LISTEN', '-nP', ...portArgs], { stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = ''; let errored = false; (_a = child.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (b) => (stdout += String(b))); child.on('error', () => (errored = true)); child.on('close', () => { if (errored) return resolve(result); for (const line of stdout.split('\n')) { if (!line || line.startsWith('COMMAND')) continue; const parts = line.trim().split(/\s+/); if (parts.length < 9) continue; const command = parts[0]; const pid = Number(parts[1]); const name = parts[8]; const portMatch = name.match(/:(\d+)$/); if (!portMatch) continue; const port = Number(portMatch[1]); if (ports.includes(port) && /\(LISTEN\)/.test(line)) { result.set(port, { command, pid }); } } resolve(result); }); }); }); } /** * Rotate a log file: rename existing `<logFile>` to `<logFile>.1`, dropping * any previous `.1`. Keeps exactly one prior generation so the most recent * `lt dev down`-able session stays inspectable without unbounded growth. * * Returns `{ rotated: false }` when no prior log exists. */ function rotateLogFile(logFile) { let previousSize; try { previousSize = (0, fs_1.statSync)(logFile).size; } catch (_a) { return { rotated: false }; } const archivePath = `${logFile}.1`; try { (0, fs_1.unlinkSync)(archivePath); } catch (_b) { /* nothing to remove */ } try { (0, fs_1.renameSync)(logFile, archivePath); } catch (_c) { return { rotated: false }; } return { archivePath, previousSize, rotated: true }; } /** * Run a child to completion with inherited stdio. Resolves with the exit code. * * Counterpart of `spawnDetached`: foreground, synchronous-feeling, used for * commands the user must see live output from (build, test runners). * Errors during spawn resolve as exit code `1` so callers can branch on a * single integer instead of try/catch. */ function runChildInherit(cmd, args, opts) { return new Promise((resolve) => { const child = (0, child_process_1.spawn)(cmd, args, { cwd: opts.cwd, env: opts.env, stdio: 'inherit' }); child.on('error', () => resolve(1)); child.on('close', (code) => resolve(code)); }); } /** * Run a child to completion with stdout+stderr redirected to a log file. * Resolves with the exit code (`1` on spawn error). Like `runChildInherit` but * non-interleaving — used to run several children CONCURRENTLY (e.g. parallel * Playwright shards) without their console output clobbering each other. */ function runChildToFile(cmd, args, opts) { (0, fs_1.mkdirSync)((0, path_1.dirname)(opts.logFile), { recursive: true }); // Rotate so each run starts with a fresh log (one prior generation kept as // `<logFile>.1`) instead of appending run-on-run. rotateLogFile(opts.logFile); const out = (0, fs_1.openSync)(opts.logFile, 'a'); const close = () => { try { (0, fs_1.closeSync)(out); } catch (_a) { /* already closed */ } }; return new Promise((resolve) => { let child; try { child = (0, child_process_1.spawn)(cmd, args, { cwd: opts.cwd, env: opts.env, stdio: ['ignore', out, out] }); } catch (_a) { close(); return resolve(1); } child.on('error', () => { close(); resolve(1); }); child.on('close', (code) => { close(); resolve(code); }); }); } /** * Spawn a detached child whose stdio is redirected to a log file. * * Rotates any previous log first (one generation kept as `<logFile>.1`) so * each session starts with a fresh file. Prevents the multi-day accumulation * that produced ~10 GB logs under continuous `up`/`down` cycles. * * The parent's copy of the log file descriptor is closed in `finally` * — the child has already inherited its own fd before `spawn` returns, * so closing prevents fd leaks and avoids racing-write artifacts on * filesystems where O_APPEND is not atomic. * * Returns the child PID, or undefined if spawn failed. */ function spawnDetached(cmd, args, opts) { (0, fs_1.mkdirSync)((0, path_1.dirname)(opts.logFile), { recursive: true }); const rotated = rotateLogFile(opts.logFile); const out = (0, fs_1.openSync)(opts.logFile, 'a'); let child; try { child = (0, child_process_1.spawn)(cmd, args, { cwd: opts.cwd, detached: true, env: opts.env, stdio: ['ignore', out, out], }); child.unref(); if (child.pid === undefined) return undefined; return { pid: child.pid, rotated }; } catch (_a) { return undefined; } finally { try { (0, fs_1.closeSync)(out); } catch (_b) { /* fd already inherited by child; ignore */ } } } /** * Terminate a detached process group RELIABLY: SIGTERM the group, wait up to * `graceMs` for it to exit, then SIGKILL anything still alive. * * Needed because a compiled NestJS API (`node dist`) installs shutdown hooks * that catch SIGTERM and can hang on open Mongo connections — a single * SIGTERM then "succeeds" (the call returns) while the process keeps * listening on its port and holding DB connections. `lt dev test`'s * residue-free teardown promise depends on the process actually being gone, * so we escalate to SIGKILL after a grace period. * * Polls every 150ms so a process that exits cleanly returns near-instantly * (only a hung process waits the full `graceMs`). Returns true if the process * is gone by the end, false if it somehow survived even SIGKILL. */ function terminateProcessGroup(pid_1) { return __awaiter(this, arguments, void 0, function* (pid, graceMs = 4000) { if (!(0, dev_state_1.isValidPid)(pid)) return false; if (!(0, dev_state_1.isPidAlive)(pid)) return true; // Phase 1 — graceful: SIGTERM the group (single-PID fallback inside). killProcessGroup(pid); const deadline = Date.now() + Math.max(0, graceMs); while (Date.now() < deadline) { if (!(0, dev_state_1.isPidAlive)(pid)) return true; yield delay(150); } // Phase 2 — forced: SIGKILL the group, then the single PID. if (!(0, dev_state_1.isPidAlive)(pid)) return true; try { process.kill(-pid, 'SIGKILL'); } catch (_a) { /* group already gone or pid is not a group leader */ } try { process.kill(pid, 'SIGKILL'); } catch (_b) { /* already dead */ } yield delay(150); return !(0, dev_state_1.isPidAlive)(pid); }); } /** * Poll an HTTPS/HTTP URL until a matching response is observed or `timeoutMs` * elapses. * * Used to wait for dev servers to become reachable before the next step * (typically running a test suite). By default treats ANY HTTP status (1xx-5xx) * as "up" — a 404 means the server is bound and answering, which is usually * what we want to know. Pass `ready` to require a stricter status: an API * readiness probe wants a real 2xx on `/meta`, because Caddy answers 502 while * its upstream is still booting and the default predicate would accept that * prematurely (the cause of the test-suite API-readiness race). Uses `curl` * because it is universally available and handles HTTPS-with-self-signed-cert * (Caddy) via `-k` for free. * * Resolves `true` on the first matching response, `false` on timeout. Never rejects. */ function waitForHttp(url, timeoutMs, ready = (status) => status >= 100 && status < 600) { const start = Date.now(); return new Promise((resolve) => { const tick = () => { var _a; const child = (0, child_process_1.spawn)('curl', ['-sk', '-o', '/dev/null', '-w', '%{http_code}', '--max-time', '2', url], { stdio: ['ignore', 'pipe', 'ignore'], }); let status = ''; (_a = child.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (b) => (status += String(b))); child.on('close', () => { const code = Number(status.trim()); // `000` (curl could not connect) parses to 0 → never "ready". if (Number.isFinite(code) && code > 0 && ready(code)) return resolve(true); if (Date.now() - start > timeoutMs) return resolve(false); setTimeout(tick, 500); }); child.on('error', () => { if (Date.now() - start > timeoutMs) return resolve(false); setTimeout(tick, 500); }); }; tick(); }); } /** Promise-based delay used by the graceful→forced termination escalation. */ function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }