diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f6baeba0..7dd42910 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1965,12 +1965,12 @@ These behaviors are reinforced by explicit `Cache-Control` headers from the prod The Update Notifier tells the user when a newer **release** of the dashboard has been published, and — on an ordinary git checkout that can be fast-forwarded — can apply it on one click. -The version signal is the forge's own release list, read over REST at `/api/v1/repos///releases/latest` with the owner and repo derived from the `origin` remote URL (no hardcoded host, no credentials for a public repo). The published tag is compared against this install's `package.json` version. When no release can be read — a non-HTTP remote, an offline machine, a repo with no releases — detection falls back to the original commit count against the canonical default branch and puts the reason in `release_check`, so "up to date" never means "we couldn't look". +The version signal is the forge's own release list, read over REST at `/api/v1/repos///releases/latest` (no hardcoded host, no credentials for a public repo). The owner and repo come from one of **two** sources, chosen by install kind: a checkout derives them from its `origin` remote; a plugin-cache install derives them from the marketplace manifest on disk (`pluginMarketplaceUrl`), because it has no remote to read — and often no `.git` at all, since a marketplace entry with `"source": "url"` (this project's own) makes Claude Code unpack an archive rather than clone. The published tag is compared against this install's `package.json` version. When no release can be read — a non-HTTP remote, an offline machine, a repo with no releases — detection falls back to the original commit count against the canonical default branch and puts the reason in `release_check`, so "up to date" never means "we couldn't look". Two states the release model makes visible that a commit count cannot: - **`dev_build`** — the install is strictly ahead of the newest release and missing nothing from it. It is a build off the branch, not a shipped version, and the Sidebar says so instead of "Up to date". -- **`plugin_managed`** — the install runs from `~/.claude/plugins/cache/`. Claude Code owns that directory and replaces it wholesale, so `applyUpdate` will not rewrite it: it refuses with `reason: "plugin_managed"`, decided from the path before any status lookup so the refusal holds offline too. A self-pull would leave a cache directory whose name (a commit sha) no longer matched its contents. **The button still works here**, by a different route: `POST /api/updates/apply` catches that refusal and hands the job to the detached chain in `server/lib/plugin-update.js` — `claude plugin marketplace update ` → `claude plugin update @ -y` → `plugin-bootstrap.js --force` **from the new cache directory**, which stops this server and starts the new one. It answers `200 {applied:true, mode:"plugin"}`. The one thing no background process can do is `/reload-plugins`: that is a REPL action scoped to the session that runs it, so an already-open Claude Code session still needs it for new skills/commands/hooks. The dashboard does not, because `plugin-bootstrap.js` derives `PLUGIN_ROOT` from its own `__dirname` rather than from `${CLAUDE_PLUGIN_ROOT}`. +- **`plugin_managed`** — the install runs from `~/.claude/plugins/cache/`. Claude Code owns that directory and replaces it wholesale, so `applyUpdate` will not rewrite it: it refuses with `reason: "plugin_managed"`, decided from the path before any status lookup so the refusal holds offline too. A self-pull would leave a cache directory whose name (a commit sha) no longer matched its contents. **This branch is evaluated before the `.git` probe, and must stay there.** An archive-sourced cache directory is not a clone, so a `.git` check reached first answers "not a git clone; check manually" and skips the release layer entirely — which is exactly what 1.5.1 shipped: `update_available` pinned false however many releases were published, `plugin_marketplace`/`plugin_name` never set, so `/apply`'s plugin branch could never fire and its 409 claimed nothing newer was published. It reads as "up to date", so it stays invisible while the install happens to be current. **The button still works here**, by a different route: `POST /api/updates/apply` catches that refusal and hands the job to the detached chain in `server/lib/plugin-update.js` — `claude plugin marketplace update ` → `claude plugin update @ -y` → `plugin-bootstrap.js --force` **from the new cache directory**, which stops this server and starts the new one. It answers `200 {applied:true, mode:"plugin"}`. The one thing no background process can do is `/reload-plugins`: that is a REPL action scoped to the session that runs it, so an already-open Claude Code session still needs it for new skills/commands/hooks. The dashboard does not, because `plugin-bootstrap.js` derives `PLUGIN_ROOT` from its own `__dirname` rather than from `${CLAUDE_PLUGIN_ROOT}`. A **shallow clone** (`--depth`) is the third special case, and the plugin cache is one: history is grafted, so every commit-distance number is a graft artefact rather than a distance. All `commits_*_release` fields are `null` there and `dev_build` is never asserted; the version string is the only signal used. ### Module Layout @@ -2057,7 +2057,7 @@ sequenceDiagram | Component | Responsibility | | --- | --- | -| **`server/lib/update-check.js`** | `getUpdatesStatus(root?, { skipFetch?, latestRelease? })` + `applyUpdate(root?)`. Runs every git call via `execFile` (no shell, 10s–120s timeouts) and reads the newest release over `fetch` with an `AbortSignal.timeout` — `fetchLatestRelease` NEVER throws, resolving to `{ok:false, reason}` (`no_remote`, `remote_not_http`, `no_releases`, `unreachable`, `bad_payload`, `http_`) so a status poll degrades instead of erroring. `releaseApiUrl` derives the endpoint from the remote (https, scp-style and `ssh://` forms) and returns null for anything not owner/repo shaped, which is what keeps local-path remotes off the network entirely. `compareVersions` orders numeric triples and strips a leading `v`, since this repo carries both `v1.5.0` and `1.4.6`. `isShallowClone` gates every commit-distance calculation; `detectPluginInstall` reads the marketplace and plugin name out of the cache path. **Branch- and fork-aware** as before: resolves `/master`/`/main`/`/HEAD`, reads the current branch and its tracked upstream, and shapes `manual_command` per situation — the `claude plugin ...` commands for `plugin_managed`; `git fetch --tags && git merge --ff-only ` for `release_available`; `git pull --ff-only` when the branch tracks the canonical ref; `git fetch && git merge --ff-only ` for forks; `git fetch ` only on a feature branch or detached HEAD. Non-git installs, missing remotes, fetch failures, and unresolvable upstream refs are returned as soft payloads — never throws. | +| **`server/lib/update-check.js`** | `getUpdatesStatus(root?, { skipFetch?, latestRelease? })` + `applyUpdate(root?)`. Runs every git call via `execFile` (no shell, 10s–120s timeouts) and reads the newest release over `fetch` with an `AbortSignal.timeout` — `fetchLatestRelease` NEVER throws, resolving to `{ok:false, reason}` (`no_remote`, `remote_not_http`, `no_releases`, `unreachable`, `bad_payload`, `http_`) so a status poll degrades instead of erroring. `releaseApiUrl` derives the endpoint from a repository URL (https, scp-style and `ssh://` forms) and returns null for anything not owner/repo shaped, which is what keeps local-path remotes off the network entirely. `fetchLatestRelease` finds that URL via the `origin` remote; `fetchReleaseFromUrl` takes one directly, which is how the **git-free plugin path** works: `pluginMarketplaceUrl(mkt, plugin)` reads `known_marketplaces.json` → the marketplace's `.claude-plugin/marketplace.json` → the entry's `source.url`, returning null (never a guessed host) for a `github` `owner/repo` shorthand and never throwing. **Order matters:** `detectPluginInstall` runs FIRST in `getUpdatesStatus`, ahead of the `.git` probe — a plugin cache unpacked from an archive has no `.git`, and probing first is the 1.5.1 bug that made `update_available` permanently false. `compareVersions` orders numeric triples and strips a leading `v`, since this repo carries both `v1.5.0` and `1.4.6`. `isShallowClone` gates every commit-distance calculation; `detectPluginInstall` reads the marketplace and plugin name out of the cache path. **Branch- and fork-aware** as before: resolves `/master`/`/main`/`/HEAD`, reads the current branch and its tracked upstream, and shapes `manual_command` per situation — the `claude plugin ...` commands for `plugin_managed`; `git fetch --tags && git merge --ff-only ` for `release_available`; `git pull --ff-only` when the branch tracks the canonical ref; `git fetch && git merge --ff-only ` for forks; `git fetch ` only on a feature branch or detached HEAD. Non-git installs, missing remotes, fetch failures, and unresolvable upstream refs are returned as soft payloads — never throws. | | **`server/update-scheduler.js`** | Ticks the lib every `DASHBOARD_UPDATE_CHECK_INTERVAL_MS` (default 300 000, floor 60 000). First tick is scheduled 8s after server start with `.unref()` so it doesn't block shutdown. Broadcasts only when the fingerprint `{update_available, remote_sha, commits_behind, fetch_error, manual_command}` changes — `manual_command` is included so situation transitions (e.g. user switches branches, or adds an `upstream` remote) trigger a re-broadcast even when the SHA and commit count are unchanged. Emits a framed message to stdout on "up-to-date → behind" transitions, and only suggests "restart the dashboard" when the printed command actually rewrites the working tree. `DASHBOARD_UPDATE_CHECK=0\|false\|off` disables the scheduler entirely. | | **`server/routes/updates.js`** | Three endpoints: `GET /status` (read-only check), `POST /check` (check + broadcast), `POST /apply`. `/apply` has **two modes**, chosen by how this copy was installed, never by the request body: **checkout** — fast-forward onto the release tag, `npm run setup`, `npm run build` in production, then `scheduleRestart()`; **plugin** — `applyUpdate`'s `plugin_managed` refusal is caught and `schedulePluginUpdate()` runs instead, answering `200 {applied:true, mode:"plugin"}`. The plugin branch fires only when there is genuinely something to pull (`update_available` plus a marketplace and plugin name parsed from the cache path) — a `plugin_managed` refusal with nothing newer published stays a 409 rather than spawning a chain that would stop the server for nothing. Otherwise 409 `UPDATE_NOT_APPLICABLE` with `reason` `"up_to_date"`, `"not_fast_forwardable"`, or `"plugin_managed"`. Both modes write the response BEFORE anything stops the process. Mounted under `/api/*`, so all three ride the default loopback bind as their trust boundary and are gated by `DASHBOARD_TOKEN` when one is set. | | **`server/lib/plugin-update.js`** + **`scripts/plugin-update-helper.js`** | The plugin-cache update chain. The lib only spawns the helper detached (cwd = the runtime dir, **not** the plugin root, which is about to be superseded) and clears any previous verdict first so a stale success can't be read back as this run's result. All work is in the helper because its last step SIGTERMs the server that asked for the update — the same split as `self-restart.js`, one step further. The helper re-validates the marketplace and plugin names against `/^[A-Za-z0-9][\w.-]*$/` (they reach an argv across a process boundary), resolves `claude` on PATH or at `~/.local/bin/claude` (a hook-spawned server does not inherit the login shell's PATH), picks the newest cache directory that isn't the old root and does contain `scripts/plugin-bootstrap.js`, and records **every** outcome — including an unexpected throw — to `plugin-update.json` under `~/.claude/agenthub/runtime/`, outside the cache so it survives the swap. Steps: `validate`, `resolve_claude`, `marketplace_update`, `plugin_update`, `already_current` (`ok:true`; nothing was pulled and the old server was left running), `bootstrap`, `done`. `readLastPluginUpdate()` surfaces it as `last_plugin_update` on the status payload — without it a failed chain is indistinguishable from a slow one. | @@ -2126,7 +2126,8 @@ The same shape is used by `GET /status`, `POST /check`, and the `update_status` | Condition | Returned payload | User-visible effect | | --- | --- | --- | -| Not a git clone | `{git_repo:false, update_available:false, message:"Install directory is not a git clone..."}` | Modal suppressed (`update_available` false). Sidebar stays neutral. | +| Not a git clone (and not a plugin cache) | `{git_repo:false, update_available:false, message:"Install directory is not a git clone..."}` | Modal suppressed (`update_available` false). Sidebar stays neutral. | +| Plugin cache with no `.git` (archive-sourced marketplace entry) | `{git_repo:false, install_kind:"plugin", release_check:"ok", latest_release:"vX.Y.Z", update_available:}` | Checked normally: the repo URL comes from the marketplace manifest and `package.json` is the only local signal. The `notGit` note is suppressed and **Update now** is offered. | | No remotes configured | `{git_repo:true, update_available:false, message:"No git remotes configured..."}` | Same as above. | | `git fetch` failed (offline, auth) | `{git_repo:true, update_available:false, canonical_remote, fetch_error:""}` | Sidebar button goes amber; modal stays suppressed until a successful check. | | Canonical default branch unresolvable | `{git_repo:true, update_available:false, canonical_remote, message:"Could not resolve /master..."}` | Modal suppressed. | @@ -2138,7 +2139,8 @@ The same shape is used by `GET /status`, `POST /check`, and the `update_status` | Newer release, plugin-cache install | `{update_available:true, situation:"plugin_managed", install_kind:"plugin", plugin_marketplace:"sg", plugin_name:"agenthub", manual_command:"claude plugin marketplace update sg && ..."}` | Modal opens WITH **Update now**; `POST /apply` answers `200 {applied:true, mode:"plugin"}` and the detached chain pulls the new version and re-bootstraps. The modal notes that an already-open Claude Code session still needs `/reload-plugins` for new skills/commands. | | Plugin chain ran but nothing was published | `{last_plugin_update:{ok:true, step:"already_current"}}` | The old server keeps running, untouched; no restart, no error banner. | | Plugin chain failed (no `claude` on PATH, `npm ci` broke, bootstrap died) | `{last_plugin_update:{ok:false, step:"resolve_claude"\|"plugin_update"\|"bootstrap", error:"..."}}` | Danger banner in the modal naming the failed step. A `bootstrap` failure means the old server was already stopped, so the verdict says to run `/agenthub-update` to recover — and it is read back by whichever server comes up next, because it lives outside the cache. | -| Plugin `/apply` with nothing newer published | 409 `{reason:"plugin_managed"}` | No chain is spawned. Message points at `/agenthub-update` to force a refresh. | +| Plugin `/apply` with nothing newer published | 409 `{reason:"plugin_managed", release_check:"ok"}` | No chain is spawned. Message names the newest release and points at `/agenthub-update` to force a refresh. | +| Plugin `/apply` after a failed release check | 409 `{reason:"plugin_managed", release_check:"unreachable"\|"no_local_version"\|...}` | No chain is spawned, and the message says the check did not succeed rather than claiming nothing newer exists — an unknown reported as a negative is how a broken check looks identical to an up-to-date install. | | Healthy, behind, on canonical branch | `{update_available:true, situation:"tracking_canonical", manual_command:"...git pull --ff-only..."}` | Modal opens with `git pull` flow + restart hint. | | Healthy, behind, fork (origin = fork, upstream = canonical) | `{update_available:true, situation:"fork_or_diverged_tracking", manual_command:"...git fetch upstream && git merge --ff-only upstream/master..."}` | Modal opens with merge flow + restart hint + `situation_note` explaining the divergence. | | Healthy, behind, on a feature branch | `{update_available:true, situation:"feature_branch", manual_command:"...git fetch "}` | Modal opens; `situation_note` explains the user is off the canonical branch; restart hint suppressed because the working tree isn't being changed. | diff --git a/CLAUDE.md b/CLAUDE.md index 8db8d7de..b7de34d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. +_Last synced: eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. diff --git a/bin/agenthub.js b/bin/agenthub.js index bbef4f7f..1f2f524a 100755 --- a/bin/agenthub.js +++ b/bin/agenthub.js @@ -1195,7 +1195,10 @@ async function cmdUpdateCheck() { // sees the same fresh result via the update_status websocket broadcast. const s = await post("/api/updates/check"); heading("Dashboard updates", s.repo_root || baseUrl()); - if (!s.git_repo) { + // A plugin-cache install legitimately has no clone (an archive-sourced + // marketplace entry unpacks one), yet it IS checkable and updatable via the + // release API — so only a genuine non-git checkout bails out here. + if (!s.git_repo && s.install_kind !== "plugin") { console.log(`${c.yellow("○")} ${s.message}`); return; } diff --git a/client/src/components/UpdateNotifier.tsx b/client/src/components/UpdateNotifier.tsx index 3e8be69a..2d943388 100644 --- a/client/src/components/UpdateNotifier.tsx +++ b/client/src/components/UpdateNotifier.tsx @@ -372,7 +372,7 @@ export function UpdateNotifier() { ) : null} - {!status.git_repo ? ( + {!status.git_repo && status.install_kind !== "plugin" ? (
{t("notGit")}
diff --git a/client/src/components/__tests__/UpdateNotifier.test.tsx b/client/src/components/__tests__/UpdateNotifier.test.tsx index 56824a6e..b3793719 100644 --- a/client/src/components/__tests__/UpdateNotifier.test.tsx +++ b/client/src/components/__tests__/UpdateNotifier.test.tsx @@ -114,6 +114,29 @@ describe("UpdateNotifier — Update now", () => { expect(screen.getByText(i18n.t("updates:pluginAutoNote"))).toBeInTheDocument(); }); + it("still offers the button when a plugin install has no git clone at all", async () => { + // The 1.5.1 bug: an archive-sourced marketplace entry leaves no .git, so + // git_repo is legitimately false. Gating the UI on that reported "not a git + // clone; check manually" for an install the dashboard can check AND update. + statusMock.mockResolvedValue({ + ...BASE_STATUS, + git_repo: false, + situation: "plugin_managed", + install_kind: "plugin", + plugin_marketplace: "sg", + plugin_name: "agenthub", + commits_behind: 0, + local_version: "1.5.1", + release_check: "ok", + latest_release: "v1.6.0", + }); + render(); + + await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" })); + expect(screen.getByText(i18n.t("updates:updateNow"))).toBeInTheDocument(); + expect(screen.queryByText(i18n.t("updates:notGit"))).not.toBeInTheDocument(); + }); + it("surfaces a previous plugin chain's failure so a dead update isn't silent", async () => { statusMock.mockResolvedValue({ ...BASE_STATUS, diff --git a/docs/CLI.md b/docs/CLI.md index 7113ef3d..a9d640ee 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -277,7 +277,7 @@ Because a lane's services are fully detached, restarting the dashboard (or `agen | `agenthub export [file.json]` | Full JSON data export (sessions, agents, events, tokens, workflows, dashboard runs, alert rules, pricing) — defaults to a dated filename. Re-importable via `agenthub import-data` | | `agenthub cleanup --hours N --days M` | Abandon active sessions idle for `N` hours and/or purge completed sessions older than `M` days | | `agenthub reinstall-hooks` | Rewrite the Claude Code hook entries in `~/.claude/settings.json` | -| `agenthub update-check` | Ask the server whether a newer **release** has been published, read from the forge's own releases API (derived from the `origin` remote); falls back to a commit count against the canonical remote when no release can be read, with the reason in `release_check`. Prints the release tag against the running version, a situation note for fork/feature-branch checkouts, and the **copy-paste update command**. Reports `dev_build` when the install is ahead of the newest release, and `plugin_managed` for an install under `~/.claude/plugins/cache/`. The dashboard UI can self-apply either kind (`POST /api/updates/apply`): a fast-forwardable checkout is fast-forwarded onto the release tag, rebuilt and self-restarted; a plugin install is handed to a detached `claude plugin marketplace update` → `claude plugin update` → `plugin-bootstrap.js --force` chain instead. `/agenthub-update` remains the manual equivalent, and an already-open Claude Code session still needs `/reload-plugins` to pick up new skills and commands. Also refreshes the update banner in any open dashboard tab (same `update_status` broadcast) | +| `agenthub update-check` | Ask the server whether a newer **release** has been published, read from the forge's own releases API (a checkout derives the repo from its `origin` remote; a plugin-cache install, which has no clone and often no `.git` at all, derives it from the marketplace manifest on disk); falls back to a commit count against the canonical remote when no release can be read, with the reason in `release_check`. Prints the release tag against the running version, a situation note for fork/feature-branch checkouts, and the **copy-paste update command**. Reports `dev_build` when the install is ahead of the newest release, and `plugin_managed` for an install under `~/.claude/plugins/cache/`. The dashboard UI can self-apply either kind (`POST /api/updates/apply`): a fast-forwardable checkout is fast-forwarded onto the release tag, rebuilt and self-restarted; a plugin install is handed to a detached `claude plugin marketplace update` → `claude plugin update` → `plugin-bootstrap.js --force` chain instead. `/agenthub-update` remains the manual equivalent, and an already-open Claude Code session still needs `/reload-plugins` to pick up new skills and commands. Also refreshes the update banner in any open dashboard tab (same `update_status` broadcast) | | `agenthub clear-data --yes` | Delete **all** data (schema preserved). Refuses to run without `--yes` | | `agenthub open` | Open the dashboard in your default browser (`open` / `xdg-open` / `start`) | | `agenthub version` | Print the agenthub version (also `--version` / `-v`) | diff --git a/server/__tests__/update-check-plugin-nogit.test.js b/server/__tests__/update-check-plugin-nogit.test.js new file mode 100644 index 00000000..091e8dd7 --- /dev/null +++ b/server/__tests__/update-check-plugin-nogit.test.js @@ -0,0 +1,320 @@ +/** + * @file Regression tests for update detection on a plugin-cache install that is + * NOT a git clone. + * + * This is the case 1.5.1 shipped broken. When a marketplace entry declares + * `"source": "url"` — this project's own does — Claude Code unpacks an archive, + * so the cache directory has no `.git`. `getUpdatesStatus` probed for `.git` + * first and returned "Install directory is not a git clone; check for updates + * manually", skipping the entire release layer: `update_available` was + * permanently false however many releases were published, `plugin_marketplace` + * and `plugin_name` were never set, so `POST /api/updates/apply`'s plugin branch + * could never fire either. The bug read as "up to date" and was therefore + * invisible for as long as the install happened to be current. + * + * These tests pin the git-free path: the repository URL comes from the + * marketplace manifest on disk, version comparison is the only signal, and a + * newer release must flip `update_available` to true. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { describe, it, before, after, beforeEach } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const { + getUpdatesStatus, + pluginMarketplaceUrl, + fetchReleaseFromUrl, + detectPluginInstall, +} = require("../lib/update-check"); + +let tmpDir; +let prevHome; + +before(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "upd-nogit-")); + prevHome = process.env.CLAUDE_HOME; +}); + +after(() => { + if (prevHome === undefined) delete process.env.CLAUDE_HOME; + else process.env.CLAUDE_HOME = prevHome; + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + /* best effort */ + } +}); + +/** + * Build a fake Claude home containing a marketplace manifest and an + * archive-style plugin cache directory — no `.git` anywhere. + * + * @param {object} opts + * @param {any} [opts.source] the marketplace entry's `source` value + * @param {string|null} [opts.version] package.json version, or null to omit the file + * @param {boolean} [opts.knownMarketplaces] write known_marketplaces.json + * @returns {{root: string, home: string}} the plugin cache root + */ +function makePluginInstall(name, opts = {}) { + const { + source = { source: "url", url: "https://forge.example.com/Owner/Repo.git", ref: "main" }, + version = "1.5.1", + knownMarketplaces = true, + } = opts; + + const home = path.join(tmpDir, name, ".claude"); + const mktDir = path.join(home, "plugins", "marketplaces", "sg"); + const root = path.join(home, "plugins", "cache", "sg", "agenthub", "d4052ed23506"); + fs.mkdirSync(path.join(mktDir, ".claude-plugin"), { recursive: true }); + fs.mkdirSync(root, { recursive: true }); + + fs.writeFileSync( + path.join(mktDir, ".claude-plugin", "marketplace.json"), + JSON.stringify({ name: "sg", plugins: [{ name: "agenthub", source }] }) + ); + if (knownMarketplaces) { + fs.writeFileSync( + path.join(home, "plugins", "known_marketplaces.json"), + JSON.stringify({ sg: { installLocation: mktDir } }) + ); + } + if (version !== null) { + fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ version })); + } + + process.env.CLAUDE_HOME = home; + return { root, home }; +} + +/** A resolved release, as fetchReleaseFromUrl would return it. */ +function release(tag) { + return { + ok: true, + tag, + name: tag.replace(/^v/, ""), + url: `https://forge.example.com/Owner/Repo/releases/tag/${tag}`, + published_at: "2026-09-10T09:18:48Z", + notes: "notes", + }; +} + +describe("pluginMarketplaceUrl", () => { + it("reads the plugin's repository URL out of the marketplace manifest", () => { + makePluginInstall("ok"); + assert.equal( + pluginMarketplaceUrl("sg", "agenthub"), + "https://forge.example.com/Owner/Repo.git" + ); + }); + + it("falls back to the conventional marketplace location when the index is missing", () => { + // known_marketplaces.json is an index, not the authority — the manifest is. + makePluginInstall("noindex", { knownMarketplaces: false }); + assert.equal( + pluginMarketplaceUrl("sg", "agenthub"), + "https://forge.example.com/Owner/Repo.git" + ); + }); + + it("accepts a bare URL string as the source", () => { + makePluginInstall("strsrc", { source: "https://forge.example.com/Owner/Repo.git" }); + assert.equal( + pluginMarketplaceUrl("sg", "agenthub"), + "https://forge.example.com/Owner/Repo.git" + ); + }); + + it("returns null for a github owner/repo shorthand rather than guessing a host", () => { + // Inventing github.com here would produce an API URL that 404s and a + // confidently wrong "up to date". + makePluginInstall("ghshort", { source: { source: "github", repo: "Owner/Repo" } }); + assert.equal(pluginMarketplaceUrl("sg", "agenthub"), null); + }); + + it("returns null when the manifest has no entry for that plugin", () => { + const { home } = makePluginInstall("nomatch"); + const manifest = path.join( + home, + "plugins", + "marketplaces", + "sg", + ".claude-plugin", + "marketplace.json" + ); + fs.writeFileSync(manifest, JSON.stringify({ name: "sg", plugins: [{ name: "other" }] })); + assert.equal(pluginMarketplaceUrl("sg", "agenthub"), null); + }); + + it("returns null instead of throwing on a malformed manifest", () => { + const { home } = makePluginInstall("badjson"); + const manifest = path.join( + home, + "plugins", + "marketplaces", + "sg", + ".claude-plugin", + "marketplace.json" + ); + fs.writeFileSync(manifest, "{not json"); + assert.equal(pluginMarketplaceUrl("sg", "agenthub"), null); + }); + + it("returns null when nothing is on disk at all", () => { + process.env.CLAUDE_HOME = path.join(tmpDir, "empty-home"); + assert.equal(pluginMarketplaceUrl("sg", "agenthub"), null); + }); +}); + +describe("getUpdatesStatus — plugin cache with no .git", () => { + it("checks the release API instead of reporting 'not a git clone'", async () => { + const { root } = makePluginInstall("regression"); + assert.equal(fs.existsSync(path.join(root, ".git")), false, "fixture must have no .git"); + + const s = await getUpdatesStatus(root, { latestRelease: release("v1.5.1") }); + + // The old behaviour: message set, everything else absent. + assert.equal(s.message, null, "must not fall through to the not-a-git-clone payload"); + assert.equal(s.release_check, "ok", "the release layer must actually run"); + assert.equal(s.install_kind, "plugin"); + assert.equal(s.plugin_marketplace, "sg"); + assert.equal(s.plugin_name, "agenthub"); + assert.equal(s.local_version, "1.5.1"); + assert.equal(s.latest_release, "v1.5.1"); + assert.equal(s.situation, "plugin_managed"); + }); + + it("reports up-to-date for the RIGHT reason when versions match", async () => { + const { root } = makePluginInstall("current"); + const s = await getUpdatesStatus(root, { latestRelease: release("v1.5.1") }); + assert.equal(s.update_available, false); + // The distinction that matters: false because it looked and found nothing, + // not false because it never looked. + assert.equal(s.release_check, "ok"); + assert.equal(s.latest_release, "v1.5.1"); + }); + + it("flips update_available to true when a newer release is published", async () => { + // The whole point: before the fix this stayed false forever. + const { root } = makePluginInstall("newer"); + const s = await getUpdatesStatus(root, { latestRelease: release("v1.6.0") }); + assert.equal(s.update_available, true); + assert.equal(s.latest_release, "v1.6.0"); + // The fields POST /apply's plugin branch requires must all be present, or + // the batch can never be spawned. + assert.ok(s.plugin_marketplace && s.plugin_name && s.repo_root); + }); + + it("does not treat an older published release as an update", async () => { + const { root } = makePluginInstall("older"); + const s = await getUpdatesStatus(root, { latestRelease: release("v1.4.0") }); + assert.equal(s.update_available, false); + // Not a dev_build either: a plugin install is whatever shipped, it cannot + // be "ahead" in the way a checkout can. + assert.equal(s.dev_build, false); + }); + + it("never claims to have checked when the release lookup failed", async () => { + const { root } = makePluginInstall("unreachable"); + const s = await getUpdatesStatus(root, { + latestRelease: { ok: false, reason: "unreachable" }, + }); + assert.equal(s.release_check, "unreachable"); + assert.equal(s.update_available, false); + assert.equal(s.latest_release, null); + }); + + it("reports no_local_version when package.json cannot be read", async () => { + // With nothing to compare against, "up to date" would be a guess. + const { root } = makePluginInstall("noversion", { version: null }); + const s = await getUpdatesStatus(root, { latestRelease: release("v1.6.0") }); + assert.equal(s.release_check, "no_local_version"); + assert.equal(s.update_available, false); + }); + + it("reports no git-derived numbers rather than fabricating them", async () => { + const { root } = makePluginInstall("nonumbers"); + const s = await getUpdatesStatus(root, { latestRelease: release("v1.6.0") }); + assert.equal(s.git_repo, false, "honest: there is no clone here"); + assert.equal(s.shallow_clone, false); + assert.equal(s.commits_behind_release, null); + assert.equal(s.commits_ahead_of_release, null); + assert.equal(s.release_sha, null); + }); + + it("prints the claude plugin commands as the manual fallback", async () => { + const { root } = makePluginInstall("manual"); + const s = await getUpdatesStatus(root, { latestRelease: release("v1.6.0") }); + assert.match(s.manual_command, /claude plugin marketplace update sg/); + assert.match(s.manual_command, /claude plugin update agenthub@sg -y/); + // No git command may be suggested for a directory that is not a clone. + assert.doesNotMatch(s.manual_command, /git /); + }); + + it("takes the marketplace and plugin from the path, not from a caller", async () => { + const { root } = makePluginInstall("frompath"); + assert.deepEqual(detectPluginInstall(root), { marketplace: "sg", plugin: "agenthub" }); + }); +}); + +describe("fetchReleaseFromUrl", () => { + let realFetch; + beforeEach(() => { + realFetch = global.fetch; + }); + + it("never touches the network for a URL that isn't owner/repo shaped", async () => { + global.fetch = () => { + throw new Error("must not fetch for an unusable URL"); + }; + try { + const res = await fetchReleaseFromUrl("/some/local/path"); + assert.equal(res.ok, false); + assert.equal(res.reason, "remote_not_http"); + } finally { + global.fetch = realFetch; + } + }); + + it("reports no_remote for a missing URL instead of throwing", async () => { + global.fetch = () => { + throw new Error("must not fetch without a URL"); + }; + try { + const res = await fetchReleaseFromUrl(null); + assert.equal(res.ok, false); + assert.equal(res.reason, "no_remote"); + } finally { + global.fetch = realFetch; + } + }); + + it("resolves the tag from a Gitea-shaped releases payload", async () => { + global.fetch = async () => ({ + ok: true, + status: 200, + json: async () => ({ tag_name: "v9.9.9", name: "9.9.9", html_url: "u", body: "b" }), + }); + try { + const res = await fetchReleaseFromUrl("https://forge.example.com/Owner/Repo.git"); + assert.equal(res.ok, true); + assert.equal(res.tag, "v9.9.9"); + } finally { + global.fetch = realFetch; + } + }); + + it("treats 404 as 'no releases yet', not a fault", async () => { + global.fetch = async () => ({ ok: false, status: 404, json: async () => ({}) }); + try { + const res = await fetchReleaseFromUrl("https://forge.example.com/Owner/Repo.git"); + assert.equal(res.ok, false); + assert.equal(res.reason, "no_releases"); + } finally { + global.fetch = realFetch; + } + }); +}); diff --git a/server/lib/update-check.js b/server/lib/update-check.js index dea3e219..5ea04702 100644 --- a/server/lib/update-check.js +++ b/server/lib/update-check.js @@ -2,21 +2,33 @@ * @file Detects whether this install is behind the newest published RELEASE of * its own repository, and can fast-forward onto that release tag on request. * - * The release is read straight from the forge's REST API, derived from the - * `origin` remote URL (`/api/v1/repos///releases/latest` — the - * Gitea/Forgejo shape this project is hosted on), so the check needs no - * hardcoded host and no credentials for a public repo. When no release can be - * read — a non-HTTP remote, an offline machine, a repo with no releases yet — - * detection degrades to the original commit-count comparison against the - * canonical default branch rather than reporting a false "up to date". + * The release is read straight from the forge's REST API + * (`/api/v1/repos///releases/latest` — the Gitea/Forgejo shape + * this project is hosted on), so the check needs no hardcoded host and no + * credentials for a public repo. When no release can be read — a non-HTTP + * remote, an offline machine, a repo with no releases yet — detection degrades + * to the original commit-count comparison against the canonical default branch + * rather than reporting a false "up to date". + * + * **Two independent paths, chosen by install kind.** A checkout derives the API + * URL from its `origin` remote. A plugin-cache install does NOT: when a + * marketplace entry declares `"source": "url"` (this project's own does) Claude + * Code unpacks an archive, so the cache directory has no `.git` and there is no + * remote to read. That path is answered entirely without git — the repository + * URL comes from the marketplace manifest on disk (`pluginMarketplaceUrl`) and + * the package.json version is the only comparison signal. Getting this wrong is + * what shipped in 1.5.1: the `.git` probe ran first, returned "not a git clone", + * and skipped the release layer, so `update_available` stayed false however many + * releases were published — a bug that reads as "up to date" and is therefore + * invisible while the install happens to be current. Keep the plugin branch + * ahead of the `.git` probe. * * Two states the release model makes visible that a commit count cannot: * a checkout sitting AHEAD of the newest release tag is a `dev_build`, not an * up-to-date install; and an install living under `~/.claude/plugins/cache/` - * is `plugin_managed`, where this process must NOT self-update — Claude Code - * only re-resolves `${CLAUDE_PLUGIN_ROOT}` on `/reload-plugins`, a REPL-only - * action no server can trigger, so a self-pull would leave a cache directory - * whose name (a commit sha) no longer matches its contents. + * is `plugin_managed`, which this process does not rewrite itself — Claude Code + * owns that directory, so `applyUpdate` refuses and `routes/updates.js` hands + * the job to the detached `claude plugin update` chain instead. * @author Nguyễn Ngọc Trí Vĩ */ @@ -24,6 +36,7 @@ const fs = require("fs"); const path = require("path"); const { execFile } = require("child_process"); const { readLastPluginUpdate } = require("./plugin-update"); +const { getClaudeHome } = require("./claude-home"); const DEFAULT_ROOT = path.join(__dirname, "..", ".."); @@ -239,8 +252,21 @@ async function fetchLatestRelease(root, timeoutMs = 10_000) { } catch { return { ok: false, reason: "no_remote" }; } + return fetchReleaseFromUrl(remoteUrl, timeoutMs); +} - const api = releaseApiUrl(remoteUrl); +/** + * The HTTP half of the release lookup, split out because a **plugin-cache + * install has no git remote to derive the URL from** — it is unpacked from an + * archive, so `pluginMarketplaceUrl()` supplies the repository URL instead and + * calls straight in here. Same contract as `fetchLatestRelease`: never throws. + * + * @param {string} repoUrl a repository URL (https, scp-style, or ssh://) + * @param {number} [timeoutMs] + */ +async function fetchReleaseFromUrl(repoUrl, timeoutMs = 10_000) { + if (!repoUrl) return { ok: false, reason: "no_remote" }; + const api = releaseApiUrl(repoUrl); if (!api) return { ok: false, reason: "remote_not_http" }; try { @@ -279,6 +305,131 @@ async function fetchLatestRelease(root, timeoutMs = 10_000) { * a tag that is actually a plain ancestor. Callers must fall back to comparing * version strings, the one signal a shallow clone still answers correctly. */ +/** + * Repository URL a marketplace declares for one of its plugins, read off disk. + * + * This exists because a plugin-cache install is NOT necessarily a git clone. + * When a marketplace entry uses `"source": "url"` (this project's own does), + * Claude Code unpacks an archive and the cache directory has no `.git` at all — + * so there is no remote to derive the releases API from, and every git-based + * signal is unavailable. The marketplace manifest is the authority on where the + * plugin came from, and it is already on disk next to the cache. + * + * Path: `/plugins/known_marketplaces.json` gives the marketplace's + * `installLocation`; that directory's `.claude-plugin/marketplace.json` lists + * the plugins, each with its own `source`. + * + * @param {string} marketplace marketplace name (from the cache path) + * @param {string} plugin plugin name (from the cache path) + * @returns {string|null} the URL, or null when it cannot be determined. Never throws. + */ +function pluginMarketplaceUrl(marketplace, plugin) { + try { + const pluginsDir = path.join(getClaudeHome(), "plugins"); + let installLocation = path.join(pluginsDir, "marketplaces", marketplace); + try { + const known = JSON.parse( + fs.readFileSync(path.join(pluginsDir, "known_marketplaces.json"), "utf8") + ); + const entry = known && known[marketplace]; + if (entry && typeof entry.installLocation === "string" && entry.installLocation) { + installLocation = entry.installLocation; + } + } catch { + // Fall back to the conventional location — the manifest below is what + // actually matters, and it may well be readable without this index. + } + + const manifest = JSON.parse( + fs.readFileSync(path.join(installLocation, ".claude-plugin", "marketplace.json"), "utf8") + ); + const entry = (manifest?.plugins || []).find((p) => p && p.name === plugin); + if (!entry) return null; + + // `source` is either a string (a bare URL or owner/repo) or an object with + // its own `source` discriminator plus `url`/`repo`. + const src = entry.source; + if (typeof src === "string") return src.includes("://") ? src : null; + if (src && typeof src.url === "string" && src.url) return src.url; + // A `github`-style `repo: "owner/repo"` shorthand carries no host; this + // build is distributed from its own forge, so rather than guessing + // github.com the caller reports "remote_not_http" and prints the manual + // command instead of inventing an API URL that would 404. + return null; + } catch { + return null; + } +} + +/** + * Full update status for an install running out of the plugin cache, computed + * WITHOUT git. Version comparison is the only signal available and the only one + * that means anything here: the cache directory name is a content hash, and + * even when a `.git` happens to exist it is a depth-1 clone whose commit + * distances are graft artefacts. + * + * Returns the same payload shape as the git path, with every git-derived field + * null, so one client renders both. + */ +async function pluginStatus(root, pluginInstall, options = {}) { + const localVersion = readLocalVersion(root); + const repoUrl = pluginMarketplaceUrl(pluginInstall.marketplace, pluginInstall.plugin); + const release = + options.latestRelease ?? + (options.skipFetch ? { ok: false, reason: "skipped" } : await fetchReleaseFromUrl(repoUrl)); + + // No local version means nothing to compare against — report "could not + // look" rather than a false "up to date". + const comparable = release.ok && Boolean(localVersion); + const releaseIsNewer = comparable && compareVersions(release.tag, localVersion) > 0; + + const manualParts = [ + `claude plugin marketplace update ${pluginInstall.marketplace}`, + `claude plugin update ${pluginInstall.plugin}@${pluginInstall.marketplace} -y`, + ]; + + return { + // Truthfully false: there is no clone here. Consumers must branch on + // `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, + repo_root: root, + situation: "plugin_managed", + situation_note: + `This dashboard runs from the Claude Code plugin cache (marketplace ` + + `'${pluginInstall.marketplace}'). Updating is handled by Claude Code's own plugin ` + + `commands — "Update now" runs them for you and restarts the dashboard. An already-open ` + + `Claude Code session still needs '/reload-plugins' to pick up new skills and commands.`, + manual_command: manualParts.join(" && "), + message: null, + + install_kind: "plugin", + plugin_marketplace: pluginInstall.marketplace, + plugin_name: pluginInstall.plugin, + last_plugin_update: readLastPluginUpdate(), + + local_version: localVersion, + // Not a clone, so there is nothing to be shallow about and no distances to + // report. Kept present-and-null so the payload shape never varies. + shallow_clone: false, + local_sha: null, + remote_sha: null, + commits_behind: 0, + release_check: release.ok ? (localVersion ? "ok" : "no_local_version") : release.reason, + latest_release: release.ok ? release.tag : null, + latest_release_name: release.ok ? release.name : null, + latest_release_url: release.ok ? release.url : null, + latest_release_published_at: release.ok ? release.published_at : null, + latest_release_notes: release.ok ? release.notes : null, + release_sha: null, + commits_behind_release: null, + commits_ahead_of_release: null, + // A plugin install is whatever the marketplace shipped; it cannot be ahead. + dev_build: false, + }; +} + async function isShallowClone(root) { try { const out = await execGit(root, ["rev-parse", "--is-shallow-repository"], { timeout: 10_000 }); @@ -306,6 +457,17 @@ async function countCommits(root, range) { */ async function getUpdatesStatus(gitRoot = DEFAULT_ROOT, options = {}) { const root = path.resolve(gitRoot); + + // A plugin-cache install is answered WITHOUT git, and this must come before + // the `.git` probe below. When a marketplace entry uses `"source": "url"`, + // Claude Code unpacks an archive and there is no `.git` at all — so the probe + // would return "not a git clone; check for updates manually" and skip the + // entire release layer, leaving update_available permanently false however + // many releases get published. Version compare needs no clone; the repository + // URL comes from the marketplace manifest. + const earlyPluginInstall = detectPluginInstall(root); + if (earlyPluginInstall) return pluginStatus(root, earlyPluginInstall, options); + const gitDir = path.join(root, ".git"); if (!fs.existsSync(gitDir)) { return { @@ -632,6 +794,8 @@ module.exports = { compareVersions, releaseApiUrl, detectPluginInstall, + pluginMarketplaceUrl, + fetchReleaseFromUrl, fetchLatestRelease, isShallowClone, }; diff --git a/server/openapi.js b/server/openapi.js index 760371fe..26ca3071 100644 --- a/server/openapi.js +++ b/server/openapi.js @@ -2471,7 +2471,7 @@ function createOpenApiSpec() { type: "object", additionalProperties: true, description: - 'applied: false plus reason: "up_to_date" | "not_fast_forwardable" | "plugin_managed". plugin_managed appears here only when a plugin-cache install has nothing newer published; when a newer release exists the plugin chain runs and the answer is 200 instead. Run /agenthub-update to force a refresh.', + 'applied: false plus reason: "up_to_date" | "not_fast_forwardable" | "plugin_managed". plugin_managed appears here only when a plugin-cache install has nothing to pull: either release_check is "ok" and the published release is already installed, or the release check did not succeed (the message says which — an unknown is never reported as "nothing newer"). When a newer release exists the plugin chain runs and the answer is 200 instead. Run /agenthub-update to force a refresh.', }, }, }, diff --git a/server/routes/updates.js b/server/routes/updates.js index e994b29f..4d5afc58 100644 --- a/server/routes/updates.js +++ b/server/routes/updates.js @@ -91,7 +91,13 @@ router.post("/apply", async (_req, res) => { result.reason === "up_to_date" ? "Already up to date." : result.reason === "plugin_managed" - ? "This install runs from the Claude Code plugin cache and nothing newer is published; run /agenthub-update to force a refresh." + ? // 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.", }, });