201 lines
5.6 KiB
JavaScript
201 lines
5.6 KiB
JavaScript
#!/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;
|
|
targetRef: 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 targetRef = values.get("--target-ref");
|
|
const parent = Number(values.get("--parent"));
|
|
if (
|
|
!repo ||
|
|
!branch ||
|
|
!from ||
|
|
!to ||
|
|
!targetRef ||
|
|
!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.");
|
|
}
|
|
if (targetRef !== `refs/remotes/xo-updater/${branch}`) {
|
|
throw new Error("Invalid updater target reference.");
|
|
}
|
|
return { repo, branch, from, to, targetRef, 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", options.targetRef], 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:unsafe"], 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:unsafe"], 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;
|
|
});
|