058c574138
A `claude plugin update` run from a terminal (or with only the Claude desktop
app open) unpacks the new build and starts nothing: no SessionStart fires, so
the dashboard kept running the old cache dir across reboots, and with the
forge unreachable the sidebar read "Up to date" (seen on a machine stuck at
1.8.10).
- pluginStatus asks supersededBy for its own root: a recorded sibling install
sets `installed_not_running: {root, version}` and `update_available`, with
no network needed
- the sidebar reads "<version> installed, not running"; the update modal keys
on it, so Update now is offered even with no release tag or sha
- plugin-update-helper hands over to the recorded install first, the newest
sibling dir only as a fallback
- a plugin install whose release lookup failed reads "Could not check for
updates" instead of "Up to date"
- docs/PLUGINS.md: the case and the manual hand-over command
423 lines
16 KiB
JavaScript
423 lines
16 KiB
JavaScript
/**
|
|
* @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,
|
|
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);
|
|
});
|
|
});
|