Files
AgentHub/server/routes/updates.js
T
nntrivi2001 30bc6debde fix(updates): check releases for a plugin install with no .git
An archive-sourced marketplace entry ("source": "url" — this project's own)
makes Claude Code unpack the plugin rather than clone it, so the cache dir
has no .git. getUpdatesStatus probed for .git first and returned "not a git
clone; check for updates manually", skipping the entire release layer:
update_available stayed false however many releases were published, and
plugin_marketplace/plugin_name were never set, so POST /api/updates/apply's
plugin branch could never fire (it answered 409, claiming nothing newer was
published). The bug reads as "up to date", so it stayed invisible for as
long as the install happened to be current.

The plugin branch now runs BEFORE the .git probe and answers without git at
all: the repository URL comes from the marketplace manifest on disk
(known_marketplaces.json -> .claude-plugin/marketplace.json -> source.url,
null rather than a guessed host for a github owner/repo shorthand), and
package.json is the only local comparison signal. A failed release lookup
is reported as release_check, never as "nothing newer".

UI/CLI: the "not a git clone" note is gated on install_kind !== "plugin", so
the Update now button is offered where the dashboard can actually check and
apply.
2026-09-10 16:46:06 +07:00

123 lines
4.9 KiB
JavaScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
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();
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;