feat(updates): auto-apply and self-restart on new dashboard versions

Adds POST /api/updates/apply (pull, rebuild, restart) plus an "Update now"
button and hourly auto-check in the UI, for checkouts that are safely
fast-forwardable. Reintroduces self-restart (previously removed in edc25ca
for cross-environment reliability concerns) per explicit user request.
This commit is contained in:
2026-08-18 16:04:27 +07:00
parent f053051a5d
commit 8e49d2b300
15 changed files with 502 additions and 19 deletions
+32
View File
@@ -0,0 +1,32 @@
/**
* @file Triggers the one self-restart path this dashboard has: after
* `POST /api/updates/apply` fast-forwards and rebuilds the checkout, the
* running process needs to relaunch itself on the new code. Reuses the
* existing SIGTERM graceful-shutdown path in `server/index.js` (closes
* websockets, drains the HTTP server, closes the DB) instead of duplicating
* it, and hands the "wait for the old PID to actually die, then spawn the
* replacement" job to a detached helper script — the dying process can't do
* that itself without racing its own replacement for the port.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const path = require("path");
const { spawn } = require("child_process");
/**
* @param {string} [root] repo root the helper should run from.
*/
function scheduleRestart(root = path.join(__dirname, "..", "..")) {
const helperPath = path.join(root, "scripts", "restart-helper.js");
const entry = process.argv[1] || path.join(root, "server", "index.js");
const helper = spawn(process.execPath, [helperPath, String(process.pid), entry], {
cwd: root,
detached: true,
stdio: "ignore",
env: process.env,
});
helper.unref();
process.kill(process.pid, "SIGTERM");
}
module.exports = { scheduleRestart };
+62 -1
View File
@@ -54,6 +54,24 @@ function execGit(cwd, args, opts = {}) {
});
}
/** Same shape as {@link execGit}, for the `npm run setup`/`npm run build` steps
* {@link applyUpdate} runs after fast-forwarding. */
function execNpm(cwd, args, opts = {}) {
const timeout = opts.timeout ?? 300_000;
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
return new Promise((resolve, reject) => {
execFile(
npmCmd,
args,
{ cwd, timeout, maxBuffer: 5_000_000, encoding: "utf8" },
(err, stdout, stderr) => {
if (err) reject(new Error(stderr || err.message || String(err)));
else resolve(String(stdout).trim());
}
);
});
}
async function listRemotes(gitRoot) {
try {
const out = await execGit(gitRoot, ["remote"], { timeout: 10_000 });
@@ -273,4 +291,47 @@ async function getUpdatesStatus(gitRoot = DEFAULT_ROOT, options = {}) {
};
}
module.exports = { getUpdatesStatus, DEFAULT_ROOT };
/**
* Fast-forwards the checkout to the canonical remote and rebuilds, for the
* "Update now" button — the one path in this dashboard that mutates the
* working tree and restarts the process on its own initiative (everywhere
* else, `server/routes/updates.js` only ever prints a command for the user
* to run). Only acts when {@link getUpdatesStatus} reports a situation that
* is actually fast-forwardable (`tracking_canonical` or
* `fork_or_diverged_tracking`); a feature branch or detached HEAD is left
* alone since there's no safe automatic move.
*
* @param {string} [gitRoot]
* @returns {Promise<object>} the refreshed status, plus `applied` (and
* `reason` when `applied` is false).
*/
async function applyUpdate(gitRoot = DEFAULT_ROOT) {
const status = await getUpdatesStatus(gitRoot);
if (!status.update_available) {
return { ...status, applied: false, reason: "up_to_date" };
}
if (
status.situation !== "tracking_canonical" &&
status.situation !== "fork_or_diverged_tracking"
) {
return { ...status, applied: false, reason: "not_fast_forwardable" };
}
const root = path.resolve(gitRoot);
if (status.situation === "tracking_canonical") {
await execGit(root, ["pull", "--ff-only"], { timeout: 120_000 });
} else {
await execGit(root, ["fetch", status.canonical_remote], { timeout: 120_000 });
await execGit(root, ["merge", "--ff-only", status.remote_ref], { timeout: 30_000 });
}
await execNpm(root, ["run", "setup"]);
if (process.env.NODE_ENV === "production") {
await execNpm(root, ["run", "build"]);
}
const refreshed = await getUpdatesStatus(root, { skipFetch: true });
return { ...refreshed, applied: true };
}
module.exports = { getUpdatesStatus, applyUpdate, DEFAULT_ROOT };