/** * @file HTTP routes for release-update detection plus the self-applying path. * `POST /apply` has two modes, chosen by how this copy was installed: * * - **checkout** — fast-forward onto the newest release tag, rebuild, and * restart in place (`update-check.js`'s `applyUpdate` + `self-restart.js`). * - **plugin** — `applyUpdate` cannot touch a `~/.claude/plugins/cache/` * directory, so it refuses with `plugin_managed`; this route catches that and * hands the job to the detached chain in `plugin-update.js` instead * (`claude plugin marketplace update` → `claude plugin update` → * `plugin-bootstrap.js --force`, which retires this server and starts the new * one). Answered 200 with `mode: "plugin"`, not 409. * * Both modes reply BEFORE anything stops the process, and the request is only * ever a trigger: the marketplace and plugin names come from the local cache * path, never from the body, so there is nothing in the payload to validate. * @author Nguyễn Ngọc Trí Vĩ */ const { Router } = require("express"); const { getUpdatesStatus, applyUpdate } = require("../lib/update-check"); const { scheduleRestart } = require("../lib/self-restart"); const { schedulePluginUpdate } = require("../lib/plugin-update"); const router = Router(); router.get("/status", async (req, res) => { try { const status = await getUpdatesStatus(); // The install folder is the host's; a developer sees versions only. if (require("../lib/scope").memberId(req) != null) status.repo_root = null; res.json(status); } catch (err) { res.status(500).json({ error: { code: "UPDATE_STATUS_FAILED", message: err.message || String(err) }, }); } }); router.post("/check", async (_req, res) => { try { const status = await getUpdatesStatus(); try { const { broadcast } = require("../websocket"); broadcast("update_status", status); } catch { // WS not initialized (e.g. in isolated tests) — safe to ignore. } res.json(status); } catch (err) { res.status(500).json({ error: { code: "UPDATE_CHECK_FAILED", message: err.message || String(err) }, }); } }); router.post("/apply", async (_req, res) => { try { const result = await applyUpdate(); // A plugin-cache install is refused by applyUpdate on purpose (it must not // rewrite a directory Claude Code owns). Route it to the detached chain, // which pulls the new version and re-bootstraps from the NEW cache dir. // Only when there is genuinely something to pull — a `plugin_managed` // refusal with nothing newer published must stay a 409, not spawn a // process that would stop the server for no reason. if ( !result.applied && result.reason === "plugin_managed" && result.update_available && result.plugin_marketplace && result.plugin_name && result.repo_root ) { const scheduled = schedulePluginUpdate({ marketplace: result.plugin_marketplace, plugin: result.plugin_name, root: result.repo_root, }); // `applied: true` because the update IS being applied — just by the // plugin chain rather than by a git fast-forward. `mode` tells the client // which one, so it can word the progress text honestly. res.json({ ...result, applied: true, mode: "plugin", ...scheduled }); return; } if (!result.applied) { res.status(409).json({ ...result, error: { code: "UPDATE_NOT_APPLICABLE", message: result.reason === "up_to_date" ? "Already up to date." : result.reason === "plugin_managed" ? // Do not claim "nothing newer is published" unless the release // lookup actually succeeded — saying so after a failed check // reports an unknown as a negative, which is how a broken // check looks identical to an up-to-date install. result.release_check === "ok" ? `This install runs from the Claude Code plugin cache and ${result.latest_release || "the published release"} is already the newest; run /agenthub-update to force a refresh.` : `This install runs from the Claude Code plugin cache and the release check did not succeed (${result.release_check || "unknown"}), so there is nothing to apply; run /agenthub-update to update it by hand.` : "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;