diff --git a/CLAUDE.md b/CLAUDE.md index 282565b..c1a92a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ ## 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). - `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/`. - `.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`. diff --git a/client/src/components/UpdateNotifier.tsx b/client/src/components/UpdateNotifier.tsx index 42d78ef..0ecb268 100644 --- a/client/src/components/UpdateNotifier.tsx +++ b/client/src/components/UpdateNotifier.tsx @@ -2,11 +2,15 @@ * @file UpdateNotifier.tsx * @description Modal surfaced when the dashboard's git checkout is behind its * remote tracking branch. Shows how many commits behind, the exact terminal - * command to update, and copy-to-clipboard — the dashboard never pulls or - * restarts itself. + * command to update with copy-to-clipboard, and — when the checkout is on a + * 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 * - 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}. * * ## Dismissal persistence @@ -63,7 +67,7 @@ import { useCallback, useEffect, useState } from "react"; 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 { eventBus } from "../lib/eventBus"; 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. */ 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}. */ function isUpdatePayload(x: unknown): x is UpdateStatusPayload { 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(null); const [copied, setCopied] = useState(false); const [checking, setChecking] = useState(false); + const [applying, setApplying] = useState(false); + const [restarting, setRestarting] = useState(false); const syncFromPayload = useCallback((s: UpdateStatusPayload) => { setStatus(s); @@ -128,6 +143,19 @@ export function UpdateNotifier() { }); }, [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(() => { const handler = () => setDismissedSha(null); window.addEventListener("dashboard:reset-update-dismissal", handler); @@ -139,14 +167,14 @@ export function UpdateNotifier() { ); const dismiss = useCallback(() => { - if (!status?.remote_sha) return; + if (restarting || !status?.remote_sha) return; try { localStorage.setItem(DISMISS_KEY, status.remote_sha); } catch { /* ignore */ } setDismissedSha(status.remote_sha); - }, [status?.remote_sha]); + }, [restarting, status?.remote_sha]); // Escape to dismiss - standard modal affordance. 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; const refLabel = status.remote_ref || "origin"; @@ -266,6 +329,13 @@ export function UpdateNotifier() {

{t("restartNote")}

) : null} + {restarting ? ( +
+ + {t("restarting")} +
+ ) : null} + {error ? (

{error} @@ -278,13 +348,18 @@ export function UpdateNotifier() { - {status.manual_command ? ( @@ -292,12 +367,27 @@ export function UpdateNotifier() { type="button" onClick={copyCmd} disabled={copied} - className="btn-primary disabled:opacity-70" + className="btn-ghost disabled:opacity-70" > {copied ? : } {copied ? t("copied") : t("copy")} ) : null} + {isAutoApplicable(status.situation) ? ( + + ) : null} diff --git a/client/src/components/__tests__/UpdateNotifier.test.tsx b/client/src/components/__tests__/UpdateNotifier.test.tsx new file mode 100644 index 0000000..014b902 --- /dev/null +++ b/client/src/components/__tests__/UpdateNotifier.test.tsx @@ -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ĩ + */ + +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>(); + 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(); + + 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(); + + 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(); + + 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(); + }); +}); diff --git a/client/src/i18n/locales/en/updates.json b/client/src/i18n/locales/en/updates.json index df7327b..2717ecc 100644 --- a/client/src/i18n/locales/en/updates.json +++ b/client/src/i18n/locales/en/updates.json @@ -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.", "checkNow": "Check now", "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." } diff --git a/client/src/i18n/locales/vi/updates.json b/client/src/i18n/locales/vi/updates.json index 3834e79..95cd586 100644 --- a/client/src/i18n/locales/vi/updates.json +++ b/client/src/i18n/locales/vi/updates.json @@ -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ó.", "checkNow": "Kiểm tra ngay", "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." } diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 2c7bfec..075dae8 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -557,6 +557,23 @@ export const api = { method: "POST", 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("/updates/apply", { + method: "POST", + body: JSON.stringify({}), + }), }, // ──────────────────────────────── Stats API ──────────────────────────────── diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index f6b47f6..3aa3849 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -1275,6 +1275,14 @@ export interface UpdateStatusPayload { /** Set instead of a normal result when the remote fetch itself failed * (e.g. offline) - the message text explains it in user-facing terms. */ 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 ───── diff --git a/docs/CLI.md b/docs/CLI.md index 99b8082..3b27500 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -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 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 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 open` | Open the dashboard in your default browser (`open` / `xdg-open` / `start`) | | `ccam version` | Print the ccam version (also `--version` / `-v`) | diff --git a/openapi.yaml b/openapi.yaml index 82260da..18217b3 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -6328,6 +6328,35 @@ paths: error: code: UPDATE_CHECK_FAILED 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: get: tags: diff --git a/scripts/restart-helper.js b/scripts/restart-helper.js new file mode 100644 index 0000000..4e2e950 --- /dev/null +++ b/scripts/restart-helper.js @@ -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ĩ + */ + +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(); diff --git a/server/__tests__/updates.test.js b/server/__tests__/updates.test.js index 7ebd2f5..ebb597e 100644 --- a/server/__tests__/updates.test.js +++ b/server/__tests__/updates.test.js @@ -90,12 +90,17 @@ describe("POST /api/updates/check", () => { }); }); -describe("removed POST /api/updates/apply", () => { - it("returns 404 because self-update has been removed", async () => { +describe("POST /api/updates/apply", () => { + 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", { method: "POST", 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)); }); }); diff --git a/server/lib/self-restart.js b/server/lib/self-restart.js new file mode 100644 index 0000000..234ccfa --- /dev/null +++ b/server/lib/self-restart.js @@ -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ĩ + */ + +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 }; diff --git a/server/lib/update-check.js b/server/lib/update-check.js index 37612ac..1b7c50a 100644 --- a/server/lib/update-check.js +++ b/server/lib/update-check.js @@ -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} 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 }; diff --git a/server/openapi.js b/server/openapi.js index 8c9bfdf..ec94640 100644 --- a/server/openapi.js +++ b/server/openapi.js @@ -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": { get: { tags: ["Alerts"], diff --git a/server/routes/updates.js b/server/routes/updates.js index 34db72c..ed3d1d6 100644 --- a/server/routes/updates.js +++ b/server/routes/updates.js @@ -1,11 +1,15 @@ /** - * @file HTTP routes for dashboard upstream-update detection. The dashboard never - * restarts itself — users copy the printed command and run it in their terminal. + * @file HTTP routes for dashboard upstream-update detection, plus the one + * 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ĩ */ 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(); @@ -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;