feat(updates): detect the newest release and self-apply it, including plugin installs
Update detection was a commit count against the default branch, which says nothing about whether a *version* shipped and is pure noise on the plugin cache (a depth-1 clone, where every distance is a graft artefact). It now reads the forge's own releases API and compares the published tag to this install's package.json version, falling back to the commit count only when no release can be read — with the reason in release_check, so "up to date" never silently means "we could not look". Two states a commit count cannot express: dev_build (ahead of the newest release, missing nothing from it) now reads "Dev build - N ahead" instead of a false "Up to date"; and on a shallow clone every commits_*_release field is null and dev_build is never asserted, because the numbers would be meaningless. POST /api/updates/apply gains a second mode, chosen by install kind and never by the request body. A checkout still fast-forwards onto the release tag, rebuilds and self-restarts. A ~/.claude/plugins/cache/ install is refused by applyUpdate (that directory is Claude Code's to replace) and handed instead to a detached chain: claude plugin marketplace update -> claude plugin update -y -> plugin-bootstrap.js --force from the NEW cache dir, which retires this server and starts its replacement. Both write the HTTP response before anything stops the process. The chain records every outcome to plugin-update.json in the runtime dir, outside the cache the update replaces, so the next server reports it as last_plugin_update: a failed chain is otherwise indistinguishable from a slow one, and the tab would poll a server that never returns. /reload-plugins is deliberately not in the chain - it is REPL-scoped to the session that runs it, so no background process can perform it, and plugin-bootstrap derives PLUGIN_ROOT from its own __dirname, which is what makes it unnecessary for the dashboard. Also rebrands the sidebar subtitle, splash and page title to SmartGift Solution, and corrects three ARCHITECTURE.md statements that claimed /apply did not exist. Verified: server 1683/1683, client 486/486, build, prettier, file-header audit, openapi.yaml regenerated.
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* @file Tests for the plugin-cache update chain: the verdict file the detached
|
||||
* helper leaves behind (the only channel through which a failed chain becomes
|
||||
* visible, since the server that would report it is the one being replaced),
|
||||
* and the helper's own name guard.
|
||||
*
|
||||
* The guard test runs the REAL helper, deliberately, with a name that must be
|
||||
* rejected — that is the one execution path safe to exercise, because it must
|
||||
* bail out before invoking the `claude` CLI. No test here ever runs the chain
|
||||
* with a valid name: that would pull a plugin version onto the machine running
|
||||
* the suite and stop the developer's own dashboard.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const { execFileSync } = require("child_process");
|
||||
|
||||
const { readLastPluginUpdate, schedulePluginUpdate, runtimeDir } = require("../lib/plugin-update");
|
||||
|
||||
const HELPER = path.join(__dirname, "..", "..", "scripts", "plugin-update-helper.js");
|
||||
|
||||
let tmpDir;
|
||||
|
||||
before(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pluginupd-"));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
try {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
/** Wait for `file` to exist, polling — the helper is a separate process. */
|
||||
function waitForFile(file, timeoutMs = 20_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const sleeper = new Int32Array(new SharedArrayBuffer(4));
|
||||
while (!fs.existsSync(file) && Date.now() < deadline) {
|
||||
Atomics.wait(sleeper, 0, 0, 100);
|
||||
}
|
||||
return fs.existsSync(file);
|
||||
}
|
||||
|
||||
describe("readLastPluginUpdate", () => {
|
||||
it("returns null when the verdict file has never been written", () => {
|
||||
const rt = path.join(tmpDir, "empty");
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
assert.equal(readLastPluginUpdate(rt), null);
|
||||
});
|
||||
|
||||
it("returns null for malformed JSON rather than throwing on a status poll", () => {
|
||||
// This is read on EVERY status request for a plugin install, so a corrupt
|
||||
// file must degrade to "nothing to report", never break the endpoint.
|
||||
const rt = path.join(tmpDir, "malformed");
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
fs.writeFileSync(path.join(rt, "plugin-update.json"), "{not json");
|
||||
assert.equal(readLastPluginUpdate(rt), null);
|
||||
});
|
||||
|
||||
it("returns null for valid JSON that isn't an object", () => {
|
||||
const rt = path.join(tmpDir, "notobj");
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
fs.writeFileSync(path.join(rt, "plugin-update.json"), "42");
|
||||
assert.equal(readLastPluginUpdate(rt), null);
|
||||
});
|
||||
|
||||
it("reads back a recorded failure, including the step it died at", () => {
|
||||
const rt = path.join(tmpDir, "failed");
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(rt, "plugin-update.json"),
|
||||
JSON.stringify({ ok: false, step: "bootstrap", error: "npm ci exploded" })
|
||||
);
|
||||
const v = readLastPluginUpdate(rt);
|
||||
assert.equal(v.ok, false);
|
||||
assert.equal(v.step, "bootstrap");
|
||||
assert.match(v.error, /npm ci exploded/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("schedulePluginUpdate", () => {
|
||||
it("clears a stale verdict before spawning, so an old success can't be read as this run's result", () => {
|
||||
// Redirect the runtime dir into the temp tree — this must not touch the
|
||||
// real ~/.claude/agenthub/runtime of whoever runs the suite.
|
||||
const dataDir = path.join(tmpDir, "datadir");
|
||||
const prev = process.env.DASHBOARD_DATA_DIR;
|
||||
process.env.DASHBOARD_DATA_DIR = dataDir;
|
||||
|
||||
// Point it at a root with no helper script: the spawn fails harmlessly
|
||||
// (detached, stdio ignored) and nothing can rewrite the verdict, which is
|
||||
// exactly what makes the clearing observable.
|
||||
const fakeRoot = path.join(tmpDir, "noscripts");
|
||||
fs.mkdirSync(fakeRoot, { recursive: true });
|
||||
|
||||
try {
|
||||
const rt = runtimeDir();
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
const verdict = path.join(rt, "plugin-update.json");
|
||||
fs.writeFileSync(verdict, JSON.stringify({ ok: true, step: "done" }));
|
||||
|
||||
const res = schedulePluginUpdate({ marketplace: "sg", plugin: "agenthub", root: fakeRoot });
|
||||
assert.equal(res.spawned, true);
|
||||
assert.equal(res.runtime_dir, rt);
|
||||
assert.equal(fs.existsSync(verdict), false, "stale verdict must be removed");
|
||||
assert.equal(readLastPluginUpdate(rt), null);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.DASHBOARD_DATA_DIR;
|
||||
else process.env.DASHBOARD_DATA_DIR = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin-update-helper name guard", () => {
|
||||
it("refuses a path-traversal marketplace name without invoking the claude CLI", () => {
|
||||
const rt = path.join(tmpDir, "guard");
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
|
||||
// Run synchronously and let it record its own verdict. A name like this can
|
||||
// only arrive from a bug or tampering; either way the helper must stop
|
||||
// before it builds an argv for `claude`.
|
||||
execFileSync(process.execPath, [HELPER, "../../../evil", "agenthub", "/nonexistent/root", rt], {
|
||||
stdio: "ignore",
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
assert.ok(waitForFile(path.join(rt, "plugin-update.json")), "helper must record a verdict");
|
||||
const v = JSON.parse(fs.readFileSync(path.join(rt, "plugin-update.json"), "utf8"));
|
||||
assert.equal(v.ok, false);
|
||||
assert.equal(v.step, "validate");
|
||||
|
||||
// And it must not have gotten as far as running a command.
|
||||
const logPath = path.join(rt, "plugin-update.log");
|
||||
const log = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf8") : "";
|
||||
assert.doesNotMatch(log, /claude plugin/, "no claude invocation may be logged");
|
||||
});
|
||||
|
||||
it("refuses an empty plugin name the same way", () => {
|
||||
const rt = path.join(tmpDir, "guard2");
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
execFileSync(process.execPath, [HELPER, "sg", "", "/nonexistent/root", rt], {
|
||||
stdio: "ignore",
|
||||
timeout: 30_000,
|
||||
});
|
||||
const v = JSON.parse(fs.readFileSync(path.join(rt, "plugin-update.json"), "utf8"));
|
||||
assert.equal(v.ok, false);
|
||||
assert.equal(v.step, "validate");
|
||||
});
|
||||
|
||||
it("stamps every verdict with a finish time", () => {
|
||||
// The failure mode this file exists to prevent is a helper that dies
|
||||
// silently: the tab would poll a server that never returns and say nothing.
|
||||
// Only the rejected-name path is exercised — running the helper with a
|
||||
// valid name would really update this machine's plugin and stop its server.
|
||||
const rt = path.join(tmpDir, "guard3");
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
execFileSync(process.execPath, [HELPER, "sg", "agent hub", "/nonexistent/root", rt], {
|
||||
stdio: "ignore",
|
||||
timeout: 30_000,
|
||||
});
|
||||
const v = JSON.parse(fs.readFileSync(path.join(rt, "plugin-update.json"), "utf8"));
|
||||
assert.equal(v.step, "validate");
|
||||
assert.ok(v.finished_at, "every verdict is timestamped");
|
||||
assert.ok(!Number.isNaN(Date.parse(v.finished_at)));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,481 @@
|
||||
/**
|
||||
* @file Tests for the RELEASE layer of update detection: version comparison,
|
||||
* forge-API URL derivation from a git remote, plugin-cache recognition, and the
|
||||
* shallow-clone guard. Every scenario injects `options.latestRelease` instead of
|
||||
* letting the module reach the network, so the suite is offline and
|
||||
* deterministic; the one case that must NOT be reachable (a local file-path
|
||||
* remote) is asserted to short-circuit before any request is attempted.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const { execFileSync } = require("child_process");
|
||||
|
||||
const {
|
||||
getUpdatesStatus,
|
||||
applyUpdate,
|
||||
compareVersions,
|
||||
releaseApiUrl,
|
||||
detectPluginInstall,
|
||||
fetchLatestRelease,
|
||||
isShallowClone,
|
||||
} = require("../lib/update-check");
|
||||
|
||||
// Same scrub as update-check.test.js: a parent git hook's GIT_* vars override
|
||||
// an explicit cwd, which would point these commands at the real repo.
|
||||
const GIT_ENV = { ...process.env };
|
||||
for (const k of [
|
||||
"GIT_DIR",
|
||||
"GIT_WORK_TREE",
|
||||
"GIT_INDEX_FILE",
|
||||
"GIT_COMMON_DIR",
|
||||
"GIT_OBJECT_DIRECTORY",
|
||||
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
||||
"GIT_PREFIX",
|
||||
"GIT_NAMESPACE",
|
||||
"GIT_CONFIG_PARAMETERS",
|
||||
]) {
|
||||
delete GIT_ENV[k];
|
||||
}
|
||||
|
||||
const IDENT = ["-c", "user.email=t@t", "-c", "user.name=t"];
|
||||
|
||||
function git(cwd, args) {
|
||||
return execFileSync("git", args, {
|
||||
cwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf8",
|
||||
env: GIT_ENV,
|
||||
}).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* A repo with `version` in package.json, one commit, an optional tag, and a
|
||||
* real pushed `origin` so `origin/main` resolves — without remote-tracking
|
||||
* refs, getUpdatesStatus returns its "could not resolve remote ref" payload
|
||||
* and never reaches the release layer under test. The remote is a local bare
|
||||
* path on purpose: `releaseApiUrl` rejects it, so nothing here can touch the
|
||||
* network even if a test forgets to inject `latestRelease`.
|
||||
*/
|
||||
function makeVersionedRepo(parent, dir, version, tag) {
|
||||
const remote = path.join(parent, `${dir}-remote.git`);
|
||||
fs.mkdirSync(remote, { recursive: true });
|
||||
execFileSync("git", ["-c", "init.defaultBranch=main", "init", "--bare", remote], {
|
||||
stdio: "ignore",
|
||||
env: GIT_ENV,
|
||||
});
|
||||
|
||||
const repo = path.join(parent, dir);
|
||||
fs.mkdirSync(repo, { recursive: true });
|
||||
execFileSync("git", ["-c", "init.defaultBranch=main", "init", repo], {
|
||||
stdio: "ignore",
|
||||
env: GIT_ENV,
|
||||
});
|
||||
fs.writeFileSync(path.join(repo, "package.json"), JSON.stringify({ name: "fx", version }));
|
||||
git(repo, [...IDENT, "add", "."]);
|
||||
git(repo, [...IDENT, "commit", "-m", "release commit"]);
|
||||
if (tag) git(repo, ["tag", tag]);
|
||||
git(repo, ["remote", "add", "origin", remote]);
|
||||
git(repo, ["push", "-u", "origin", "main"]);
|
||||
return repo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repoints `origin` at an https URL AFTER the push, so `origin/main` still
|
||||
* resolves (the refs survive a URL change) while `releaseApiUrl` now produces
|
||||
* an endpoint — which is what lets a stubbed `fetch` be reached at all.
|
||||
*/
|
||||
function setHttpOrigin(repo) {
|
||||
git(repo, ["remote", "set-url", "origin", "https://git.example.com/Owner/Repo.git"]);
|
||||
return repo;
|
||||
}
|
||||
|
||||
let tmpDir;
|
||||
|
||||
before(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "updrel-"));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
try {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
describe("compareVersions", () => {
|
||||
it("orders numeric triples", () => {
|
||||
assert.equal(compareVersions("1.6.0", "1.5.0"), 1);
|
||||
assert.equal(compareVersions("1.5.0", "1.6.0"), -1);
|
||||
assert.equal(compareVersions("1.5.0", "1.5.0"), 0);
|
||||
assert.equal(compareVersions("1.5.1", "1.5.0"), 1);
|
||||
assert.equal(compareVersions("2.0.0", "1.99.99"), 1);
|
||||
});
|
||||
|
||||
it("treats a leading v as noise — this repo tags both ways", () => {
|
||||
// Real tags in this repository: `v1.5.0` and `1.4.6`.
|
||||
assert.equal(compareVersions("v1.5.0", "1.5.0"), 0);
|
||||
assert.equal(compareVersions("v1.5.0", "1.4.6"), 1);
|
||||
assert.equal(compareVersions("1.4.6", "v1.5.0"), -1);
|
||||
});
|
||||
|
||||
it("compares only the release triple, ignoring prerelease/build suffixes", () => {
|
||||
assert.equal(compareVersions("1.5.0-rc.1", "1.5.0"), 0);
|
||||
assert.equal(compareVersions("1.5.0+build9", "1.5.0"), 0);
|
||||
});
|
||||
|
||||
it("treats missing or junk components as zero rather than NaN", () => {
|
||||
assert.equal(compareVersions("2", "1.9.9"), 1);
|
||||
assert.equal(compareVersions("1.5", "1.5.0"), 0);
|
||||
assert.equal(compareVersions("junk", "0.0.0"), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("releaseApiUrl", () => {
|
||||
it("derives the Gitea endpoint from an https remote", () => {
|
||||
assert.equal(
|
||||
releaseApiUrl("https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git"),
|
||||
"https://git.smartgift.io.vn/api/v1/repos/Smartgift-AI/AgentHub/releases/latest"
|
||||
);
|
||||
});
|
||||
|
||||
it("handles a missing .git suffix, a trailing slash, and embedded credentials", () => {
|
||||
const want = "https://git.example.com/api/v1/repos/Owner/Repo/releases/latest";
|
||||
assert.equal(releaseApiUrl("https://git.example.com/Owner/Repo"), want);
|
||||
assert.equal(releaseApiUrl("https://git.example.com/Owner/Repo/"), want);
|
||||
assert.equal(releaseApiUrl("https://oauth2:tok@git.example.com/Owner/Repo.git"), want);
|
||||
});
|
||||
|
||||
it("converts scp-style and ssh:// remotes to the https API host", () => {
|
||||
const want = "https://git.example.com/api/v1/repos/Owner/Repo/releases/latest";
|
||||
assert.equal(releaseApiUrl("git@git.example.com:Owner/Repo.git"), want);
|
||||
assert.equal(releaseApiUrl("ssh://git@git.example.com:2222/Owner/Repo.git"), want);
|
||||
});
|
||||
|
||||
it("returns null for anything not owner/repo shaped — this is what keeps local-path remotes offline", () => {
|
||||
assert.equal(releaseApiUrl("/tmp/some/bare-remote.git"), null);
|
||||
assert.equal(releaseApiUrl("../relative/remote"), null);
|
||||
assert.equal(releaseApiUrl(""), null);
|
||||
// No dot in the host: a bare hostname is not a forge we can call.
|
||||
assert.equal(releaseApiUrl("https://localhost/Owner/Repo.git"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchLatestRelease", () => {
|
||||
it("reports remote_not_http for a local file-path remote, without a network call", async () => {
|
||||
const repo = path.join(tmpDir, "localremote");
|
||||
fs.mkdirSync(repo, { recursive: true });
|
||||
execFileSync("git", ["-c", "init.defaultBranch=main", "init", repo], {
|
||||
stdio: "ignore",
|
||||
env: GIT_ENV,
|
||||
});
|
||||
git(repo, ["remote", "add", "origin", path.join(tmpDir, "nowhere.git")]);
|
||||
|
||||
// Any attempt to reach the network would throw here, since fetch is gone.
|
||||
const realFetch = global.fetch;
|
||||
global.fetch = () => {
|
||||
throw new Error("fetchLatestRelease must not call fetch for a non-http remote");
|
||||
};
|
||||
try {
|
||||
const res = await fetchLatestRelease(repo);
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.reason, "remote_not_http");
|
||||
} finally {
|
||||
global.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("reports no_remote when the repo has no remotes at all", async () => {
|
||||
const repo = path.join(tmpDir, "noremote");
|
||||
fs.mkdirSync(repo, { recursive: true });
|
||||
execFileSync("git", ["-c", "init.defaultBranch=main", "init", repo], {
|
||||
stdio: "ignore",
|
||||
env: GIT_ENV,
|
||||
});
|
||||
const res = await fetchLatestRelease(repo);
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.reason, "no_remote");
|
||||
});
|
||||
|
||||
it("maps a 404 to no_releases rather than an error — a repo with no releases is normal", async () => {
|
||||
const repo = setHttpOrigin(makeVersionedRepo(tmpDir, "rel404", "1.0.0", null));
|
||||
const realFetch = global.fetch;
|
||||
global.fetch = async () => ({ ok: false, status: 404, json: async () => ({}) });
|
||||
try {
|
||||
const res = await fetchLatestRelease(repo);
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.reason, "no_releases");
|
||||
} finally {
|
||||
global.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("never throws when the request fails — it resolves to unreachable", async () => {
|
||||
const repo = setHttpOrigin(makeVersionedRepo(tmpDir, "relboom", "1.0.0", null));
|
||||
const realFetch = global.fetch;
|
||||
global.fetch = async () => {
|
||||
throw new Error("ENOTFOUND");
|
||||
};
|
||||
try {
|
||||
const res = await fetchLatestRelease(repo);
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.reason, "unreachable");
|
||||
} finally {
|
||||
global.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("caps release notes so a huge changelog can't bloat every status poll", async () => {
|
||||
const repo = setHttpOrigin(makeVersionedRepo(tmpDir, "relbig", "1.0.0", null));
|
||||
const realFetch = global.fetch;
|
||||
global.fetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ tag_name: "v9.0.0", body: "x".repeat(10_000) }),
|
||||
});
|
||||
try {
|
||||
const res = await fetchLatestRelease(repo);
|
||||
assert.equal(res.ok, true);
|
||||
assert.equal(res.notes.length, 4000);
|
||||
} finally {
|
||||
global.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectPluginInstall", () => {
|
||||
it("extracts the marketplace and plugin name from a plugin cache path", () => {
|
||||
assert.deepEqual(
|
||||
detectPluginInstall("/home/u/.claude/plugins/cache/sg/agenthub/eb6356932438"),
|
||||
{
|
||||
marketplace: "sg",
|
||||
plugin: "agenthub",
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for an ordinary checkout", () => {
|
||||
assert.equal(detectPluginInstall("/home/u/Projects/agenthub"), null);
|
||||
// A directory merely NAMED like the cache is not the cache.
|
||||
assert.equal(detectPluginInstall("/home/u/plugins/cache/sg/agenthub"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUpdatesStatus — release drives update_available", () => {
|
||||
it("reports the release as available when its tag outranks the local version", async () => {
|
||||
const repo = makeVersionedRepo(tmpDir, "behind", "1.5.0", "v1.5.0");
|
||||
const status = await getUpdatesStatus(repo, {
|
||||
skipFetch: true,
|
||||
latestRelease: {
|
||||
ok: true,
|
||||
tag: "v1.6.0",
|
||||
name: "1.6.0",
|
||||
url: "u",
|
||||
published_at: null,
|
||||
notes: null,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(status.update_available, true);
|
||||
assert.equal(status.situation, "release_available");
|
||||
assert.equal(status.latest_release, "v1.6.0");
|
||||
assert.equal(status.local_version, "1.5.0");
|
||||
assert.equal(status.dev_build, false);
|
||||
// Moves onto the TAG, and fetches tags first so the tag exists locally.
|
||||
assert.match(status.manual_command, /git fetch origin --tags/);
|
||||
assert.match(status.manual_command, /git merge --ff-only v1\.6\.0/);
|
||||
});
|
||||
|
||||
it("is up to date — not a dev build — when local version equals the release and no commits are ahead", async () => {
|
||||
const repo = makeVersionedRepo(tmpDir, "level", "1.5.0", "v1.5.0");
|
||||
const status = await getUpdatesStatus(repo, {
|
||||
skipFetch: true,
|
||||
latestRelease: {
|
||||
ok: true,
|
||||
tag: "v1.5.0",
|
||||
name: "1.5.0",
|
||||
url: null,
|
||||
published_at: null,
|
||||
notes: null,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(status.update_available, false);
|
||||
assert.equal(status.dev_build, false);
|
||||
assert.equal(status.commits_ahead_of_release, 0);
|
||||
assert.equal(status.commits_behind_release, 0);
|
||||
});
|
||||
|
||||
it("reports dev_build with a commit count when the checkout is ahead of the newest release", async () => {
|
||||
const repo = makeVersionedRepo(tmpDir, "ahead", "1.5.0", "v1.5.0");
|
||||
fs.writeFileSync(path.join(repo, "extra.txt"), "unreleased work\n");
|
||||
git(repo, [...IDENT, "add", "."]);
|
||||
git(repo, [...IDENT, "commit", "-m", "unreleased"]);
|
||||
|
||||
const status = await getUpdatesStatus(repo, {
|
||||
skipFetch: true,
|
||||
latestRelease: {
|
||||
ok: true,
|
||||
tag: "v1.5.0",
|
||||
name: "1.5.0",
|
||||
url: null,
|
||||
published_at: null,
|
||||
notes: null,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(status.update_available, false, "a dev build is not an available update");
|
||||
assert.equal(status.dev_build, true);
|
||||
assert.equal(status.commits_ahead_of_release, 1);
|
||||
assert.equal(status.commits_behind_release, 0);
|
||||
assert.match(status.message, /Dev build: 1 commit/);
|
||||
});
|
||||
|
||||
it("does not call a release ahead when the local version already outranks it", async () => {
|
||||
const repo = makeVersionedRepo(tmpDir, "newer", "2.0.0", "v2.0.0");
|
||||
const status = await getUpdatesStatus(repo, {
|
||||
skipFetch: true,
|
||||
latestRelease: {
|
||||
ok: true,
|
||||
tag: "v1.5.0",
|
||||
name: "1.5.0",
|
||||
url: null,
|
||||
published_at: null,
|
||||
notes: null,
|
||||
},
|
||||
});
|
||||
assert.equal(status.update_available, false);
|
||||
});
|
||||
|
||||
it("records why the release lookup failed and falls back to the commit count", async () => {
|
||||
const repo = makeVersionedRepo(tmpDir, "fallback", "1.5.0", "v1.5.0");
|
||||
const status = await getUpdatesStatus(repo, {
|
||||
skipFetch: true,
|
||||
latestRelease: { ok: false, reason: "unreachable" },
|
||||
});
|
||||
|
||||
assert.equal(status.release_check, "unreachable");
|
||||
assert.equal(status.latest_release, null);
|
||||
assert.equal(status.dev_build, false);
|
||||
// Not "up to date" by silence: the reason is on the payload for the UI.
|
||||
assert.match(status.message, /tip of the canonical default branch|commit\(s\) on/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUpdatesStatus — shallow clone", () => {
|
||||
it("returns null commit distances and never claims a dev build", async () => {
|
||||
// The Claude Code plugin cache is a depth-1 clone. Before this guard it
|
||||
// reported "306 behind AND 1 ahead" of a tag that is a plain ancestor.
|
||||
const origin = makeVersionedRepo(tmpDir, "deep-origin", "1.5.0", "v1.5.0");
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
fs.writeFileSync(path.join(origin, `f${i}.txt`), `${i}\n`);
|
||||
git(origin, [...IDENT, "add", "."]);
|
||||
git(origin, [...IDENT, "commit", "-m", `c${i}`]);
|
||||
}
|
||||
const shallow = path.join(tmpDir, "shallow-clone");
|
||||
execFileSync("git", ["clone", "--depth", "1", `file://${origin}`, shallow], {
|
||||
stdio: "ignore",
|
||||
env: GIT_ENV,
|
||||
});
|
||||
|
||||
assert.equal(await isShallowClone(shallow), true, "fixture must actually be shallow");
|
||||
assert.equal(await isShallowClone(origin), false);
|
||||
|
||||
const status = await getUpdatesStatus(shallow, {
|
||||
skipFetch: true,
|
||||
latestRelease: {
|
||||
ok: true,
|
||||
tag: "v1.5.0",
|
||||
name: "1.5.0",
|
||||
url: null,
|
||||
published_at: null,
|
||||
notes: null,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(status.shallow_clone, true);
|
||||
assert.equal(status.commits_behind_release, null);
|
||||
assert.equal(status.commits_ahead_of_release, null);
|
||||
assert.equal(status.dev_build, false);
|
||||
// Version compare still works, and it is the only signal used here.
|
||||
assert.equal(status.update_available, false);
|
||||
});
|
||||
|
||||
it("still reports a newer release on a shallow clone, from versions alone", async () => {
|
||||
const origin = makeVersionedRepo(tmpDir, "deep-origin2", "1.5.0", "v1.5.0");
|
||||
const shallow = path.join(tmpDir, "shallow-clone2");
|
||||
execFileSync("git", ["clone", "--depth", "1", `file://${origin}`, shallow], {
|
||||
stdio: "ignore",
|
||||
env: GIT_ENV,
|
||||
});
|
||||
|
||||
const status = await getUpdatesStatus(shallow, {
|
||||
skipFetch: true,
|
||||
latestRelease: {
|
||||
ok: true,
|
||||
tag: "v1.7.0",
|
||||
name: "1.7.0",
|
||||
url: null,
|
||||
published_at: null,
|
||||
notes: null,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(status.shallow_clone, true);
|
||||
assert.equal(status.update_available, true);
|
||||
assert.equal(status.commits_behind_release, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyUpdate — plugin cache is Claude Code's to replace", () => {
|
||||
it("refuses with reason plugin_managed and touches nothing", async () => {
|
||||
// Build the repo underneath a path that looks like the plugin cache, since
|
||||
// that path IS the signal (see detectPluginInstall).
|
||||
const cacheRoot = path.join(tmpDir, "home", ".claude", "plugins", "cache", "sg", "agenthub");
|
||||
fs.mkdirSync(cacheRoot, { recursive: true });
|
||||
const repo = setHttpOrigin(makeVersionedRepo(cacheRoot, "abc123def456", "1.5.0", "v1.5.0"));
|
||||
const headBefore = git(repo, ["rev-parse", "HEAD"]);
|
||||
|
||||
const status = await getUpdatesStatus(repo, {
|
||||
skipFetch: true,
|
||||
latestRelease: {
|
||||
ok: true,
|
||||
tag: "v1.9.0",
|
||||
name: "1.9.0",
|
||||
url: null,
|
||||
published_at: null,
|
||||
notes: null,
|
||||
},
|
||||
});
|
||||
assert.equal(status.install_kind, "plugin");
|
||||
assert.equal(status.plugin_marketplace, "sg");
|
||||
assert.equal(status.situation, "plugin_managed");
|
||||
assert.equal(status.update_available, true);
|
||||
// The printed commands are Claude Code's own update path, not git.
|
||||
assert.match(status.manual_command, /claude plugin marketplace update sg/);
|
||||
assert.match(status.manual_command, /claude plugin update agenthub@sg -y/);
|
||||
assert.match(status.situation_note, /reload-plugins/);
|
||||
|
||||
// applyUpdate hits the network-free path only because it re-reads status;
|
||||
// stub fetch so the refusal is decided by the path, not by connectivity.
|
||||
const realFetch = global.fetch;
|
||||
global.fetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ tag_name: "v1.9.0" }),
|
||||
});
|
||||
try {
|
||||
const result = await applyUpdate(repo);
|
||||
assert.equal(result.applied, false);
|
||||
assert.equal(result.reason, "plugin_managed");
|
||||
} finally {
|
||||
global.fetch = realFetch;
|
||||
}
|
||||
assert.equal(git(repo, ["rev-parse", "HEAD"]), headBefore, "HEAD must be untouched");
|
||||
});
|
||||
});
|
||||
@@ -95,12 +95,22 @@ describe("POST /api/updates/apply", () => {
|
||||
// The test repo checkout has no update pending (or isn't fast-forwardable
|
||||
// from a bare test run), so this exercises the decline path only — it must
|
||||
// never touch the working tree or process in a test run.
|
||||
//
|
||||
// The `plugin_managed` branch cannot fire from a checkout: it additionally
|
||||
// requires plugin_marketplace/plugin_name, which only a path under
|
||||
// ~/.claude/plugins/cache/ produces. A suite run from such a copy would get
|
||||
// 200 + mode:"plugin" and spawn the update chain, so guard on the shape
|
||||
// rather than the status code alone.
|
||||
const res = await httpFetch("/api/updates/apply", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
});
|
||||
assert.equal(res.body.install_kind, "checkout", "suite must run from a git checkout");
|
||||
assert.equal(res.status, 409);
|
||||
assert.equal(res.body.applied, false);
|
||||
assert.ok(["up_to_date", "not_fast_forwardable"].includes(res.body.reason));
|
||||
assert.ok(
|
||||
["up_to_date", "not_fast_forwardable", "plugin_managed"].includes(res.body.reason),
|
||||
`unexpected reason: ${res.body.reason}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* @file Launches and reports on the plugin-cache update chain — the path that
|
||||
* makes "Update now" work for an install running out of
|
||||
* `~/.claude/plugins/cache/` instead of a plain git checkout.
|
||||
*
|
||||
* All the work happens in the DETACHED `scripts/plugin-update-helper.js`, for a
|
||||
* structural reason: its last step runs `plugin-bootstrap.js --force`, which
|
||||
* SIGTERMs the dashboard server that asked for the update. An in-process
|
||||
* version would kill itself halfway through and leave no server to finish the
|
||||
* job or report what happened. Same split as `self-restart.js`, one step
|
||||
* further: there the helper only waits and respawns, here it also pulls the new
|
||||
* plugin version first.
|
||||
*
|
||||
* This module deliberately does NOT run `/reload-plugins`. That is a Claude Code
|
||||
* REPL action, and it re-resolves plugins only for the session that runs it — a
|
||||
* background process cannot perform it on an interactive session's behalf, and a
|
||||
* throwaway one-shot session would reload its own plugin view and exit. The
|
||||
* dashboard does not need it: `plugin-bootstrap.js` derives `PLUGIN_ROOT` from
|
||||
* its own `__dirname`, so being executed out of the new cache directory is
|
||||
* sufficient to move the server onto the new code. Only new
|
||||
* skills/commands/hooks in an already-open Claude Code session still require the
|
||||
* user to reload.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawn } = require("child_process");
|
||||
const { getDataDir } = require("./claude-home");
|
||||
|
||||
/** Runtime directory the helper writes its verdict into. Outside the plugin
|
||||
* cache on purpose — that directory is replaced by the update itself. */
|
||||
function runtimeDir() {
|
||||
return path.join(getDataDir(), "runtime");
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of the most recent plugin update attempt, or null when there has never
|
||||
* been one. Surfaced on the update-status payload as `last_plugin_update` so a
|
||||
* failed chain is visible instead of looking like a server that is merely slow
|
||||
* to come back.
|
||||
*
|
||||
* @returns {{ok: boolean, step: string, error?: string, note?: string,
|
||||
* plugin_root?: string, finished_at?: string} | null}
|
||||
*/
|
||||
function readLastPluginUpdate(rt = runtimeDir()) {
|
||||
try {
|
||||
const raw = fs.readFileSync(path.join(rt, "plugin-update.json"), "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === "object" ? parsed : null;
|
||||
} catch {
|
||||
return null; // never run, unreadable, or malformed — all "nothing to report"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn the detached update chain. Returns immediately; the caller must have
|
||||
* already written its HTTP response, because this process is about to be
|
||||
* stopped by the helper's final step.
|
||||
*
|
||||
* Clears any previous verdict first, so a stale success can never be read back
|
||||
* as the outcome of THIS run while the chain is still in flight.
|
||||
*
|
||||
* @param {{marketplace: string, plugin: string, root: string}} install
|
||||
* @returns {{spawned: true, runtime_dir: string}}
|
||||
*/
|
||||
function schedulePluginUpdate({ marketplace, plugin, root }) {
|
||||
const rt = runtimeDir();
|
||||
try {
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
fs.rmSync(path.join(rt, "plugin-update.json"), { force: true });
|
||||
} catch {
|
||||
/* best effort — a stale verdict is better than failing the update */
|
||||
}
|
||||
|
||||
const helper = path.join(root, "scripts", "plugin-update-helper.js");
|
||||
const child = spawn(process.execPath, [helper, marketplace, plugin, root, rt], {
|
||||
// cwd is the runtime dir, NOT the plugin root: the root is about to be
|
||||
// superseded, and a cwd inside a replaced directory can wedge the process.
|
||||
cwd: rt,
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
env: process.env,
|
||||
});
|
||||
child.unref();
|
||||
return { spawned: true, runtime_dir: rt };
|
||||
}
|
||||
|
||||
module.exports = { schedulePluginUpdate, readLastPluginUpdate, runtimeDir };
|
||||
+312
-12
@@ -1,15 +1,29 @@
|
||||
/**
|
||||
* @file Detects whether the dashboard git checkout is behind the canonical
|
||||
* remote default branch (origin/master or origin/main on a
|
||||
* direct clone) after a non-destructive fetch. Branch- and fork-aware:
|
||||
* picks the right remote, recognises feature-branch checkouts, and shapes
|
||||
* manual_command so it actually closes the gap for the user's situation.
|
||||
* @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".
|
||||
*
|
||||
* 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.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { execFile } = require("child_process");
|
||||
const { readLastPluginUpdate } = require("./plugin-update");
|
||||
|
||||
const DEFAULT_ROOT = path.join(__dirname, "..", "..");
|
||||
|
||||
@@ -141,6 +155,150 @@ function stripRemotePrefix(ref) {
|
||||
return idx === -1 ? ref : ref.slice(idx + 1);
|
||||
}
|
||||
|
||||
/** This install's own declared version, or null when package.json is unreadable. */
|
||||
function readLocalVersion(root) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
||||
return typeof pkg.version === "string" ? pkg.version : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin cache coordinates when `root` lives under
|
||||
* `~/.claude/plugins/cache/<marketplace>/<plugin>/<sha>`, else null.
|
||||
* Read from the path because it is the one place the marketplace name is
|
||||
* available locally — shelling out to `claude plugin list` to learn it would
|
||||
* make a status poll depend on another CLI being on PATH.
|
||||
*/
|
||||
function detectPluginInstall(root) {
|
||||
const posix = root.split(path.sep).join("/");
|
||||
const m = posix.match(/\/\.claude\/plugins\/cache\/([^/]+)\/([^/]+)(?:\/|$)/);
|
||||
return m ? { marketplace: m[1], plugin: m[2] } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Numeric-triple version compare: -1 / 0 / 1. Tolerates the inconsistent tag
|
||||
* naming this repo already carries (`v1.5.0` alongside `1.4.6`) by stripping a
|
||||
* leading `v`. Prerelease and build suffixes are dropped rather than ordered —
|
||||
* this project publishes plain releases, and a wrong prerelease ordering would
|
||||
* be worse than ignoring one.
|
||||
*/
|
||||
function compareVersions(a, b) {
|
||||
const parts = (v) =>
|
||||
String(v)
|
||||
.trim()
|
||||
.replace(/^v/i, "")
|
||||
.split(/[-+]/)[0]
|
||||
.split(".")
|
||||
.map((n) => Number.parseInt(n, 10) || 0);
|
||||
const x = parts(a);
|
||||
const y = parts(b);
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
const diff = (x[i] || 0) - (y[i] || 0);
|
||||
if (diff !== 0) return diff < 0 ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the forge's "latest release" REST endpoint from a git remote URL.
|
||||
* Returns null for anything that isn't owner/repo shaped, which is what keeps
|
||||
* the local file-path remotes used by the tests off the network entirely.
|
||||
*/
|
||||
function releaseApiUrl(remoteUrl) {
|
||||
const raw = String(remoteUrl).trim();
|
||||
let m = raw.match(/^https?:\/\/(?:[^@/]*@)?([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
|
||||
if (!m) {
|
||||
// scp-style (`git@host:owner/repo.git`) and ssh:// forms.
|
||||
m = raw.match(/^(?:ssh:\/\/)?[^@/]+@([^:/]+)(?::\d+)?[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
|
||||
}
|
||||
if (!m) return null;
|
||||
const [, host, owner, repo] = m;
|
||||
if (!host.includes(".")) return null;
|
||||
return `https://${host}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(
|
||||
repo
|
||||
)}/releases/latest`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the newest published release from the forge API. Never throws: every
|
||||
* failure resolves to `{ ok: false, reason }` so a status poll degrades to the
|
||||
* commit-count path instead of surfacing an error to the user.
|
||||
*
|
||||
* @returns {Promise<{ok: true, tag: string, name: string|null, url: string|null,
|
||||
* published_at: string|null, notes: string|null} | {ok: false, reason: string}>}
|
||||
*/
|
||||
async function fetchLatestRelease(root, timeoutMs = 10_000) {
|
||||
let remoteUrl;
|
||||
try {
|
||||
const remote = await pickCanonicalRemote(root);
|
||||
if (!remote) return { ok: false, reason: "no_remote" };
|
||||
remoteUrl = await execGit(root, ["remote", "get-url", remote], { timeout: 10_000 });
|
||||
} catch {
|
||||
return { ok: false, reason: "no_remote" };
|
||||
}
|
||||
|
||||
const api = releaseApiUrl(remoteUrl);
|
||||
if (!api) return { ok: false, reason: "remote_not_http" };
|
||||
|
||||
try {
|
||||
const res = await fetch(api, {
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
headers: { accept: "application/json" },
|
||||
redirect: "follow",
|
||||
});
|
||||
// A repo with no releases yet answers 404 — a normal state, not a fault.
|
||||
if (res.status === 404) return { ok: false, reason: "no_releases" };
|
||||
if (!res.ok) return { ok: false, reason: `http_${res.status}` };
|
||||
const body = await res.json();
|
||||
if (!body || typeof body.tag_name !== "string" || !body.tag_name) {
|
||||
return { ok: false, reason: "bad_payload" };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
tag: body.tag_name,
|
||||
name: typeof body.name === "string" && body.name ? body.name : null,
|
||||
url: typeof body.html_url === "string" ? body.html_url : null,
|
||||
published_at: typeof body.published_at === "string" ? body.published_at : null,
|
||||
// Release notes are rendered in a modal, not stored — cap the size so a
|
||||
// pathological changelog can't bloat every status poll and WS broadcast.
|
||||
notes: typeof body.body === "string" && body.body ? body.body.slice(0, 4000) : null,
|
||||
};
|
||||
} catch (err) {
|
||||
return { ok: false, reason: "unreachable", error: err.message || String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True for a shallow clone (`--depth`), where EVERY commit-distance number is
|
||||
* a lie: history is grafted, so `git rev-list --count <tag>..HEAD` counts the
|
||||
* graft boundary instead of real ancestry. The Claude Code plugin cache is
|
||||
* exactly this — a depth-1 clone — which reported "306 behind AND 1 ahead" of
|
||||
* 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.
|
||||
*/
|
||||
async function isShallowClone(root) {
|
||||
try {
|
||||
const out = await execGit(root, ["rev-parse", "--is-shallow-repository"], { timeout: 10_000 });
|
||||
return out.trim() === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Commit count for `range`, or 0 when it can't be resolved. */
|
||||
async function countCommits(root, range) {
|
||||
try {
|
||||
const out = await execGit(root, ["rev-list", "--count", range], { timeout: 30_000 });
|
||||
const n = Number.parseInt(out, 10);
|
||||
return Number.isNaN(n) ? 0 : n;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} [gitRoot]
|
||||
* @param {{ skipFetch?: boolean }} [options]
|
||||
@@ -175,7 +333,9 @@ async function getUpdatesStatus(gitRoot = DEFAULT_ROOT, options = {}) {
|
||||
|
||||
if (!options.skipFetch) {
|
||||
try {
|
||||
await execGit(root, ["fetch", canonicalRemote, "--prune"], { timeout: 120_000 });
|
||||
// `--tags` so the release tag resolved below exists locally; without it
|
||||
// a freshly published release has no local commit to compare against.
|
||||
await execGit(root, ["fetch", canonicalRemote, "--prune", "--tags"], { timeout: 120_000 });
|
||||
} catch (err) {
|
||||
return {
|
||||
git_repo: true,
|
||||
@@ -225,7 +385,54 @@ async function getUpdatesStatus(gitRoot = DEFAULT_ROOT, options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
const updateAvailable = commitsBehind > 0;
|
||||
// ── Release layer ────────────────────────────────────────────────────────
|
||||
// The published release is the version signal; the commit count above stays
|
||||
// as the fallback for when no release can be read, and as the raw distance
|
||||
// that tells a dev build apart from a stale one.
|
||||
const localVersion = readLocalVersion(root);
|
||||
const pluginInstall = detectPluginInstall(root);
|
||||
const shallow = await isShallowClone(root);
|
||||
const release = options.latestRelease ?? (await fetchLatestRelease(root));
|
||||
|
||||
let releaseSha = null;
|
||||
let commitsBehindRelease = null;
|
||||
let commitsAheadOfRelease = null;
|
||||
let releaseIsNewer = false;
|
||||
if (release.ok) {
|
||||
try {
|
||||
releaseSha = await execGit(
|
||||
root,
|
||||
["rev-parse", "--verify", `refs/tags/${release.tag}^{commit}`],
|
||||
{ timeout: 10_000 }
|
||||
);
|
||||
} catch {
|
||||
releaseSha = null; // tag not fetched locally (offline, or skipFetch in tests)
|
||||
}
|
||||
// Distances stay null on a shallow clone: see isShallowClone().
|
||||
if (releaseSha && !shallow) {
|
||||
commitsBehindRelease = await countCommits(root, `HEAD..${releaseSha}`);
|
||||
commitsAheadOfRelease = await countCommits(root, `${releaseSha}..HEAD`);
|
||||
}
|
||||
// Version compare is the primary test, and the ONLY one a shallow clone can
|
||||
// answer. The commit distance decides only when this install declares no
|
||||
// version of its own and the distance is actually trustworthy.
|
||||
releaseIsNewer = localVersion
|
||||
? compareVersions(release.tag, localVersion) > 0
|
||||
: commitsBehindRelease !== null && commitsBehindRelease > 0;
|
||||
}
|
||||
|
||||
const updateAvailable = release.ok
|
||||
? releaseIsNewer
|
||||
: shallow
|
||||
? false // no release to compare and no usable commit distance — say nothing
|
||||
: commitsBehind > 0;
|
||||
// Ahead of the newest release with nothing of that release missing: a build
|
||||
// from the branch, not a shipped version. Reported so the widget never claims
|
||||
// "up to date" for a checkout carrying commits no release contains. Requires
|
||||
// behind === 0, or a diverged history would be mislabelled a dev build.
|
||||
const devBuild =
|
||||
release.ok && !releaseIsNewer && commitsBehindRelease === 0 && (commitsAheadOfRelease ?? 0) > 0;
|
||||
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
const installSteps = ["npm run setup"];
|
||||
if (isProd) installSteps.push("npm run build");
|
||||
@@ -245,7 +452,35 @@ async function getUpdatesStatus(gitRoot = DEFAULT_ROOT, options = {}) {
|
||||
let manualParts;
|
||||
let situationNote;
|
||||
let situation;
|
||||
if (tracksCanonical) {
|
||||
if (pluginInstall) {
|
||||
// Claude Code owns this directory and replaces it wholesale on update, so
|
||||
// the only correct move is its own update path. The printed commands do
|
||||
// pull the new version, but `/reload-plugins` is a REPL action — nothing
|
||||
// here can finish the job, which is why `applyUpdate` refuses outright.
|
||||
situation = "plugin_managed";
|
||||
manualParts = [
|
||||
`claude plugin marketplace update ${pluginInstall.marketplace}`,
|
||||
`claude plugin update ${pluginInstall.plugin}@${pluginInstall.marketplace} -y`,
|
||||
];
|
||||
situationNote =
|
||||
`This dashboard runs from the Claude Code plugin cache (marketplace '${pluginInstall.marketplace}'), ` +
|
||||
`so it can't update itself. Run the commands above, then '/reload-plugins' in Claude Code — ` +
|
||||
`or just run '/agenthub-update', which does both and rebuilds the runtime.`;
|
||||
} else if (release.ok && releaseIsNewer) {
|
||||
// Move onto the release TAG rather than the branch tip: the tag is what
|
||||
// was published, and a fast-forward keeps the tracking branch intact (a
|
||||
// `git checkout <tag>` would detach HEAD and break every later update).
|
||||
situation = "release_available";
|
||||
manualParts = [
|
||||
`cd "${root}"`,
|
||||
`git fetch ${canonicalRemote} --tags`,
|
||||
`git merge --ff-only ${release.tag}`,
|
||||
...installSteps,
|
||||
];
|
||||
situationNote = releaseSha
|
||||
? null
|
||||
: `Release ${release.tag} isn't in your local clone yet — the fetch above brings its tag in.`;
|
||||
} else if (tracksCanonical) {
|
||||
situation = "tracking_canonical";
|
||||
manualParts = [`cd "${root}"`, "git pull --ff-only", ...installSteps];
|
||||
situationNote = null;
|
||||
@@ -285,9 +520,42 @@ async function getUpdatesStatus(gitRoot = DEFAULT_ROOT, options = {}) {
|
||||
commits_behind: commitsBehind,
|
||||
manual_command: manualCommand,
|
||||
situation_note: situationNote,
|
||||
|
||||
// Release layer. `release_check` names why the release fields are absent
|
||||
// when they are, so the UI can say "offline" rather than "up to date".
|
||||
install_kind: pluginInstall ? "plugin" : "checkout",
|
||||
plugin_marketplace: pluginInstall ? pluginInstall.marketplace : null,
|
||||
plugin_name: pluginInstall ? pluginInstall.plugin : null,
|
||||
// Verdict of the last detached plugin-update chain, if any. Read here so
|
||||
// every consumer (route, WS broadcast, CLI) gets it from one place — and so
|
||||
// a chain that died is visible instead of looking like a slow restart.
|
||||
last_plugin_update: pluginInstall ? readLastPluginUpdate() : null,
|
||||
local_version: localVersion,
|
||||
// True when commit distances are unusable and version strings are the only
|
||||
// signal — the UI must not render an "N commits" badge in that case.
|
||||
shallow_clone: shallow,
|
||||
release_check: release.ok ? "ok" : 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: releaseSha,
|
||||
commits_behind_release: commitsBehindRelease,
|
||||
commits_ahead_of_release: commitsAheadOfRelease,
|
||||
dev_build: devBuild,
|
||||
|
||||
message: updateAvailable
|
||||
? `${commitsBehind} commit(s) on ${remoteRef} not in your checkout.`
|
||||
: "Your checkout includes the tip of the canonical default branch.",
|
||||
? release.ok
|
||||
? `Release ${release.tag} is available (this install reports ${localVersion || "no version"}).`
|
||||
: `${commitsBehind} commit(s) on ${remoteRef} not in your checkout.`
|
||||
: devBuild
|
||||
? `Dev build: ${commitsAheadOfRelease} commit(s) ahead of release ${release.tag}.`
|
||||
: release.ok
|
||||
? `Running the newest release (${release.tag}).`
|
||||
: shallow
|
||||
? `Shallow clone — could not read the latest release (${release.reason}); version comparison unavailable.`
|
||||
: "Your checkout includes the tip of the canonical default branch.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -306,11 +574,27 @@ async function getUpdatesStatus(gitRoot = DEFAULT_ROOT, options = {}) {
|
||||
* `reason` when `applied` is false).
|
||||
*/
|
||||
async function applyUpdate(gitRoot = DEFAULT_ROOT) {
|
||||
// A plugin-cache install is Claude Code's to replace. Pulling here would
|
||||
// leave a cache directory named after one commit but holding another, and
|
||||
// the running session would keep resolving ${CLAUDE_PLUGIN_ROOT} to the old
|
||||
// path until `/reload-plugins` — which no server can invoke. Refuse and let
|
||||
// the printed commands (or `/agenthub-update`) do it.
|
||||
//
|
||||
// Decided from the PATH, before any status lookup: the refusal is true
|
||||
// whether or not the machine is online and whatever the version gap is.
|
||||
// Reading it off `status.situation` instead would mis-report `up_to_date`
|
||||
// every time the fetch failed, since that error returns early.
|
||||
if (detectPluginInstall(path.resolve(gitRoot))) {
|
||||
const status = await getUpdatesStatus(gitRoot);
|
||||
return { ...status, applied: false, reason: "plugin_managed" };
|
||||
}
|
||||
|
||||
const status = await getUpdatesStatus(gitRoot);
|
||||
if (!status.update_available) {
|
||||
return { ...status, applied: false, reason: "up_to_date" };
|
||||
}
|
||||
if (
|
||||
status.situation !== "release_available" &&
|
||||
status.situation !== "tracking_canonical" &&
|
||||
status.situation !== "fork_or_diverged_tracking"
|
||||
) {
|
||||
@@ -318,7 +602,13 @@ async function applyUpdate(gitRoot = DEFAULT_ROOT) {
|
||||
}
|
||||
|
||||
const root = path.resolve(gitRoot);
|
||||
if (status.situation === "tracking_canonical") {
|
||||
if (status.situation === "release_available") {
|
||||
await execGit(root, ["fetch", status.canonical_remote, "--tags"], { timeout: 120_000 });
|
||||
// --ff-only is the guard: a release that isn't a descendant of HEAD (a
|
||||
// rollback, or a rewritten history) aborts here instead of creating a
|
||||
// merge commit in the user's install.
|
||||
await execGit(root, ["merge", "--ff-only", status.latest_release], { timeout: 30_000 });
|
||||
} else if (status.situation === "tracking_canonical") {
|
||||
await execGit(root, ["pull", "--ff-only"], { timeout: 120_000 });
|
||||
} else {
|
||||
await execGit(root, ["fetch", status.canonical_remote], { timeout: 120_000 });
|
||||
@@ -334,4 +624,14 @@ async function applyUpdate(gitRoot = DEFAULT_ROOT) {
|
||||
return { ...refreshed, applied: true };
|
||||
}
|
||||
|
||||
module.exports = { getUpdatesStatus, applyUpdate, DEFAULT_ROOT };
|
||||
module.exports = {
|
||||
getUpdatesStatus,
|
||||
applyUpdate,
|
||||
DEFAULT_ROOT,
|
||||
// Exported for tests: each is a pure decision the status shape depends on.
|
||||
compareVersions,
|
||||
releaseApiUrl,
|
||||
detectPluginInstall,
|
||||
fetchLatestRelease,
|
||||
isShallowClone,
|
||||
};
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
/**
|
||||
* @file HTTP routes for dashboard upstream-update detection, plus the one
|
||||
* self-applying path: POST /apply fast-forwards, rebuilds, and restarts the
|
||||
* process on the user's explicit click (see `server/lib/update-check.js`'s
|
||||
* `applyUpdate` and `server/lib/self-restart.js`). Everywhere else, the
|
||||
* dashboard only ever prints a command for the user to run themselves.
|
||||
* @file HTTP routes for release-update detection plus the self-applying path.
|
||||
* `POST /apply` has two modes, chosen by how this copy was installed:
|
||||
*
|
||||
* - **checkout** — fast-forward onto the newest release tag, rebuild, and
|
||||
* restart in place (`update-check.js`'s `applyUpdate` + `self-restart.js`).
|
||||
* - **plugin** — `applyUpdate` cannot touch a `~/.claude/plugins/cache/`
|
||||
* directory, so it refuses with `plugin_managed`; this route catches that and
|
||||
* hands the job to the detached chain in `plugin-update.js` instead
|
||||
* (`claude plugin marketplace update` → `claude plugin update` →
|
||||
* `plugin-bootstrap.js --force`, which retires this server and starts the new
|
||||
* one). Answered 200 with `mode: "plugin"`, not 409.
|
||||
*
|
||||
* Both modes reply BEFORE anything stops the process, and the request is only
|
||||
* ever a trigger: the marketplace and plugin names come from the local cache
|
||||
* path, never from the body, so there is nothing in the payload to validate.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const { getUpdatesStatus, applyUpdate } = require("../lib/update-check");
|
||||
const { scheduleRestart } = require("../lib/self-restart");
|
||||
const { schedulePluginUpdate } = require("../lib/plugin-update");
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -44,6 +55,33 @@ router.post("/check", async (_req, res) => {
|
||||
router.post("/apply", async (_req, res) => {
|
||||
try {
|
||||
const result = await applyUpdate();
|
||||
|
||||
// A plugin-cache install is refused by applyUpdate on purpose (it must not
|
||||
// rewrite a directory Claude Code owns). Route it to the detached chain,
|
||||
// which pulls the new version and re-bootstraps from the NEW cache dir.
|
||||
// Only when there is genuinely something to pull — a `plugin_managed`
|
||||
// refusal with nothing newer published must stay a 409, not spawn a
|
||||
// process that would stop the server for no reason.
|
||||
if (
|
||||
!result.applied &&
|
||||
result.reason === "plugin_managed" &&
|
||||
result.update_available &&
|
||||
result.plugin_marketplace &&
|
||||
result.plugin_name &&
|
||||
result.repo_root
|
||||
) {
|
||||
const scheduled = schedulePluginUpdate({
|
||||
marketplace: result.plugin_marketplace,
|
||||
plugin: result.plugin_name,
|
||||
root: result.repo_root,
|
||||
});
|
||||
// `applied: true` because the update IS being applied — just by the
|
||||
// plugin chain rather than by a git fast-forward. `mode` tells the client
|
||||
// which one, so it can word the progress text honestly.
|
||||
res.json({ ...result, applied: true, mode: "plugin", ...scheduled });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.applied) {
|
||||
res.status(409).json({
|
||||
...result,
|
||||
@@ -52,7 +90,9 @@ router.post("/apply", async (_req, res) => {
|
||||
message:
|
||||
result.reason === "up_to_date"
|
||||
? "Already up to date."
|
||||
: "Current branch can't be fast-forwarded automatically.",
|
||||
: 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."
|
||||
: "Current branch can't be fast-forwarded automatically.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user