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:
2026-09-10 16:46:06 +07:00
parent d4052ed235
commit 30bc6debde
10 changed files with 541 additions and 23 deletions
+176 -12
View File
@@ -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,
};