add auto-updater
This commit is contained in:
@@ -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;
|
||||
});
|
||||
Reference in New Issue
Block a user