fix(updates): report a plugin build installed beside the running one, never "up to date" on a failed check

A `claude plugin update` run from a terminal (or with only the Claude desktop
app open) unpacks the new build and starts nothing: no SessionStart fires, so
the dashboard kept running the old cache dir across reboots, and with the
forge unreachable the sidebar read "Up to date" (seen on a machine stuck at
1.8.10).

- pluginStatus asks supersededBy for its own root: a recorded sibling install
  sets `installed_not_running: {root, version}` and `update_available`, with
  no network needed
- the sidebar reads "<version> installed, not running"; the update modal keys
  on it, so Update now is offered even with no release tag or sha
- plugin-update-helper hands over to the recorded install first, the newest
  sibling dir only as a fallback
- a plugin install whose release lookup failed reads "Could not check for
  updates" instead of "Up to date"
- docs/PLUGINS.md: the case and the manual hand-over command
This commit is contained in:
2026-10-01 10:09:12 +07:00
parent 969b06253c
commit 058c574138
14 changed files with 208 additions and 27 deletions
@@ -384,3 +384,39 @@ describe("fetchReleaseFromUrl", () => {
}
});
});
describe("a newer build installed beside the running one", () => {
/** Record `installed` as Claude Code's install of agenthub@sg. */
function recordInstall(home, installed) {
fs.writeFileSync(
path.join(home, "plugins", "installed_plugins.json"),
JSON.stringify({ plugins: { "agenthub@sg": [{ scope: "user", installPath: installed }] } })
);
}
it("reports an update even when the release check fails", async () => {
// `claude plugin update` from a terminal unpacks the new build and starts
// nothing: the server keeps running the old directory, and with the forge
// unreachable it used to answer "up to date" forever.
const { root, home } = makePluginInstall("installed-newer", { version: "1.8.10" });
const newer = path.join(path.dirname(root), "aaaaaaaaaaaa");
fs.mkdirSync(newer, { recursive: true });
fs.writeFileSync(path.join(newer, "package.json"), JSON.stringify({ version: "1.8.12" }));
recordInstall(home, newer);
const s = await getUpdatesStatus(root, {
latestRelease: { ok: false, reason: "unreachable" },
});
assert.equal(s.update_available, true);
assert.deepEqual(s.installed_not_running, { root: newer, version: "1.8.12" });
assert.match(s.message, /1\.8\.12 is installed but not running/);
});
it("says nothing extra when the running build is the installed one", async () => {
const { root, home } = makePluginInstall("installed-same", { version: "1.8.12" });
recordInstall(home, root);
const s = await getUpdatesStatus(root, { latestRelease: release("v1.8.12") });
assert.equal(s.update_available, false);
assert.equal(s.installed_not_running, null);
});
});