fix(npm): improve binary staging for zero-downtime auto-upgrade

Replace the simple binary cache with a proper staged binary mechanism:

- Add version.json metadata tracking (version, timestamp, platform)
- Add platform validation to prevent cross-platform misuse
- Add file integrity checks (exists, non-empty) in resolveStagedBinary()
- Notify users via dim stderr message when running from staged binary
- Refresh staged binary after successful upgrade for next-time protection
- Reuse STATE_DIR from platform.js to eliminate path duplication
- Always write version.json to avoid orphan binaries on disk

Resolution priority: platform package > staged binary > legacy path.
This commit is contained in:
kite 2026-08-04 16:58:21 +08:00
parent 730f2243f6
commit c2312f9744
3 changed files with 94 additions and 28 deletions

View file

@ -17,6 +17,13 @@ if (!resolved) {
}
const binaryPath = resolved.path;
if (resolved.fromStaged) {
const ver = resolved.stagedVersion ? ` (v${resolved.stagedVersion})` : "";
process.stderr.write(
`\x1b[2m[ocr] Using staged binary${ver}; run npm i -g @alibaba-group/open-code-review to reinstall.\x1b[0m\n`
);
}
const hintFile = path.join(os.homedir(), ".opencodereview", "update-available");
try {
const hint = JSON.parse(fs.readFileSync(hintFile, "utf8"));

View file

@ -7,6 +7,10 @@ const os = require("os");
const IS_WINDOWS = process.platform === "win32";
const BINARY_FILENAME = IS_WINDOWS ? "opencodereview.exe" : "opencodereview";
const STATE_DIR = path.join(os.homedir(), ".opencodereview");
const STAGED_BIN_DIR = path.join(STATE_DIR, "staged");
const VERSION_JSON_PATH = path.join(STAGED_BIN_DIR, "version.json");
const PLATFORM_PKG = {
"darwin-arm64": "@alibaba-group/ocr-darwin-arm64",
"darwin-x64": "@alibaba-group/ocr-darwin-x64",
@ -34,6 +38,41 @@ function getPlatformPackageName() {
return PLATFORM_PKG[key] || null;
}
function resolveStagedBinary() {
try {
const raw = fs.readFileSync(VERSION_JSON_PATH, "utf8");
const meta = JSON.parse(raw);
if (!meta.version || !meta.stagedAt || !meta.platform) {
return null;
}
const currentPlatform = `${process.platform}-${process.arch}`;
if (meta.platform !== currentPlatform) {
return null;
}
const binPath = path.join(STAGED_BIN_DIR, BINARY_FILENAME);
let stat;
try {
stat = fs.statSync(binPath);
} catch (_) {
return null;
}
if (!stat.isFile() || stat.size === 0) {
return null;
}
return {
path: binPath,
fromPlatformPkg: false,
fromStaged: true,
stagedVersion: meta.version,
};
} catch (_) {
return null;
}
}
function resolveNativeBinary() {
const pkgName = getPlatformPackageName();
if (pkgName) {
@ -41,7 +80,7 @@ function resolveNativeBinary() {
const pkgDir = path.dirname(require.resolve(`${pkgName}/package.json`));
const binPath = path.join(pkgDir, "bin", BINARY_FILENAME);
if (fs.existsSync(binPath)) {
return { path: binPath, fromPlatformPkg: true, fromCache: false };
return { path: binPath, fromPlatformPkg: true, fromStaged: false };
}
} catch (err) {
if (err.code !== "MODULE_NOT_FOUND") {
@ -50,16 +89,14 @@ function resolveNativeBinary() {
}
}
const legacyPath = path.join(__dirname, "..", "bin", BINARY_FILENAME);
if (fs.existsSync(legacyPath)) {
return { path: legacyPath, fromPlatformPkg: false, fromCache: false };
const staged = resolveStagedBinary();
if (staged) {
return staged;
}
const cachePath = path.join(
os.homedir(), ".opencodereview", "bin", BINARY_FILENAME
);
if (fs.existsSync(cachePath)) {
return { path: cachePath, fromPlatformPkg: false, fromCache: true };
const legacyPath = path.join(__dirname, "..", "bin", BINARY_FILENAME);
if (fs.existsSync(legacyPath)) {
return { path: legacyPath, fromPlatformPkg: false, fromStaged: false };
}
return null;
@ -69,6 +106,10 @@ module.exports = {
IS_WINDOWS,
BINARY_FILENAME,
PLATFORM_PKG,
STATE_DIR,
STAGED_BIN_DIR,
VERSION_JSON_PATH,
getPlatformPackageName,
resolveStagedBinary,
resolveNativeBinary,
};

View file

@ -3,23 +3,27 @@
const fs = require("fs");
const path = require("path");
const os = require("os");
const https = require("https");
const { spawnSync } = require("child_process");
const { resolveNativeBinary } = require("./platform");
const {
resolveNativeBinary,
IS_WINDOWS,
BINARY_FILENAME,
STATE_DIR,
STAGED_BIN_DIR,
VERSION_JSON_PATH,
} = require("./platform");
const { loadPackageJson } = require("./install.js");
const stateDir = path.join(os.homedir(), ".opencodereview");
const tsFile = path.join(stateDir, "last-update-check");
const lockFile = path.join(stateDir, "update.lock");
const hintFile = path.join(stateDir, "update-available");
const tsFile = path.join(STATE_DIR, "last-update-check");
const lockFile = path.join(STATE_DIR, "update.lock");
const hintFile = path.join(STATE_DIR, "update-available");
const CACHE_BIN_DIR = path.join(stateDir, "bin");
const DEFAULT_REGISTRY = "https://registry.npmjs.org";
function touchTimestamp() {
fs.mkdirSync(stateDir, { recursive: true });
fs.mkdirSync(STATE_DIR, { recursive: true });
const now = new Date();
try {
fs.utimesSync(tsFile, now, now);
@ -29,7 +33,7 @@ function touchTimestamp() {
}
function acquireLock() {
fs.mkdirSync(stateDir, { recursive: true });
fs.mkdirSync(STATE_DIR, { recursive: true });
try {
fs.writeFileSync(lockFile, String(process.pid), { flag: "wx" });
return true;
@ -137,16 +141,27 @@ function removeHint() {
} catch (_) {}
}
function cacheBinary(srcPath) {
function writeVersionJson(version) {
const meta = {
version,
stagedAt: new Date().toISOString(),
platform: `${process.platform}-${process.arch}`,
};
fs.writeFileSync(VERSION_JSON_PATH, JSON.stringify(meta, null, 2));
}
function stageBinary(srcPath) {
try {
fs.mkdirSync(CACHE_BIN_DIR, { recursive: true });
const dest = path.join(CACHE_BIN_DIR, path.basename(srcPath));
fs.mkdirSync(STAGED_BIN_DIR, { recursive: true });
const dest = path.join(STAGED_BIN_DIR, BINARY_FILENAME);
const tmp = dest + ".tmp";
fs.copyFileSync(srcPath, tmp);
fs.renameSync(tmp, dest);
if (process.platform !== "win32") {
fs.chmodSync(dest, 0o755);
if (!IS_WINDOWS) {
fs.chmodSync(tmp, 0o755);
}
fs.renameSync(tmp, dest);
writeVersionJson(getInstalledVersion(dest) || "unknown");
} catch (_) {}
}
@ -173,10 +188,12 @@ async function main() {
return;
}
cacheBinary(resolved.path);
// Stage current binary before npm i -g to cover the gap window
if (!resolved.fromStaged) {
stageBinary(resolved.path);
}
const pkgName = pkg.name;
const IS_WINDOWS = process.platform === "win32";
const result = spawnSync("npm", ["i", "-g", `${pkgName}@${latestVersion}`], {
encoding: "utf8",
timeout: 120000,
@ -185,9 +202,10 @@ async function main() {
if (result.status === 0) {
removeHint();
// Refresh staged binary with the newly installed version
const newResolved = resolveNativeBinary();
if (newResolved && !newResolved.fromCache) {
cacheBinary(newResolved.path);
if (newResolved && !newResolved.fromStaged) {
stageBinary(newResolved.path);
}
} else {
writeHint(latestVersion, pkgName);