import { readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { spawn, spawnSync } from "node:child_process"; // ── Config ─────────────────────────────────────────────────────────────────── // Project config: ralph.config.json at repo root. Missing → auto-detect (vite). // Fields: kind, port, url, routes[], serveStart, smokeBuild, smokeStart, env{}, ttlSeconds. const DEFAULT_PORT = 4173; function readJSON(p) { try { return JSON.parse(readFileSync(p, "utf-8")); } catch { return null; } } function depHas(pkg, name) { return !!(pkg?.dependencies?.[name] || pkg?.devDependencies?.[name]); } function autoDetect(root) { const pkg = readJSON(join(root, "package.json")) ?? {}; const scripts = pkg.scripts ?? {}; const port = DEFAULT_PORT; const url = `http://localhost:${port}`; const isVite = depHas(pkg, "vite") || depHas(pkg, "@sveltejs/kit") || !!scripts.preview; if (isVite) { return { kind: "web", port, url, routes: ["/"], // serve (exploration): dev server gives source errors + HMR; WE supervise+reap it. serveStart: scripts.dev ? `npm run dev -- --port ${port} --strictPort` : `npx vite dev --port ${port} --strictPort`, // smoke (deterministic): production build + preview. smokeBuild: scripts.build ? "npm run build" : "npx vite build", smokeStart: `npx vite preview --port ${port} --strictPort`, env: {}, ttlSeconds: 1800, }; } // generic node server fallback return { kind: scripts.start ? "server" : "cli", port, url, routes: ["/"], serveStart: scripts.start ? "npm start" : "", smokeBuild: scripts.build ? "npm run build" : "", smokeStart: scripts.start ? "npm start" : "", env: {}, ttlSeconds: 1800, }; } export function loadConfig(root) { const declared = readJSON(join(root, "ralph.config.json")); const auto = autoDetect(root); const cfg = { ...auto, ...(declared ?? {}) }; cfg.url = cfg.url ?? `http://localhost:${cfg.port}`; cfg.routes = cfg.routes ?? ["/"]; cfg.env = cfg.env ?? {}; return cfg; } // ── Paths ──────────────────────────────────────────────────────────────────── export function ralphDir(root) { const d = join(root, ".ralph"); if (!existsSync(d)) mkdirSync(d, { recursive: true }); return d; } export const pidPath = (root) => join(ralphDir(root), "serve.pid"); export const logPath = (root) => join(ralphDir(root), "serve.log"); // ── Port hygiene ───────────────────────────────────────────────────────────── export function killPort(port) { // try fuser, then lsof — best-effort, never throws spawnSync("bash", ["-c", `fuser -k ${port}/tcp 2>/dev/null; true`], { timeout: 8000 }); spawnSync("bash", ["-c", `lsof -ti tcp:${port} 2>/dev/null | xargs -r kill -9 2>/dev/null; true`], { timeout: 8000 }); } // ── Process helpers ────────────────────────────────────────────────────────── // Spawn a server in its OWN process group (detached:true calls setsid() for the // child → child.pid == pgid), so we can kill the whole tree with kill(-pid) // without ever signalling our own group. Returns the child (group leader). export function spawnServer(startCmd, cwd, env, logFd) { return spawn("bash", ["-c", startCmd], { cwd, env: { ...process.env, ...env, CI: "1", NO_COLOR: "1", FORCE_COLOR: "0", BROWSER: "none" }, stdio: ["ignore", logFd, logFd], detached: true, }); } export function killGroup(pid, signal = "SIGTERM") { if (!pid || pid === process.pid || pid === process.ppid) return; // never signal ourselves try { process.kill(-pid, signal); // negative pid = the child's own process group } catch { try { process.kill(pid, signal); } catch { /* already dead */ } } } export function pidAlive(pid) { try { process.kill(pid, 0); return true; } catch { return false; } } // ── wait-on (bounded, no dep) ──────────────────────────────────────────────── export async function waitOn(url, timeoutMs = 60000, intervalMs = 500) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { const res = await fetch(url, { method: "GET", redirect: "manual", headers: { Connection: "close" } }); if (res.status > 0) return true; // any HTTP response = server is up } catch { /* not up yet */ } await sleep(intervalMs); } return false; } export const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // ── HTTP probe ─────────────────────────────────────────────────────────────── export async function httpProbe(baseUrl, routes, expectStatus = 200) { const rows = []; for (const route of routes) { const url = baseUrl.replace(/\/$/, "") + route; const t0 = Date.now(); try { const res = await fetch(url, { redirect: "manual", headers: { Connection: "close" } }); const ms = Date.now() - t0; const ctype = res.headers.get("content-type") ?? ""; const body = await res.text(); const expected = typeof expectStatus === "object" ? (expectStatus[route] ?? 200) : expectStatus; const redirect = res.status >= 300 && res.status < 400 ? res.headers.get("location") : null; rows.push({ route, status: res.status, ms, ctype: ctype.split(";")[0], bytes: Buffer.byteLength(body), redirect, ok: res.status === expected, snippet: res.status < 200 || res.status >= 300 ? body.replace(/\s+/g, " ").trim().slice(0, 200) : "", }); } catch (e) { rows.push({ route, status: 0, ms: Date.now() - t0, error: String(e.message), ok: false }); } } return rows; } // ── Browser probe (Playwright chromium, headless) ──────────────────────────── // Degrades gracefully to a note if playwright isn't installed. export async function browserProbe(baseUrl, route, actions = []) { let chromium; try { ({ chromium } = await import("playwright")); } catch { return { available: false, note: "browser probe skipped — playwright not installed. Run: cd ~/Sources/ralph-runtime && npm i playwright && npx playwright install chromium", consoleErrors: [], consoleMessages: [], ok: true, // don't fail the smoke just because the browser probe is unavailable }; } const errors = []; // pageerror + console.error — drives the verdict const messages = []; // EVERY console.* message (log/info/warn/error/debug), in order let browser; try { browser = await chromium.launch({ headless: true }); const page = await browser.newPage(); page.on("pageerror", (e) => { const t = String(e.message || e); errors.push(t); messages.push({ type: "pageerror", text: t }); }); page.on("console", (m) => { const type = m.type(); const text = m.text(); messages.push({ type, text }); if (type === "error") errors.push(text); }); const url = baseUrl.replace(/\/$/, "") + route; await page.goto(url, { waitUntil: "networkidle", timeout: 30000 }); for (const a of actions) { try { const [verb, ...rest] = a.split(/\s+/); if (verb === "click" && rest.length >= 2) { await page.mouse.click(Number(rest[0]), Number(rest[1])); } else if (verb === "clickText") { await page.getByText(rest.join(" ")).first().click({ timeout: 5000 }); } else if (verb === "wait") { await sleep(Number(rest[0]) || 500); } await sleep(300); } catch (e) { errors.push(`action "${a}" failed: ${String(e.message)}`); } } await sleep(400); // let async errors surface return { available: true, consoleErrors: errors, consoleMessages: messages, ok: errors.length === 0 }; } catch (e) { const t = `navigation failed: ${String(e.message)}`; return { available: true, consoleErrors: [...errors, t], consoleMessages: [...messages, { type: "pageerror", text: t }], ok: false }; } finally { if (browser) await browser.close().catch(() => {}); } } // ── misc ───────────────────────────────────────────────────────────────────── export function tailFile(p, n = 40) { try { const lines = readFileSync(p, "utf-8").split("\n"); return lines.slice(-n).join("\n"); } catch { return ""; } } export function writePid(root, pid, ttlSeconds) { writeFileSync(pidPath(root), JSON.stringify({ pid, startedAt: Date.now(), ttlSeconds })); } export function readPid(root) { return readJSON(pidPath(root)); } export function clearPid(root) { try { writeFileSync(pidPath(root), ""); } catch { /* ignore */ } }