Files
EvoScientist-WebUI/bin/updater.mjs
T

126 lines
3.5 KiB
JavaScript

#!/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 <plan.json> --parent-pid <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)); // <staging>/v<ver>/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);