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
+1 -1
View File
@@ -8,7 +8,7 @@
## Repo map ## Repo map
- `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes optional git upstream checks and `routes/updates.js`, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks). - `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes optional git upstream checks and `routes/updates.js`, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks).
- `client/`: React + Vite UI. - `client/`: React + Vite UI.
- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`; the dashboard never restarts itself — users run the printed command, surfaced in the UI and by `ccam update-check`.) - `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`. `POST /api/updates/apply` (only when the checkout is fast-forwardable) pulls, rebuilds, and self-restarts via `server/lib/self-restart.js` + the detached `scripts/restart-helper.js`; otherwise users run the printed manual command, surfaced in the UI and by `ccam update-check`.)
- `mcp/`: local MCP server exposing dashboard operations as tools. **`mcp/build/` is committed on purpose** — plugin MCP servers start before any bootstrap could build them; `scripts/check-mcp-build.js` (content hash in `mcp/build/.srchash`, run by pre-commit and `/ccam-doctor`) keeps it honest. Rebuild with `npm run mcp:build`, never hand-edit `mcp/build/`. - `mcp/`: local MCP server exposing dashboard operations as tools. **`mcp/build/` is committed on purpose** — plugin MCP servers start before any bootstrap could build them; `scripts/check-mcp-build.js` (content hash in `mcp/build/.srchash`, run by pre-commit and `/ccam-doctor`) keeps it honest. Rebuild with `npm run mcp:build`, never hand-edit `mcp/build/`.
- `.claude-plugin/`: the marketplace plus the root `ccam` plugin manifest (`"source": "./"` — the whole repo is the plugin). Its hooks are inline in `plugin.json`; its commands live in `plugins/ccam/commands/`, which is NOT a subdirectory plugin. `scripts/plugin-bootstrap.js` runs from `SessionStart` and owns the writable runtime under `~/.claude/agent-dashboard/runtime/` — it never writes into the plugin cache, which Claude Code replaces on every update. See `docs/PLUGINS.md`. - `.claude-plugin/`: the marketplace plus the root `ccam` plugin manifest (`"source": "./"` — the whole repo is the plugin). Its hooks are inline in `plugin.json`; its commands live in `plugins/ccam/commands/`, which is NOT a subdirectory plugin. `scripts/plugin-bootstrap.js` runs from `SessionStart` and owns the writable runtime under `~/.claude/agent-dashboard/runtime/` — it never writes into the plugin cache, which Claude Code replaces on every update. See `docs/PLUGINS.md`.
+98 -8
View File
@@ -2,11 +2,15 @@
* @file UpdateNotifier.tsx * @file UpdateNotifier.tsx
* @description Modal surfaced when the dashboard's git checkout is behind its * @description Modal surfaced when the dashboard's git checkout is behind its
* remote tracking branch. Shows how many commits behind, the exact terminal * remote tracking branch. Shows how many commits behind, the exact terminal
* command to update, and copy-to-clipboard — the dashboard never pulls or * command to update with copy-to-clipboard, andwhen the checkout is on a
* restarts itself. * fast-forwardable branch — an "Update now" button that calls
* `POST /api/updates/apply` to pull, rebuild, and restart the server itself,
* then polls until it's back and reloads the page.
* *
* ## State sources * ## State sources
* - Initial fetch via `api.updates.status()` on mount. * - Initial fetch via `api.updates.status()` on mount.
* - Background re-check via `api.updates.check()` every hour
* ({@link AUTO_CHECK_INTERVAL_MS}), plus the manual "Check now" button.
* - Live refresh from WebSocket `update_status` events on {@link eventBus}. * - Live refresh from WebSocket `update_status` events on {@link eventBus}.
* *
* ## Dismissal persistence * ## Dismissal persistence
@@ -63,7 +67,7 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Download, X, Copy, Check, RefreshCw } from "lucide-react"; import { Download, X, Copy, Check, RefreshCw, Zap } from "lucide-react";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus"; import { eventBus } from "../lib/eventBus";
import type { UpdateStatusPayload, WSMessage } from "../lib/types"; import type { UpdateStatusPayload, WSMessage } from "../lib/types";
@@ -71,6 +75,15 @@ import type { UpdateStatusPayload, WSMessage } from "../lib/types";
/** `localStorage` key storing the dismissed upstream SHA. */ /** `localStorage` key storing the dismissed upstream SHA. */
const DISMISS_KEY = "agent-monitor-update-dismissed-sha"; const DISMISS_KEY = "agent-monitor-update-dismissed-sha";
/** How often to silently re-check for updates in the background. */
const AUTO_CHECK_INTERVAL_MS = 60 * 60 * 1000;
/** Situations `POST /api/updates/apply` will actually act on — mirrors the
* server-side check in `server/lib/update-check.js`'s `applyUpdate`. */
function isAutoApplicable(situation: UpdateStatusPayload["situation"]): boolean {
return situation === "tracking_canonical" || situation === "fork_or_diverged_tracking";
}
/** Narrow unknown WebSocket payloads to {@link UpdateStatusPayload}. */ /** Narrow unknown WebSocket payloads to {@link UpdateStatusPayload}. */
function isUpdatePayload(x: unknown): x is UpdateStatusPayload { function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
return typeof x === "object" && x !== null && "git_repo" in x && "update_available" in x; return typeof x === "object" && x !== null && "git_repo" in x && "update_available" in x;
@@ -96,6 +109,8 @@ export function UpdateNotifier() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [checking, setChecking] = useState(false); const [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false);
const [restarting, setRestarting] = useState(false);
const syncFromPayload = useCallback((s: UpdateStatusPayload) => { const syncFromPayload = useCallback((s: UpdateStatusPayload) => {
setStatus(s); setStatus(s);
@@ -128,6 +143,19 @@ export function UpdateNotifier() {
}); });
}, [syncFromPayload]); }, [syncFromPayload]);
// Background re-check every hour, on top of the initial mount fetch and the
// manual "Check now" button — so a long-lived tab notices an update without
// the user having to click anything.
useEffect(() => {
const id = setInterval(() => {
api.updates
.check()
.then(syncFromPayload)
.catch(() => {});
}, AUTO_CHECK_INTERVAL_MS);
return () => clearInterval(id);
}, [syncFromPayload]);
useEffect(() => { useEffect(() => {
const handler = () => setDismissedSha(null); const handler = () => setDismissedSha(null);
window.addEventListener("dashboard:reset-update-dismissal", handler); window.addEventListener("dashboard:reset-update-dismissal", handler);
@@ -139,14 +167,14 @@ export function UpdateNotifier() {
); );
const dismiss = useCallback(() => { const dismiss = useCallback(() => {
if (!status?.remote_sha) return; if (restarting || !status?.remote_sha) return;
try { try {
localStorage.setItem(DISMISS_KEY, status.remote_sha); localStorage.setItem(DISMISS_KEY, status.remote_sha);
} catch { } catch {
/* ignore */ /* ignore */
} }
setDismissedSha(status.remote_sha); setDismissedSha(status.remote_sha);
}, [status?.remote_sha]); }, [restarting, status?.remote_sha]);
// Escape to dismiss - standard modal affordance. // Escape to dismiss - standard modal affordance.
useEffect(() => { useEffect(() => {
@@ -183,6 +211,41 @@ export function UpdateNotifier() {
} }
}; };
// Polls `status()` until the restarted server answers again, then reloads
// so the tab picks up the new client bundle too — the server can't push
// this over its own WebSocket since it's mid-restart.
const pollUntilBack = useCallback(() => {
const attempt = () => {
api.updates
.status()
.then(() => window.location.reload())
.catch(() => setTimeout(attempt, 1500));
};
setTimeout(attempt, 1500);
}, []);
const applyNow = async () => {
if (applying || restarting) return;
setError(null);
setApplying(true);
try {
const result = await api.updates.apply();
if (result.applied) {
setApplying(false);
setRestarting(true);
pollUntilBack();
return;
}
setError(
result.reason === "not_fast_forwardable" ? t("reasonNotFastForwardable") : t("applyError")
);
} catch (e) {
setError(e instanceof Error ? e.message : t("applyError"));
} finally {
setApplying(false);
}
};
if (!show || !status) return null; if (!show || !status) return null;
const refLabel = status.remote_ref || "origin"; const refLabel = status.remote_ref || "origin";
@@ -266,6 +329,13 @@ export function UpdateNotifier() {
<p className="text-[11px] text-fg-muted leading-relaxed">{t("restartNote")}</p> <p className="text-[11px] text-fg-muted leading-relaxed">{t("restartNote")}</p>
) : null} ) : null}
{restarting ? (
<div className="text-xs text-accent bg-accent-muted border border-accent/30 rounded-lg px-3 py-2 flex items-center gap-2">
<RefreshCw className="w-3.5 h-3.5 animate-spin flex-shrink-0" aria-hidden />
{t("restarting")}
</div>
) : null}
{error ? ( {error ? (
<p className="text-xs text-status-danger" role="alert"> <p className="text-xs text-status-danger" role="alert">
{error} {error}
@@ -278,13 +348,18 @@ export function UpdateNotifier() {
<button <button
type="button" type="button"
onClick={checkNow} onClick={checkNow}
disabled={checking} disabled={checking || applying || restarting}
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed" className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
> >
<RefreshCw className={`w-3.5 h-3.5 ${checking ? "animate-spin" : ""}`} aria-hidden /> <RefreshCw className={`w-3.5 h-3.5 ${checking ? "animate-spin" : ""}`} aria-hidden />
{checking ? t("checking") : t("checkNow")} {checking ? t("checking") : t("checkNow")}
</button> </button>
<button type="button" onClick={dismiss} className="btn-ghost"> <button
type="button"
onClick={dismiss}
disabled={restarting}
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
>
{t("dismiss")} {t("dismiss")}
</button> </button>
{status.manual_command ? ( {status.manual_command ? (
@@ -292,12 +367,27 @@ export function UpdateNotifier() {
type="button" type="button"
onClick={copyCmd} onClick={copyCmd}
disabled={copied} disabled={copied}
className="btn-primary disabled:opacity-70" className="btn-ghost disabled:opacity-70"
> >
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />} {copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
{copied ? t("copied") : t("copy")} {copied ? t("copied") : t("copy")}
</button> </button>
) : null} ) : null}
{isAutoApplicable(status.situation) ? (
<button
type="button"
onClick={applyNow}
disabled={applying || restarting}
className="btn-primary disabled:opacity-70"
>
{applying || restarting ? (
<RefreshCw className="w-4 h-4 animate-spin" aria-hidden />
) : (
<Zap className="w-4 h-4" aria-hidden />
)}
{applying ? t("updating") : restarting ? t("restarting") : t("updateNow")}
</button>
) : null}
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,107 @@
/**
* @file UpdateNotifier.test.tsx
* @description Pins the "Update now" self-apply flow added on top of the
* existing manual-command modal: the button only renders when the checkout
* situation is fast-forwardable, clicking it calls `api.updates.apply()`,
* and a successful `applied: true` response flips the modal into a
* "restarting" state that polls `api.updates.status()` until it resolves.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { UpdateNotifier } from "../UpdateNotifier";
import type { UpdateStatusPayload } from "../../lib/types";
const statusMock = vi.fn();
const checkMock = vi.fn();
const applyMock = vi.fn();
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
updates: {
status: (...args: unknown[]) => statusMock(...args),
check: (...args: unknown[]) => checkMock(...args),
apply: (...args: unknown[]) => applyMock(...args),
},
},
};
});
const BASE_STATUS: UpdateStatusPayload = {
git_repo: true,
update_available: true,
repo_root: "/repo",
remote_ref: "origin/main",
canonical_remote: "origin",
current_branch: "main",
tracking_upstream: "origin/main",
tracks_canonical: true,
situation: "tracking_canonical",
situation_note: null,
local_sha: "aaa",
remote_sha: "bbb",
commits_behind: 2,
manual_command: 'cd "/repo" && git pull --ff-only && npm run setup',
message: "2 commit(s) on origin/main not in your checkout.",
};
beforeEach(() => {
statusMock.mockReset().mockResolvedValue(BASE_STATUS);
checkMock.mockReset().mockResolvedValue(BASE_STATUS);
applyMock.mockReset();
try {
localStorage.clear();
} catch {
// Some CI environments stub a non-functional localStorage; the component
// already tolerates that (see UpdateNotifier's own try/catch), so tests do too.
}
});
describe("UpdateNotifier — Update now", () => {
it("shows the Update now button for a fast-forwardable checkout and applies on click", async () => {
applyMock.mockResolvedValue({ ...BASE_STATUS, applied: true, update_available: false });
render(<UpdateNotifier />);
const updateBtn = await screen.findByText(i18n.t("updates:updateNow"));
await userEvent.click(updateBtn);
await waitFor(() => expect(applyMock).toHaveBeenCalled());
// Appears both in the status banner and the button label while restarting.
const restartingNodes = await screen.findAllByText(i18n.t("updates:restarting"));
expect(restartingNodes.length).toBeGreaterThan(0);
});
it("hides the Update now button for a non-fast-forwardable branch", async () => {
statusMock.mockResolvedValue({
...BASE_STATUS,
situation: "feature_branch",
tracks_canonical: false,
});
render(<UpdateNotifier />);
await screen.findByText(i18n.t("updates:title"));
expect(screen.queryByText(i18n.t("updates:updateNow"))).not.toBeInTheDocument();
});
it("surfaces the decline reason instead of restarting on a 409", async () => {
applyMock.mockResolvedValue({
...BASE_STATUS,
applied: false,
reason: "not_fast_forwardable",
});
render(<UpdateNotifier />);
const updateBtn = await screen.findByText(i18n.t("updates:updateNow"));
await userEvent.click(updateBtn);
expect(await screen.findByText(i18n.t("updates:reasonNotFastForwardable"))).toBeInTheDocument();
expect(screen.queryByText(i18n.t("updates:restarting"))).not.toBeInTheDocument();
});
});
+6 -1
View File
@@ -11,5 +11,10 @@
"restartNote": "After the command finishes, close and restart the dashboard the same way you started it: if you used npm start, run npm start again.", "restartNote": "After the command finishes, close and restart the dashboard the same way you started it: if you used npm start, run npm start again.",
"checkNow": "Check now", "checkNow": "Check now",
"checking": "Checking...", "checking": "Checking...",
"checkError": "Could not check for updates" "checkError": "Could not check for updates",
"updateNow": "Update now",
"updating": "Updating...",
"restarting": "Rebuilt — restarting the dashboard...",
"applyError": "Could not apply the update",
"reasonNotFastForwardable": "This checkout isn't on the tracked default branch — apply the command above manually."
} }
+6 -1
View File
@@ -11,5 +11,10 @@
"restartNote": "Sau khi chạy xong lệnh, hãy khởi động lại bảng điều khiển theo cách bạn đã dùng để chạy nó.", "restartNote": "Sau khi chạy xong lệnh, hãy khởi động lại bảng điều khiển theo cách bạn đã dùng để chạy nó.",
"checkNow": "Kiểm tra ngay", "checkNow": "Kiểm tra ngay",
"checking": "Đang kiểm tra...", "checking": "Đang kiểm tra...",
"checkError": "Không thể kiểm tra cập nhật" "checkError": "Không thể kiểm tra cập nhật",
"updateNow": "Cập nhật ngay",
"updating": "Đang cập nhật...",
"restarting": "Đã build xong — đang khởi động lại bảng điều khiển...",
"applyError": "Không thể áp dụng cập nhật",
"reasonNotFastForwardable": "Nhánh hiện tại không phải nhánh mặc định được theo dõi — hãy chạy lệnh ở trên thủ công."
} }
+17
View File
@@ -557,6 +557,23 @@ export const api = {
method: "POST", method: "POST",
body: JSON.stringify({}), body: JSON.stringify({}),
}), }),
/**
* POST /api/updates/apply - fast-forward, rebuild, and restart.
*
* Only acts when the checkout is fast-forwardable (`tracking_canonical`
* or `fork_or_diverged_tracking`); a 409 response means it declined
* (`applied: false`, `reason` explains why). On success the server
* restarts itself right after responding, so the caller should poll
* `status()` until it answers again rather than expect the connection
* to outlive the call.
*
* @returns {@link UpdateStatusPayload} with `applied` set.
*/
apply: () =>
request<UpdateStatusPayload>("/updates/apply", {
method: "POST",
body: JSON.stringify({}),
}),
}, },
// ──────────────────────────────── Stats API ──────────────────────────────── // ──────────────────────────────── Stats API ────────────────────────────────
+8
View File
@@ -1275,6 +1275,14 @@ export interface UpdateStatusPayload {
/** Set instead of a normal result when the remote fetch itself failed /** Set instead of a normal result when the remote fetch itself failed
* (e.g. offline) - the message text explains it in user-facing terms. */ * (e.g. offline) - the message text explains it in user-facing terms. */
fetch_error?: string; fetch_error?: string;
/** Present only on the response from `POST /api/updates/apply` - true once
* the checkout was fast-forwarded and rebuilt (the process is about to
* restart itself). Absent from `status`/`check` responses. */
applied?: boolean;
/** Set alongside `applied: false` on an `/apply` response that declined to
* act - `"up_to_date"` or `"not_fast_forwardable"` (feature branch /
* detached HEAD, where there's no safe automatic move). */
reason?: "up_to_date" | "not_fast_forwardable";
} }
// ───── Terminal run status ───── // ───── Terminal run status ─────
+1 -1
View File
@@ -271,7 +271,7 @@ Because a lane's services are fully detached, restarting the dashboard (or `ccam
| `ccam export [file.json]` | Full JSON data export (sessions, agents, events, tokens, workflows, dashboard runs, alert rules, pricing) — defaults to a dated filename. Re-importable via `ccam import-data` | | `ccam export [file.json]` | Full JSON data export (sessions, agents, events, tokens, workflows, dashboard runs, alert rules, pricing) — defaults to a dated filename. Re-importable via `ccam import-data` |
| `ccam cleanup --hours N --days M` | Abandon active sessions idle for `N` hours and/or purge completed sessions older than `M` days | | `ccam cleanup --hours N --days M` | Abandon active sessions idle for `N` hours and/or purge completed sessions older than `M` days |
| `ccam reinstall-hooks` | Rewrite the Claude Code hook entries in `~/.claude/settings.json` | | `ccam reinstall-hooks` | Rewrite the Claude Code hook entries in `~/.claude/settings.json` |
| `ccam update-check` | Ask the server whether the dashboard checkout is behind the canonical remote (branch- and fork-aware). Prints the behind-by count, a situation note for fork/feature-branch checkouts, and the **copy-paste update command** — the dashboard never restarts itself. Also refreshes the update banner in any open dashboard tab (same `update_status` broadcast) | | `ccam update-check` | Ask the server whether the dashboard checkout is behind the canonical remote (branch- and fork-aware). Prints the behind-by count, a situation note for fork/feature-branch checkouts, and the **copy-paste update command**. When the checkout is fast-forwardable, the dashboard UI can also self-apply (`POST /api/updates/apply`: pull, rebuild, self-restart) instead of running the command by hand. Also refreshes the update banner in any open dashboard tab (same `update_status` broadcast) |
| `ccam clear-data --yes` | Delete **all** data (schema preserved). Refuses to run without `--yes` | | `ccam clear-data --yes` | Delete **all** data (schema preserved). Refuses to run without `--yes` |
| `ccam open` | Open the dashboard in your default browser (`open` / `xdg-open` / `start`) | | `ccam open` | Open the dashboard in your default browser (`open` / `xdg-open` / `start`) |
| `ccam version` | Print the ccam version (also `--version` / `-v`) | | `ccam version` | Print the ccam version (also `--version` / `-v`) |
+29
View File
@@ -6328,6 +6328,35 @@ paths:
error: error:
code: UPDATE_CHECK_FAILED code: UPDATE_CHECK_FAILED
message: 'git fetch failed: network unreachable' message: 'git fetch failed: network unreachable'
/api/updates/apply:
post:
tags:
- Updates
summary: Pull, rebuild, and restart the dashboard when fast-forwardable
operationId: applyUpdate
responses:
'200':
description: Checkout fast-forwarded and rebuilt; the process is about to self-restart
content:
application/json:
schema:
type: object
additionalProperties: true
description: 'Same shape as GET /api/updates/status, plus applied: true.'
'409':
description: Declined - nothing to apply, or the checkout isn't safely fast-forwardable
content:
application/json:
schema:
type: object
additionalProperties: true
description: 'applied: false plus reason: "up_to_date" | "not_fast_forwardable".'
'500':
description: Update apply failed
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/alerts: /api/alerts:
get: get:
tags: tags:
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env node
/**
* @file Detached helper spawned by `server/lib/self-restart.js`. Polls the
* old dashboard PID until it exits (server/index.js's own SIGTERM handler
* has a 5s force-exit backstop, so this never waits forever), then starts a
* fresh server from the same entry point and exits itself.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { spawn } = require("child_process");
const [, , pidArg, entry] = process.argv;
const pid = parseInt(pidArg, 10);
// ponytail: fixed poll cadence/cap, not configurable — this script has one caller.
const POLL_MS = 200;
const MAX_WAIT_MS = 20_000;
function isAlive(p) {
try {
process.kill(p, 0);
return true;
} catch {
return false;
}
}
function restart() {
const child = spawn(process.execPath, [entry], {
detached: true,
stdio: "ignore",
env: process.env,
});
child.unref();
process.exit(0);
}
function waitAndRestart(waited = 0) {
if (isAlive(pid) && waited < MAX_WAIT_MS) {
setTimeout(() => waitAndRestart(waited + POLL_MS), POLL_MS);
return;
}
restart();
}
waitAndRestart();
+8 -3
View File
@@ -90,12 +90,17 @@ describe("POST /api/updates/check", () => {
}); });
}); });
describe("removed POST /api/updates/apply", () => { describe("POST /api/updates/apply", () => {
it("returns 404 because self-update has been removed", async () => { it("declines with a reason instead of pulling/restarting when there is nothing to apply", async () => {
// The test repo checkout has no update pending (or isn't fast-forwardable
// from a bare test run), so this exercises the decline path only — it must
// never touch the working tree or process in a test run.
const res = await httpFetch("/api/updates/apply", { const res = await httpFetch("/api/updates/apply", {
method: "POST", method: "POST",
body: "{}", body: "{}",
}); });
assert.equal(res.status, 404); assert.equal(res.status, 409);
assert.equal(res.body.applied, false);
assert.ok(["up_to_date", "not_fast_forwardable"].includes(res.body.reason));
}); });
}); });
+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) { async function listRemotes(gitRoot) {
try { try {
const out = await execGit(gitRoot, ["remote"], { timeout: 10_000 }); 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 };
+42
View File
@@ -2440,6 +2440,48 @@ function createOpenApiSpec() {
}, },
}, },
}, },
"/api/updates/apply": {
post: {
tags: ["Updates"],
summary: "Pull, rebuild, and restart the dashboard when fast-forwardable",
operationId: "applyUpdate",
responses: {
200: {
description:
"Checkout fast-forwarded and rebuilt; the process is about to self-restart",
content: {
"application/json": {
schema: {
type: "object",
additionalProperties: true,
description: "Same shape as GET /api/updates/status, plus applied: true.",
},
},
},
},
409: {
description:
"Declined - nothing to apply, or the checkout isn't safely fast-forwardable",
content: {
"application/json": {
schema: {
type: "object",
additionalProperties: true,
description:
'applied: false plus reason: "up_to_date" | "not_fast_forwardable".',
},
},
},
},
500: {
description: "Update apply failed",
content: {
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
},
},
},
},
},
"/api/alerts": { "/api/alerts": {
get: { get: {
tags: ["Alerts"], tags: ["Alerts"],
+39 -3
View File
@@ -1,11 +1,15 @@
/** /**
* @file HTTP routes for dashboard upstream-update detection. The dashboard never * @file HTTP routes for dashboard upstream-update detection, plus the one
* restarts itself — users copy the printed command and run it in their terminal. * self-applying path: POST /apply fast-forwards, rebuilds, and restarts the
* process on the user's explicit click (see `server/lib/update-check.js`'s
* `applyUpdate` and `server/lib/self-restart.js`). Everywhere else, the
* dashboard only ever prints a command for the user to run themselves.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
const { Router } = require("express"); const { Router } = require("express");
const { getUpdatesStatus } = require("../lib/update-check"); const { getUpdatesStatus, applyUpdate } = require("../lib/update-check");
const { scheduleRestart } = require("../lib/self-restart");
const router = Router(); const router = Router();
@@ -37,4 +41,36 @@ router.post("/check", async (_req, res) => {
} }
}); });
router.post("/apply", async (_req, res) => {
try {
const result = await applyUpdate();
if (!result.applied) {
res.status(409).json({
...result,
error: {
code: "UPDATE_NOT_APPLICABLE",
message:
result.reason === "up_to_date"
? "Already up to date."
: "Current branch can't be fast-forwarded automatically.",
},
});
return;
}
res.json(result);
try {
const { broadcast } = require("../websocket");
broadcast("update_status", { ...result, update_available: false });
} catch {
// WS not initialized (e.g. in isolated tests) — safe to ignore.
}
// After the response above, restart onto the freshly-built code.
setImmediate(() => scheduleRestart());
} catch (err) {
res.status(500).json({
error: { code: "UPDATE_APPLY_FAILED", message: err.message || String(err) },
});
}
});
module.exports = router; module.exports = router;