add auto-updater

This commit is contained in:
2026-09-08 17:33:55 +00:00
parent d1793cc11b
commit 8763226ccd
12 changed files with 685 additions and 29 deletions
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { openSync, closeSync, readFileSync, unlinkSync } from "node:fs";
import { resolve } from "node:path";
interface HelperOptions {
repo: string;
branch: string;
from: string;
to: string;
parent: number;
restart: boolean;
}
function parseOptions(args: string[]): HelperOptions {
const values = new Map<string, string>();
let restart = false;
for (let index = 0; index < args.length; index += 1) {
const key = args[index];
if (key === "--restart") {
restart = true;
continue;
}
const value = args[index + 1];
if (!key?.startsWith("--") || value === undefined) {
throw new Error("Invalid updater helper arguments.");
}
values.set(key, value);
index += 1;
}
const repo = values.get("--repo");
const branch = values.get("--branch");
const from = values.get("--from");
const to = values.get("--to");
const parent = Number(values.get("--parent"));
if (!repo || !branch || !from || !to || !Number.isInteger(parent)) {
throw new Error("Missing updater helper arguments.");
}
if (!/^[0-9a-f]{40,64}$/u.test(from) || !/^[0-9a-f]{40,64}$/u.test(to)) {
throw new Error("Invalid updater commit identifiers.");
}
return { repo, branch, from, to, parent, restart };
}
function run(command: string, args: string[], cwd: string): Promise<void> {
return new Promise((resolveRun, reject) => {
const child = spawn(command, args, { cwd, stdio: "inherit" });
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) resolveRun();
else reject(new Error(`${command} exited with status ${code ?? 1}`));
});
});
}
function capture(
command: string,
args: string[],
cwd: string,
): Promise<string> {
return new Promise((resolveCapture, reject) => {
const child = spawn(command, args, {
cwd,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) resolveCapture(stdout.trim());
else
reject(
new Error(
stderr.trim() || `${command} exited with status ${code ?? 1}`,
),
);
});
});
}
async function waitForExit(pid: number): Promise<void> {
while (true) {
try {
process.kill(pid, 0);
await new Promise((resolveWait) => setTimeout(resolveWait, 100));
} catch {
return;
}
}
}
async function main(): Promise<void> {
const options = parseOptions(process.argv.slice(2));
await waitForExit(options.parent);
const packageJson = JSON.parse(
readFileSync(resolve(options.repo, "package.json"), "utf8"),
) as { name?: unknown };
if (packageJson.name !== "@xo-cash/cli") {
throw new Error("Refusing to update an unexpected repository.");
}
const gitLockPath = await capture(
"git",
["rev-parse", "--git-path", "xo-update.lock"],
options.repo,
);
const lockPath = resolve(options.repo, gitLockPath);
let lock: number | undefined;
try {
lock = openSync(lockPath, "wx");
} catch {
throw new Error("Another XO update is already running.");
}
let updated = false;
try {
console.log(`Updating ${options.branch} to ${options.to.slice(0, 7)}`);
const [branch, head, target, status] = await Promise.all([
capture("git", ["branch", "--show-current"], options.repo),
capture("git", ["rev-parse", "HEAD"], options.repo),
capture(
"git",
["rev-parse", `refs/remotes/origin/${options.branch}`],
options.repo,
),
capture("git", ["status", "--porcelain"], options.repo),
]);
if (
branch !== options.branch ||
head !== options.from ||
target !== options.to
) {
throw new Error(
"The checkout changed after the update check; please try again.",
);
}
if (status !== "") {
throw new Error("The checkout has local changes; update cancelled.");
}
await run("git", ["merge", "--ff-only", options.to], options.repo);
updated = true;
await run("npm", ["ci"], options.repo);
await run("npm", ["run", "build"], options.repo);
console.log("XO was updated successfully.");
} catch (error) {
console.error("Update failed; restoring the previous revision.");
if (updated) {
try {
await run("git", ["reset", "--hard", options.from], options.repo);
await run("npm", ["ci"], options.repo);
await run("npm", ["run", "build"], options.repo);
console.error("The previous revision was restored.");
} catch (rollbackError) {
console.error("Automatic rollback also failed:", rollbackError);
}
}
throw error;
} finally {
if (lock !== undefined) closeSync(lock);
try {
unlinkSync(lockPath);
} catch {
// The lock is best-effort cleanup; a missing lock needs no action.
}
}
if (options.restart) {
const entryPath = resolve(options.repo, "dist/index.js");
const child = spawn(process.execPath, [entryPath], {
cwd: options.repo,
detached: true,
stdio: "inherit",
});
child.unref();
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});