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,
|
||||
|
||||
Reference in New Issue
Block a user