open-code-review/scripts/update.js
kite d2c6f616c7 feat: add async background auto-update mechanism
When users run any ocr command, the Node.js shim spawns a detached
background process that checks GitHub Releases for newer versions and
atomically replaces the binary. This ensures users stay on recent
versions without blocking their current command execution.

- Cooldown: checks at most once per hour (configurable via OCR_UPDATE_INTERVAL)
- Disable: set OCR_NO_UPDATE=1 to skip entirely
- Safety: PID lock prevents concurrent updates, SHA-256 checksum verification,
  atomic rename ensures running processes are unaffected
2026-05-22 16:15:51 +08:00

199 lines
5 KiB
JavaScript

#!/usr/bin/env node
"use strict";
const fs = require("fs");
const path = require("path");
const os = require("os");
const https = require("https");
const { spawnSync } = require("child_process");
const {
detectPlatform,
loadPackageJson,
buildUrl,
downloadText,
downloadBinary,
computeChecksum,
} = require("./install.js");
const packageRoot = path.join(__dirname, "..");
const binDir = path.join(packageRoot, "bin");
const binaryPath = path.join(binDir, "opencodereview");
const stateDir = path.join(os.homedir(), ".open-code-review");
const tsFile = path.join(stateDir, "last-update-check");
const lockFile = path.join(stateDir, "update.lock");
const GITHUB_API_URL =
"https://api.github.com/repos/alibaba/open-code-review/releases/latest";
function touchTimestamp() {
fs.mkdirSync(stateDir, { recursive: true });
const now = new Date();
try {
fs.utimesSync(tsFile, now, now);
} catch (_) {
fs.writeFileSync(tsFile, now.toISOString());
}
}
function acquireLock() {
fs.mkdirSync(stateDir, { recursive: true });
try {
fs.writeFileSync(lockFile, String(process.pid), { flag: "wx" });
return true;
} catch (e) {
if (e.code !== "EEXIST") return false;
try {
const pid = parseInt(fs.readFileSync(lockFile, "utf8").trim(), 10);
process.kill(pid, 0);
return false;
} catch (_) {
try {
fs.unlinkSync(lockFile);
fs.writeFileSync(lockFile, String(process.pid), { flag: "wx" });
return true;
} catch (_2) {
return false;
}
}
}
}
function releaseLock() {
try {
fs.unlinkSync(lockFile);
} catch (_) {}
}
function getInstalledVersion() {
try {
const result = spawnSync(binaryPath, ["version"], {
encoding: "utf8",
timeout: 3000,
});
const match = (result.stdout || "").match(/v(\d+\.\d+(?:\.\d+)?)/);
return match ? match[1] : null;
} catch (_) {
return null;
}
}
function fetchLatestVersion() {
return new Promise((resolve) => {
const options = {
headers: {
"User-Agent": "ocr-updater",
Accept: "application/vnd.github.v3+json",
},
timeout: 15000,
};
const req = https
.get(GITHUB_API_URL, options, (res) => {
if (res.statusCode !== 200) {
res.resume();
resolve(null);
return;
}
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
try {
const json = JSON.parse(data);
const tag = json.tag_name || "";
const version = tag.startsWith("v") ? tag.slice(1) : tag;
resolve(version || null);
} catch (_) {
resolve(null);
}
});
res.on("error", () => resolve(null));
})
.on("error", () => resolve(null));
req.on("timeout", () => {
req.destroy();
resolve(null);
});
});
}
function semverGt(a, b) {
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
for (let i = 0; i < 3; i++) {
if ((pa[i] || 0) > (pb[i] || 0)) return true;
if ((pa[i] || 0) < (pb[i] || 0)) return false;
}
return false;
}
function cleanupTemp() {
try {
const files = fs.readdirSync(binDir);
for (const f of files) {
if (f.startsWith(".opencodereview.tmp.")) {
fs.unlinkSync(path.join(binDir, f));
}
}
} catch (_) {}
}
async function main() {
touchTimestamp();
if (!acquireLock()) return;
cleanupTemp();
try {
const installedVersion = getInstalledVersion();
if (!installedVersion) return;
const latestVersion = await fetchLatestVersion();
if (!latestVersion) return;
if (!semverGt(latestVersion, installedVersion)) return;
const { os: platform, arch } = detectPlatform();
const pkg = loadPackageJson();
const config = pkg.ocrConfig;
const vars = { version: latestVersion, os: platform, arch };
const downloadUrl = buildUrl(config.urlPattern, vars);
const tempPath = path.join(binDir, `.opencodereview.tmp.${process.pid}`);
await downloadBinary(downloadUrl, tempPath);
fs.chmodSync(tempPath, 0o755);
if (config.checksumPattern) {
try {
const checksumUrl = buildUrl(config.checksumPattern, vars);
const shaContent = await downloadText(checksumUrl);
const actualSha = await computeChecksum(tempPath);
let verified = false;
for (const line of shaContent.split("\n")) {
const trimmed = line.trim();
if (trimmed.includes(`-${platform}-${arch}`)) {
const expectedSha = trimmed.split(/\s+/)[0].toLowerCase();
if (expectedSha && actualSha !== expectedSha) {
fs.unlinkSync(tempPath);
return;
}
verified = true;
break;
}
}
} catch (_) {
// checksum fetch failed, continue with the download
}
}
fs.renameSync(tempPath, binaryPath);
} catch (_) {
cleanupTemp();
} finally {
releaseLock();
}
}
main().catch(() => {});