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:
@@ -368,15 +368,25 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
? t("nav:checkingForUpdates")
|
||||
: checkError
|
||||
? t("nav:checkFailed")
|
||||
: updateAvailable
|
||||
? updateStatus?.latest_release
|
||||
? t("nav:releaseAvailable", { version: updateStatus.latest_release })
|
||||
: t("nav:updateAvailable")
|
||||
: updateStatus?.dev_build
|
||||
? t("nav:devBuild", { count: updateStatus.commits_ahead_of_release ?? 0 })
|
||||
: updateStatus
|
||||
? t("nav:upToDate")
|
||||
: t("nav:checkForUpdates");
|
||||
: updateStatus?.installed_not_running
|
||||
? t("nav:installedNotRunning", {
|
||||
version: updateStatus.installed_not_running.version ?? "?",
|
||||
})
|
||||
: updateAvailable
|
||||
? updateStatus?.latest_release
|
||||
? t("nav:releaseAvailable", { version: updateStatus.latest_release })
|
||||
: t("nav:updateAvailable")
|
||||
: updateStatus?.dev_build
|
||||
? t("nav:devBuild", { count: updateStatus.commits_ahead_of_release ?? 0 })
|
||||
: // A plugin install has no commit count to fall back on: a release
|
||||
// that could not be read is "unknown", never "up to date".
|
||||
updateStatus?.install_kind === "plugin" &&
|
||||
updateStatus.release_check &&
|
||||
updateStatus.release_check !== "ok"
|
||||
? t("nav:releaseUnchecked")
|
||||
: updateStatus
|
||||
? t("nav:upToDate")
|
||||
: t("nav:checkForUpdates");
|
||||
const currentLanguage = normalizeLanguage(i18n.resolvedLanguage ?? i18n.language);
|
||||
const currentIndex = SUPPORTED_LANGUAGES.indexOf(currentLanguage);
|
||||
const nextLanguage = SUPPORTED_LANGUAGES[(currentIndex + 1) % SUPPORTED_LANGUAGES.length];
|
||||
|
||||
@@ -185,7 +185,13 @@ export function UpdateNotifier() {
|
||||
// also swallow v1.6.0 — and so the prompt survives the tag pointing at a
|
||||
// commit the user already has. Falls back to the upstream SHA for the
|
||||
// commit-count path (no release readable).
|
||||
const dismissKey = status?.latest_release || status?.remote_sha || null;
|
||||
// A build installed from a terminal while this server kept running has no
|
||||
// tag or sha when the forge is unreachable — key it by its directory.
|
||||
const installed = status?.installed_not_running ?? null;
|
||||
const dismissKey =
|
||||
status?.latest_release ||
|
||||
status?.remote_sha ||
|
||||
(installed ? `installed:${installed.root}` : null);
|
||||
const show = Boolean(status?.update_available && dismissKey && dismissedSha !== dismissKey);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
@@ -320,12 +326,16 @@ export function UpdateNotifier() {
|
||||
id="update-notifier-title"
|
||||
className="text-sm font-semibold text-fg-primary truncate"
|
||||
>
|
||||
{release ? t("releaseTitle", { version: release }) : t("title")}
|
||||
{installed
|
||||
? t("installedTitle", { version: installed.version ?? "?" })
|
||||
: release
|
||||
? t("releaseTitle", { version: release })
|
||||
: t("title")}
|
||||
</h2>
|
||||
<p className="text-[11px] text-fg-muted mt-0.5 font-mono truncate">
|
||||
{/* Never print a commit count on a shallow clone — the number
|
||||
is a graft artefact, not a distance. */}
|
||||
{release && !status.shallow_clone && status.local_version
|
||||
{(release || installed) && !status.shallow_clone && status.local_version
|
||||
? t("releaseSubtitle", { current: status.local_version })
|
||||
: t("commitsBehind", { count: behind, ref: refLabel })}
|
||||
</p>
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { Sidebar } from "../Sidebar";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
|
||||
function renderSidebar(wsConnected: boolean, collapsed = false) {
|
||||
return render(
|
||||
@@ -103,4 +104,44 @@ describe("Sidebar", () => {
|
||||
expect(screen.getByText("VI")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("update status line", () => {
|
||||
const plugin = {
|
||||
git_repo: false,
|
||||
install_kind: "plugin",
|
||||
situation: "plugin_managed",
|
||||
local_version: "1.8.10",
|
||||
};
|
||||
const publish = (data: object) =>
|
||||
act(() => eventBus.publish({ type: "update_status", data } as never));
|
||||
|
||||
it("does not claim up to date when a plugin install could not read the release", () => {
|
||||
renderSidebar(true);
|
||||
publish({ ...plugin, update_available: false, release_check: "unreachable" });
|
||||
expect(screen.queryByText("Up to date")).toBeNull();
|
||||
expect(screen.getByText("Could not check for updates")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names a build installed beside the running one", () => {
|
||||
renderSidebar(true);
|
||||
publish({
|
||||
...plugin,
|
||||
update_available: true,
|
||||
release_check: "unreachable",
|
||||
installed_not_running: { root: "/x", version: "1.8.12" },
|
||||
});
|
||||
expect(screen.getByText("1.8.12 installed, not running")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("still says up to date when the release was read and matches", () => {
|
||||
renderSidebar(true);
|
||||
publish({
|
||||
...plugin,
|
||||
update_available: false,
|
||||
release_check: "ok",
|
||||
latest_release: "v1.8.10",
|
||||
});
|
||||
expect(screen.getByText("Up to date")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,6 +137,33 @@ describe("UpdateNotifier — Update now", () => {
|
||||
expect(screen.queryByText(i18n.t("updates:notGit"))).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers Update now for a build installed from a terminal, even with no release read", async () => {
|
||||
// `claude plugin update` started nothing and the forge was unreachable:
|
||||
// no release tag and no sha to key the modal on, yet a newer build sits
|
||||
// on disk. That used to read as "up to date" with no way to switch.
|
||||
statusMock.mockResolvedValue({
|
||||
...BASE_STATUS,
|
||||
git_repo: false,
|
||||
situation: "plugin_managed",
|
||||
install_kind: "plugin",
|
||||
plugin_marketplace: "sg",
|
||||
plugin_name: "agenthub",
|
||||
remote_sha: null,
|
||||
commits_behind: 0,
|
||||
local_version: "1.8.10",
|
||||
release_check: "unreachable",
|
||||
latest_release: null,
|
||||
installed_not_running: { root: "/cache/sg/agenthub/new", version: "1.8.12" },
|
||||
});
|
||||
render(<UpdateNotifier />);
|
||||
|
||||
await screen.findByText(i18n.t("updates:installedTitle", { version: "1.8.12" }));
|
||||
expect(
|
||||
screen.getByText(i18n.t("updates:releaseSubtitle", { current: "1.8.10" }))
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(i18n.t("updates:updateNow"))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces a previous plugin chain's failure so a dead update isn't silent", async () => {
|
||||
statusMock.mockResolvedValue({
|
||||
...BASE_STATUS,
|
||||
|
||||
@@ -68,5 +68,7 @@
|
||||
"workspace": "Workspace",
|
||||
"wsEndpoint": "WebSocket",
|
||||
"releaseAvailable": "{{version}} available",
|
||||
"devBuild": "Dev build · {{count}} ahead"
|
||||
"devBuild": "Dev build · {{count}} ahead",
|
||||
"installedNotRunning": "{{version}} installed, not running",
|
||||
"releaseUnchecked": "Could not check for updates"
|
||||
}
|
||||
|
||||
@@ -26,5 +26,6 @@
|
||||
"pluginAutoNote": "This dashboard is installed as a Claude Code plugin. \"Update now\" pulls the new plugin version, reinstalls the runtime, and restarts the server for you — it takes a few minutes. Newly added skills and slash commands only appear in an already-open Claude Code session after you run /reload-plugins there.",
|
||||
"pluginUpdating": "Pulling the new plugin version and reinstalling the runtime — this takes a few minutes, the page reloads itself when the new server is up...",
|
||||
"lastPluginUpdateFailed": "The last plugin update failed at step \"{{step}}\": {{error}} — run /agenthub-update in Claude Code to recover.",
|
||||
"restartingShort": "Restarting…"
|
||||
"restartingShort": "Restarting…",
|
||||
"installedTitle": "Version {{version}} is installed but not running"
|
||||
}
|
||||
|
||||
@@ -68,5 +68,7 @@
|
||||
"workspace": "Không gian làm việc",
|
||||
"wsEndpoint": "WebSocket",
|
||||
"releaseAvailable": "Có {{version}}",
|
||||
"devBuild": "Bản dev · trước {{count}} commit"
|
||||
"devBuild": "Bản dev · trước {{count}} commit",
|
||||
"installedNotRunning": "Đã cài {{version}}, chưa chạy",
|
||||
"releaseUnchecked": "Không kiểm tra được bản mới"
|
||||
}
|
||||
|
||||
@@ -26,5 +26,6 @@
|
||||
"pluginAutoNote": "Dashboard này cài dạng plugin của Claude Code. Bấm \"Cập nhật ngay\" sẽ tự tải bản plugin mới, cài lại runtime và khởi động lại server — mất vài phút. Riêng skill và slash command mới thì session Claude Code đang mở chỉ thấy sau khi bạn chạy /reload-plugins ở đó.",
|
||||
"pluginUpdating": "Đang tải bản plugin mới và cài lại runtime — mất vài phút, trang sẽ tự reload khi server mới lên...",
|
||||
"lastPluginUpdateFailed": "Lần cập nhật plugin trước thất bại ở bước \"{{step}}\": {{error}} — chạy /agenthub-update trong Claude Code để khắc phục.",
|
||||
"restartingShort": "Đang khởi động lại…"
|
||||
"restartingShort": "Đang khởi động lại…",
|
||||
"installedTitle": "Đã cài bản {{version}} nhưng chưa chạy"
|
||||
}
|
||||
|
||||
@@ -1352,6 +1352,10 @@ export interface UpdateStatusPayload {
|
||||
* or `"http_<status>"`. Anything but `"ok"` means detection fell back to
|
||||
* the commit-count comparison. */
|
||||
release_check?: string;
|
||||
/** Plugin installs only: a newer build Claude Code has installed beside the
|
||||
* running one (`claude plugin update` from a terminal starts nothing), or
|
||||
* null. Sets `update_available`; "Update now" hands the server over to it. */
|
||||
installed_not_running?: { root: string; version: string | null } | null;
|
||||
/** Tag of the newest published release, e.g. `"v1.5.0"`. null when
|
||||
* `release_check !== "ok"`. */
|
||||
latest_release?: string | null;
|
||||
|
||||
@@ -185,6 +185,34 @@ key, which holds without parsing a cache path or trusting a name. An entry
|
||||
pointing at a directory that no longer exists is ignored — a phantom must never
|
||||
wedge a bootstrap shut.
|
||||
|
||||
### A build installed while the server kept running
|
||||
|
||||
The same record answers the opposite case. `claude plugin update` run from a
|
||||
terminal (or with only the Claude desktop app open) starts **nothing**: no
|
||||
SessionStart fires, so no bootstrap hands the server over, and the dashboard
|
||||
keeps running the old directory — across reboots too, since
|
||||
`agenthub.service` still names it and it still exists. `agenthub open` only
|
||||
opens the browser.
|
||||
|
||||
So `getUpdatesStatus` (`server/lib/update-check.js`) asks `supersededBy` for
|
||||
its own root: when a sibling build is the installed one, the status carries
|
||||
`installed_not_running: {root, version}` and `update_available: true` — with
|
||||
no network needed, so an unreachable forge can no longer hide it. The sidebar
|
||||
reads **`<version>` installed, not running**, and the update modal's **Update
|
||||
now** runs the usual chain; `scripts/plugin-update-helper.js` hands over to the
|
||||
recorded install first (`supersededBy(oldRoot)`), falling back to the newest
|
||||
sibling directory only when there is no record.
|
||||
|
||||
Separately, a plugin install whose release lookup failed (`release_check` not
|
||||
`ok`) now reads **Could not check for updates** in the sidebar instead of
|
||||
**Up to date** — a plugin install has no commit count to fall back on.
|
||||
|
||||
To hand over by hand without the dashboard:
|
||||
|
||||
```bash
|
||||
node "$(python3 -c 'import json,os;d=json.load(open(os.path.expanduser("~/.claude/plugins/installed_plugins.json")));print([e["installPath"] for k,v in d["plugins"].items() if k.startswith("agenthub@") for e in v][0])')/scripts/plugin-bootstrap.js" --force
|
||||
```
|
||||
|
||||
### Pruning the plugin cache
|
||||
|
||||
Claude Code unpacks every update beside the last one and does not reliably
|
||||
|
||||
@@ -158,7 +158,10 @@ function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
const newRoot = findNewRoot(oldRoot);
|
||||
// Claude Code's own install record first: newest-mtime is only a guess, and a
|
||||
// build installed from a terminal while this server kept running is exactly
|
||||
// the one to hand over to, whether or not this chain pulled anything.
|
||||
const newRoot = require("./plugin-bootstrap").supersededBy(oldRoot) || findNewRoot(oldRoot);
|
||||
if (!newRoot) {
|
||||
// Already newest: the marketplace had nothing to pull. Not a failure — the
|
||||
// old server is still running and untouched, so say so plainly.
|
||||
|
||||
@@ -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