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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -415,6 +415,18 @@ async function pluginStatus(root, pluginInstall, options = {}) {
|
||||
const comparable = release.ok && Boolean(localVersion);
|
||||
const releaseIsNewer = comparable && compareVersions(release.tag, localVersion) > 0;
|
||||
|
||||
// `claude plugin update` run from a terminal unpacks the new build into a
|
||||
// sibling cache dir and starts NOTHING — only a SessionStart or this
|
||||
// dashboard's own update chain hands the server over. Without a Claude Code
|
||||
// session this server ran the old build forever, and with the forge
|
||||
// unreachable it said "up to date". The local record needs no network, and
|
||||
// "Update now" already re-bootstraps from the newest sibling.
|
||||
const installedRoot = require("../../scripts/plugin-bootstrap").supersededBy(root);
|
||||
const installedNotRunning = installedRoot
|
||||
? { root: installedRoot, version: readLocalVersion(installedRoot) }
|
||||
: null;
|
||||
const updateAvailable = releaseIsNewer || Boolean(installedNotRunning);
|
||||
|
||||
const manualParts = [
|
||||
`claude plugin marketplace update ${pluginInstall.marketplace}`,
|
||||
`claude plugin update ${pluginInstall.plugin}@${pluginInstall.marketplace} -y`,
|
||||
@@ -425,7 +437,8 @@ async function pluginStatus(root, pluginInstall, options = {}) {
|
||||
// `install_kind`, not on this, or they will report "check manually" for an
|
||||
// install the dashboard can in fact check AND update.
|
||||
git_repo: false,
|
||||
update_available: releaseIsNewer,
|
||||
update_available: updateAvailable,
|
||||
installed_not_running: installedNotRunning,
|
||||
repo_root: root,
|
||||
situation: "plugin_managed",
|
||||
situation_note:
|
||||
@@ -438,13 +451,16 @@ async function pluginStatus(root, pluginInstall, options = {}) {
|
||||
// gets the same sentence. Left null, `agenthub update-check` printed the
|
||||
// literal "null" as its headline on every plugin-cache install — the one
|
||||
// install kind that cannot fall back to a commit count.
|
||||
message: releaseIsNewer
|
||||
? `Release ${release.tag} is available (this install reports ${localVersion || "no version"}).`
|
||||
: comparable
|
||||
? `Running the newest release (${release.tag}).`
|
||||
: release.ok
|
||||
? `This install reports no version, so it cannot be compared with release ${release.tag}.`
|
||||
: `Could not read the latest release (${release.reason}); version comparison unavailable.`,
|
||||
message: installedNotRunning
|
||||
? `Version ${installedNotRunning.version || "(unknown)"} is installed but not running ` +
|
||||
`(this server runs ${localVersion || "no version"}); "Update now" switches the dashboard to it.`
|
||||
: releaseIsNewer
|
||||
? `Release ${release.tag} is available (this install reports ${localVersion || "no version"}).`
|
||||
: comparable
|
||||
? `Running the newest release (${release.tag}).`
|
||||
: release.ok
|
||||
? `This install reports no version, so it cannot be compared with release ${release.tag}.`
|
||||
: `Could not read the latest release (${release.reason}); version comparison unavailable.`,
|
||||
|
||||
install_kind: "plugin",
|
||||
plugin_marketplace: pluginInstall.marketplace,
|
||||
|
||||
+1
-1
@@ -2419,7 +2419,7 @@ function createOpenApiSpec() {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
description:
|
||||
'Reads the newest published release from the forge API (derived from the origin remote) and compares its tag against this install\'s package.json version; falls back to a commit count against the canonical default branch when no release can be read, with the reason in release_check. Includes git_repo, update_available, install_kind ("plugin" | "checkout"), plugin_marketplace, local_version, shallow_clone, release_check, latest_release, latest_release_name, latest_release_url, latest_release_published_at, latest_release_notes, release_sha, commits_behind_release, commits_ahead_of_release, dev_build, commits_behind, remote_ref, local_sha, remote_sha, situation, manual_command, and optional error/message fields. dev_build is true when this install is strictly ahead of the newest release; commits_* fields are null on a shallow clone, where distances are meaningless.',
|
||||
'Reads the newest published release from the forge API (derived from the origin remote) and compares its tag against this install\'s package.json version; falls back to a commit count against the canonical default branch when no release can be read, with the reason in release_check. Includes git_repo, update_available, install_kind ("plugin" | "checkout"), plugin_marketplace, local_version, shallow_clone, release_check, latest_release, latest_release_name, latest_release_url, latest_release_published_at, latest_release_notes, release_sha, commits_behind_release, commits_ahead_of_release, dev_build, commits_behind, remote_ref, local_sha, remote_sha, situation, manual_command, and optional error/message fields. installed_not_running ({root, version} or null, plugin installs only) names a newer build Claude Code installed beside the running one and sets update_available without any network; dev_build is true when this install is strictly ahead of the newest release; commits_* fields are null on a shallow clone, where distances are meaningless.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user