126 lines
3.5 KiB
JavaScript
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);
|