#!/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(); 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 { 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 { 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 { while (true) { try { process.kill(pid, 0); await new Promise((resolveWait) => setTimeout(resolveWait, 100)); } catch { return; } } } async function main(): Promise { 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; });