fix(updates): check releases for a plugin install with no .git
An archive-sourced marketplace entry ("source": "url" — this project's own)
makes Claude Code unpack the plugin rather than clone it, so the cache dir
has no .git. getUpdatesStatus probed for .git first and returned "not a git
clone; check for updates manually", skipping the entire release layer:
update_available stayed false however many releases were published, and
plugin_marketplace/plugin_name were never set, so POST /api/updates/apply's
plugin branch could never fire (it answered 409, claiming nothing newer was
published). The bug reads as "up to date", so it stayed invisible for as
long as the install happened to be current.
The plugin branch now runs BEFORE the .git probe and answers without git at
all: the repository URL comes from the marketplace manifest on disk
(known_marketplaces.json -> .claude-plugin/marketplace.json -> source.url,
null rather than a guessed host for a github owner/repo shorthand), and
package.json is the only local comparison signal. A failed release lookup
is reported as release_check, never as "nothing newer".
UI/CLI: the "not a git clone" note is gated on install_kind !== "plugin", so
the Update now button is offered where the dashboard can actually check and
apply.
This commit is contained in:
+7
-5
@@ -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/<owner>/<repo>/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/<owner>/<repo>/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 <mkt>` → `claude plugin update <plugin>@<mkt> -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 <mkt>` → `claude plugin update <plugin>@<mkt> -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_<status>`) 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 `<remote>/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 <remote> --tags && git merge --ff-only <tag>` for `release_available`; `git pull --ff-only` when the branch tracks the canonical ref; `git fetch <remote> && git merge --ff-only <ref>` for forks; `git fetch <remote>` 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_<status>`) 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 `<remote>/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 <remote> --tags && git merge --ff-only <tag>` for `release_available`; `git pull --ff-only` when the branch tracks the canonical ref; `git fetch <remote> && git merge --ff-only <ref>` for forks; `git fetch <remote>` 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:<version compare>}` | 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:"<stderr>"}` | 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 <remote>/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 <remote>"}` | Modal opens; `situation_note` explains the user is off the canonical branch; restart hint suppressed because the working tree isn't being changed. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
+4
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -372,7 +372,7 @@ export function UpdateNotifier() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!status.git_repo ? (
|
||||
{!status.git_repo && status.install_kind !== "plugin" ? (
|
||||
<div className="text-xs text-fg-secondary bg-surface-2 border border-border rounded-lg px-3 py-2">
|
||||
{t("notGit")}
|
||||
</div>
|
||||
|
||||
@@ -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(<UpdateNotifier />);
|
||||
|
||||
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,
|
||||
|
||||
+1
-1
@@ -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`) |
|
||||
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
+176
-12
@@ -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/<owner>/<repo>/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/<owner>/<repo>/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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -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: `<claude home>/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,
|
||||
};
|
||||
|
||||
+1
-1
@@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user