#!/usr/bin/env node // Standalone WebUI updater. ZERO dependencies (node builtins only): this file // is copied into the staging dir and run while the @evoscientist/webui package // is being replaced on disk. import { spawn, spawnSync } from "node:child_process"; import { appendFileSync, copyFileSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; const PID_WAIT_TIMEOUT_MS = 300_000; function parseArgs(argv) { const args = { plan: null, parentPid: null }; for (let i = 2; i < argv.length; i++) { if (argv[i] === "--plan") args.plan = argv[++i]; if (argv[i] === "--parent-pid") args.parentPid = parseInt(argv[++i], 10); } if (!args.plan || !args.parentPid) { console.error("usage: node updater.mjs --plan --parent-pid "); process.exit(2); } return args; } function pidAlive(pid) { try { process.kill(pid, 0); return true; } catch { return false; } } function sleepSync(ms) { spawnSync(process.execPath, ["-e", `setTimeout(()=>{},${ms})`]); } function waitForPidExit(pid, timeoutMs) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (!pidAlive(pid)) return true; const wait = Math.min(500, deadline - Date.now()); if (wait > 0) sleepSync(wait); } return !pidAlive(pid); } function appendLog(logPath, text) { appendFileSync(logPath, text); } function writeResult(plan, staging, status, stage, message) { const payload = { status, stage, version: plan.version, previous_version: plan.previous_version ?? "", message, log: plan.log_path, }; writeFileSync(plan.result_path, JSON.stringify(payload, null, 2)); copyFileSync(plan.result_path, join(staging, "last-result.json")); } function main() { const { plan: planPath, parentPid } = parseArgs(process.argv); const plan = JSON.parse(readFileSync(planPath, "utf-8")); const staging = dirname(dirname(plan.result_path)); // /v/update-result.json appendLog(plan.log_path, `updater start: pid=${process.pid} parent=${parentPid}\n`); if (!waitForPidExit(parentPid, PID_WAIT_TIMEOUT_MS)) { writeResult(plan, staging, "failed", "wait", `parent ${parentPid} still alive`); process.exit(1); } const install = spawnSync(plan.install_command[0], plan.install_command.slice(1), { cwd: plan.cwd, encoding: "utf-8", }); appendLog( plan.log_path, `install rc=${install.status}\n${install.stdout ?? ""}\n${install.stderr ?? ""}\n` ); if (install.status !== 0) { writeResult( plan, staging, "failed", "install", (install.stderr ?? "").trim() || "install failed" ); process.exit(1); } return { plan, staging }; } // spawn() reports a missing binary via an async "error" event, not by // throwing — yield to the event loop before deciding the spawn worked. async function respawn(plan, staging) { let spawnError = null; try { const child = spawn(plan.respawn_command[0], plan.respawn_command.slice(1), { cwd: plan.cwd, detached: true, stdio: "ignore", }); child.on("error", (err) => { spawnError = err; }); child.unref(); } catch (err) { spawnError = err; } await new Promise((resolve) => setTimeout(resolve, 100)); if (spawnError) { writeResult(plan, staging, "installed_restart_failed", "restart", String(spawnError)); process.exit(1); } writeResult(plan, staging, "success", null, "update applied"); process.exit(0); } const { plan, staging } = main(); await respawn(plan, staging);