/** * @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, repoUrlFor, } = 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, repository, } = 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", repository, 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("repoUrlFor", () => { it("answers a plugin-cache root from the marketplace manifest, no git", async () => { const { root } = makePluginInstall("repourl"); assert.equal(await repoUrlFor(root), "https://forge.example.com/Owner/Repo.git"); }); it("answers null for a directory with neither a manifest nor a remote", async () => { assert.equal(await repoUrlFor(tmpDir), null); }); }); 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("falls back to the marketplace's repository for a relative source", () => { // This project's own entry is `"source": "./"` — the plugin IS the // marketplace repo. Without this the dashboard answered `no_remote` and // could never see a release on any plugin-cache install. makePluginInstall("relsrc", { source: "./", repository: "https://forge.example.com/Owner/Repo", }); assert.equal(pluginMarketplaceUrl("sg", "agenthub"), "https://forge.example.com/Owner/Repo"); }); it("returns null for a relative source when the manifest declares no repository", () => { makePluginInstall("relnorepo", { source: "./plugins/agenthub" }); assert.equal(pluginMarketplaceUrl("sg", "agenthub"), null); }); 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". // Not even when the marketplace declares a repository: a shorthand source // names a DIFFERENT repo, so borrowing the marketplace's is a wrong answer. makePluginInstall("ghshort", { source: { source: "github", repo: "Owner/Repo" }, repository: "https://forge.example.com/Owner/Marketplace", }); 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: the not-a-git-clone message, everything else absent. // Asserted on the message's CONTENT, not on it being null — a plugin // install now carries its own sentence, and null was only ever a proxy for // "did not fall through here". assert.doesNotMatch( s.message, /not a git clone/i, "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("carries a human-readable message in every outcome, never a null", async () => { // `agenthub update-check` prints `message` as its headline. Null here made // it print the literal "null" on every plugin-cache install — the one kind // that has no commit count to fall back on. const cases = [ [makePluginInstall("msg-newer").root, { latestRelease: release("v1.6.0") }, /v1\.6\.0/], [makePluginInstall("msg-current").root, { latestRelease: release("v1.5.0") }, /newest/i], [ makePluginInstall("msg-failed").root, { latestRelease: { ok: false, reason: "unreachable" } }, /unreachable/, ], [ makePluginInstall("msg-noversion", { version: null }).root, { latestRelease: release("v1.6.0") }, /no version|cannot be compared/i, ], ]; for (const [root, opts, expected] of cases) { const s = await getUpdatesStatus(root, opts); assert.equal(typeof s.message, "string", `message must be a string, got ${s.message}`); assert.match(s.message, expected); } }); 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; } }); }); describe("a newer build installed beside the running one", () => { /** Record `installed` as Claude Code's install of agenthub@sg. */ function recordInstall(home, installed) { fs.writeFileSync( path.join(home, "plugins", "installed_plugins.json"), JSON.stringify({ plugins: { "agenthub@sg": [{ scope: "user", installPath: installed }] } }) ); } it("reports an update even when the release check fails", async () => { // `claude plugin update` from a terminal unpacks the new build and starts // nothing: the server keeps running the old directory, and with the forge // unreachable it used to answer "up to date" forever. const { root, home } = makePluginInstall("installed-newer", { version: "1.8.10" }); const newer = path.join(path.dirname(root), "aaaaaaaaaaaa"); fs.mkdirSync(newer, { recursive: true }); fs.writeFileSync(path.join(newer, "package.json"), JSON.stringify({ version: "1.8.12" })); recordInstall(home, newer); const s = await getUpdatesStatus(root, { latestRelease: { ok: false, reason: "unreachable" }, }); assert.equal(s.update_available, true); assert.deepEqual(s.installed_not_running, { root: newer, version: "1.8.12" }); assert.match(s.message, /1\.8\.12 is installed but not running/); }); it("says nothing extra when the running build is the installed one", async () => { const { root, home } = makePluginInstall("installed-same", { version: "1.8.12" }); recordInstall(home, root); const s = await getUpdatesStatus(root, { latestRelease: release("v1.8.12") }); assert.equal(s.update_available, false); assert.equal(s.installed_not_running, null); }); });