From ee1e07bb5e4e50ada4e5edec9ef0214266b39bdf Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Mon, 14 Sep 2026 13:10:16 +0700 Subject: [PATCH] fix(update-check): resolve the release URL for a relative plugin source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This project's marketplace entry is `"source": "./"` — the plugin IS the marketplace repo — but pluginMarketplaceUrl only accepted a URL string or `{url}`, so it returned null and every plugin-cache install answered `no_remote`: no install of this plugin could ever see a published release. A relative source now falls back to the manifest's own `repository`. An `owner/repo` shorthand still returns null — it names a different repo, so borrowing the marketplace's URL would be a confidently wrong answer. --- .../update-check-plugin-nogit.test.js | 26 +++++++++++++++-- server/lib/update-check.js | 28 ++++++++++++++----- 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/server/__tests__/update-check-plugin-nogit.test.js b/server/__tests__/update-check-plugin-nogit.test.js index 4fe1a4c5..c8966ba5 100644 --- a/server/__tests__/update-check-plugin-nogit.test.js +++ b/server/__tests__/update-check-plugin-nogit.test.js @@ -64,6 +64,7 @@ function makePluginInstall(name, opts = {}) { 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"); @@ -74,7 +75,7 @@ function makePluginInstall(name, opts = {}) { fs.writeFileSync( path.join(mktDir, ".claude-plugin", "marketplace.json"), - JSON.stringify({ name: "sg", plugins: [{ name: "agenthub", source }] }) + JSON.stringify({ name: "sg", repository, plugins: [{ name: "agenthub", source }] }) ); if (knownMarketplaces) { fs.writeFileSync( @@ -128,10 +129,31 @@ describe("pluginMarketplaceUrl", () => { ); }); + 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". - makePluginInstall("ghshort", { source: { source: "github", repo: "Owner/Repo" } }); + // 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); }); diff --git a/server/lib/update-check.js b/server/lib/update-check.js index 10c5287d..d029c57b 100644 --- a/server/lib/update-check.js +++ b/server/lib/update-check.js @@ -11,11 +11,12 @@ * 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 + * URL from its `origin` remote. A plugin-cache install does NOT: 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 + * URL comes from the marketplace manifest on disk (`pluginMarketplaceUrl`, + * which for this project's own relative `"source": "./"` entry resolves to the + * manifest's top-level `repository`) 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 @@ -346,11 +347,24 @@ function pluginMarketplaceUrl(marketplace, plugin) { 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`. + // `source` is either a string (a bare URL, a relative path, 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 (typeof src === "string" && src.includes("://")) return src; if (src && typeof src.url === "string" && src.url) return src.url; + + // A RELATIVE source ("./", "./plugins/x") means the plugin ships inside the + // marketplace repository itself — this project's own entry is exactly that + // ("source": "./") — so the manifest's declared `repository` is where its + // releases are published. Without this fallback every plugin-cache install + // of this very plugin answered `no_remote` and could never see a release. + const relative = typeof src === "string" && /^\.{0,2}\//.test(src); + if (relative) { + const repo = manifest?.repository; + const url = typeof repo === "string" ? repo : repo?.url; + if (typeof url === "string" && url.includes("://")) return 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