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
861 lines
36 KiB
JavaScript
861 lines
36 KiB
JavaScript
/**
|
|
* @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
|
|
* (`/api/v1/repos/<owner>/<repo>/releases/latest` — the Gitea/Forgejo shape
|
|
* this project is hosted on), so the check needs no hardcoded host and no
|
|
* credentials for a public repo. When no release can be read — a non-HTTP
|
|
* remote, an offline machine, a repo with no releases yet — detection degrades
|
|
* to the original commit-count comparison against the canonical default branch
|
|
* rather than reporting a false "up to date".
|
|
*
|
|
* **Two independent paths, chosen by install kind.** A checkout derives the API
|
|
* URL from its `origin` remote. A plugin-cache install does NOT: 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`,
|
|
* 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
|
|
* releases were published — a bug that reads as "up to date" and is therefore
|
|
* invisible while the install happens to be current. Keep the plugin branch
|
|
* ahead of the `.git` probe.
|
|
*
|
|
* Two states the release model makes visible that a commit count cannot:
|
|
* a checkout sitting AHEAD of the newest release tag is a `dev_build`, not an
|
|
* up-to-date install; and an install living under `~/.claude/plugins/cache/`
|
|
* is `plugin_managed`, which this process does not rewrite itself — Claude Code
|
|
* owns that directory, so `applyUpdate` refuses and `routes/updates.js` hands
|
|
* the job to the detached `claude plugin update` chain instead.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const { execFile } = require("child_process");
|
|
const { readLastPluginUpdate } = require("./plugin-update");
|
|
const { getClaudeHome } = require("./claude-home");
|
|
|
|
const DEFAULT_ROOT = path.join(__dirname, "..", "..");
|
|
|
|
// This build tracks its OWN repository only: origin points at
|
|
// git.smartgift.io.vn/Smartgift-AI/AgentHub. "upstream" is
|
|
// deliberately absent from the priority list — a stray upstream remote must
|
|
// never make the update checker report commits from somebody else's repo.
|
|
const REMOTE_PRIORITY = ["origin"];
|
|
|
|
// Scrub git hook environment variables (GIT_DIR, GIT_INDEX_FILE, etc.) that
|
|
// leak from a parent git hook process — e.g. this repo's own pre-commit
|
|
// hook, which runs `npm run test:server` and therefore this module too.
|
|
// Without this, every git call below silently targets the OUTER repo (the
|
|
// hook's) instead of `cwd`, since GIT_DIR takes precedence over cwd-based
|
|
// discovery. Same scrub `server/lib/worktree.js` already applies.
|
|
const GIT_ENV = { ...process.env };
|
|
delete GIT_ENV.GIT_DIR;
|
|
delete GIT_ENV.GIT_WORK_TREE;
|
|
delete GIT_ENV.GIT_INDEX_FILE;
|
|
delete GIT_ENV.GIT_COMMON_DIR;
|
|
delete GIT_ENV.GIT_OBJECT_DIRECTORY;
|
|
delete GIT_ENV.GIT_ALTERNATE_OBJECT_DIRECTORIES;
|
|
delete GIT_ENV.GIT_PREFIX;
|
|
delete GIT_ENV.GIT_NAMESPACE;
|
|
delete GIT_ENV.GIT_CONFIG_PARAMETERS;
|
|
for (const name of Object.keys(GIT_ENV)) {
|
|
if (/^GIT_CONFIG_(COUNT|KEY_\d+|VALUE_\d+|GLOBAL|SYSTEM)$/.test(name)) delete GIT_ENV[name];
|
|
}
|
|
|
|
function execGit(cwd, args, opts = {}) {
|
|
const timeout = opts.timeout ?? 120_000;
|
|
return new Promise((resolve, reject) => {
|
|
execFile(
|
|
"git",
|
|
args,
|
|
{ cwd, timeout, maxBuffer: 2_000_000, encoding: "utf8", env: GIT_ENV },
|
|
(err, stdout) => {
|
|
if (err) reject(err);
|
|
else resolve(String(stdout).trim());
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
/** Same shape as {@link execGit}, for the `npm run setup`/`npm run build` steps
|
|
* {@link applyUpdate} runs after fast-forwarding. */
|
|
function execNpm(cwd, args, opts = {}) {
|
|
const timeout = opts.timeout ?? 300_000;
|
|
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
return new Promise((resolve, reject) => {
|
|
execFile(
|
|
npmCmd,
|
|
args,
|
|
{ cwd, timeout, maxBuffer: 5_000_000, encoding: "utf8" },
|
|
(err, stdout, stderr) => {
|
|
if (err) reject(new Error(stderr || err.message || String(err)));
|
|
else resolve(String(stdout).trim());
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
async function listRemotes(gitRoot) {
|
|
try {
|
|
const out = await execGit(gitRoot, ["remote"], { timeout: 10_000 });
|
|
return out
|
|
.split(/\r?\n/)
|
|
.map((l) => l.trim())
|
|
.filter(Boolean);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function pickCanonicalRemote(gitRoot) {
|
|
const remotes = await listRemotes(gitRoot);
|
|
for (const candidate of REMOTE_PRIORITY) {
|
|
if (remotes.includes(candidate)) return candidate;
|
|
}
|
|
return remotes[0] || null;
|
|
}
|
|
|
|
async function resolveCompareRefForRemote(gitRoot, remote) {
|
|
const tryRefs = [`${remote}/master`, `${remote}/main`];
|
|
for (const ref of tryRefs) {
|
|
try {
|
|
await execGit(gitRoot, ["rev-parse", "--verify", ref], { timeout: 10_000 });
|
|
return ref;
|
|
} catch {
|
|
// continue
|
|
}
|
|
}
|
|
try {
|
|
const sym = await execGit(gitRoot, ["symbolic-ref", `refs/remotes/${remote}/HEAD`], {
|
|
timeout: 10_000,
|
|
});
|
|
const m = sym.match(/^refs\/remotes\/(.+)$/);
|
|
if (m) return m[1];
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function getCurrentBranch(gitRoot) {
|
|
try {
|
|
const branch = await execGit(gitRoot, ["symbolic-ref", "--short", "HEAD"], {
|
|
timeout: 10_000,
|
|
});
|
|
return branch || null;
|
|
} catch {
|
|
return null; // detached HEAD
|
|
}
|
|
}
|
|
|
|
async function getBranchUpstream(gitRoot) {
|
|
try {
|
|
return await execGit(gitRoot, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], {
|
|
timeout: 10_000,
|
|
});
|
|
} catch {
|
|
return null; // no tracking branch configured
|
|
}
|
|
}
|
|
|
|
function stripRemotePrefix(ref) {
|
|
// "upstream/master" -> "master"; "origin/feature/foo" -> "feature/foo"
|
|
const idx = ref.indexOf("/");
|
|
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" };
|
|
}
|
|
return fetchReleaseFromUrl(remoteUrl, timeoutMs);
|
|
}
|
|
|
|
/**
|
|
* The repository URL releases are read from: the marketplace manifest for a
|
|
* plugin-cache install (no git there), else the canonical git remote. Null
|
|
* when neither answers. Never throws. Used by ai-tools-sync.js, which needs
|
|
* the repo itself (not just the newest release) to read files at a tag.
|
|
*/
|
|
async function repoUrlFor(root) {
|
|
const plugin = detectPluginInstall(root);
|
|
if (plugin) return pluginMarketplaceUrl(plugin.marketplace, plugin.plugin);
|
|
try {
|
|
const remote = await pickCanonicalRemote(root);
|
|
if (!remote) return null;
|
|
return (await execGit(root, ["remote", "get-url", remote], { timeout: 10_000 })) || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The HTTP half of the release lookup, split out because a **plugin-cache
|
|
* install has no git remote to derive the URL from** — it is unpacked from an
|
|
* archive, so `pluginMarketplaceUrl()` supplies the repository URL instead and
|
|
* calls straight in here. Same contract as `fetchLatestRelease`: never throws.
|
|
*
|
|
* @param {string} repoUrl a repository URL (https, scp-style, or ssh://)
|
|
* @param {number} [timeoutMs]
|
|
*/
|
|
async function fetchReleaseFromUrl(repoUrl, timeoutMs = 10_000) {
|
|
if (!repoUrl) return { ok: false, reason: "no_remote" };
|
|
const api = releaseApiUrl(repoUrl);
|
|
if (!api) return { ok: false, reason: "remote_not_http" };
|
|
|
|
try {
|
|
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.
|
|
*/
|
|
/**
|
|
* Repository URL a marketplace declares for one of its plugins, read off disk.
|
|
*
|
|
* This exists because a plugin-cache install is NOT necessarily a git clone.
|
|
* When a marketplace entry uses `"source": "url"` (this project's own does),
|
|
* Claude Code unpacks an archive and the cache directory has no `.git` at all —
|
|
* so there is no remote to derive the releases API from, and every git-based
|
|
* signal is unavailable. The marketplace manifest is the authority on where the
|
|
* plugin came from, and it is already on disk next to the cache.
|
|
*
|
|
* Path: `<claude home>/plugins/known_marketplaces.json` gives the marketplace's
|
|
* `installLocation`; that directory's `.claude-plugin/marketplace.json` lists
|
|
* the plugins, each with its own `source`.
|
|
*
|
|
* @param {string} marketplace marketplace name (from the cache path)
|
|
* @param {string} plugin plugin name (from the cache path)
|
|
* @returns {string|null} the URL, or null when it cannot be determined. Never throws.
|
|
*/
|
|
function pluginMarketplaceUrl(marketplace, plugin) {
|
|
try {
|
|
const pluginsDir = path.join(getClaudeHome(), "plugins");
|
|
let installLocation = path.join(pluginsDir, "marketplaces", marketplace);
|
|
try {
|
|
const known = JSON.parse(
|
|
fs.readFileSync(path.join(pluginsDir, "known_marketplaces.json"), "utf8")
|
|
);
|
|
const entry = known && known[marketplace];
|
|
if (entry && typeof entry.installLocation === "string" && entry.installLocation) {
|
|
installLocation = entry.installLocation;
|
|
}
|
|
} catch {
|
|
// Fall back to the conventional location — the manifest below is what
|
|
// actually matters, and it may well be readable without this index.
|
|
}
|
|
|
|
const manifest = JSON.parse(
|
|
fs.readFileSync(path.join(installLocation, ".claude-plugin", "marketplace.json"), "utf8")
|
|
);
|
|
const entry = (manifest?.plugins || []).find((p) => p && p.name === plugin);
|
|
if (!entry) return null;
|
|
|
|
// `source` is either a string (a bare URL, 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" && 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
|
|
// command instead of inventing an API URL that would 404.
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Full update status for an install running out of the plugin cache, computed
|
|
* WITHOUT git. Version comparison is the only signal available and the only one
|
|
* that means anything here: the cache directory name is a content hash, and
|
|
* even when a `.git` happens to exist it is a depth-1 clone whose commit
|
|
* distances are graft artefacts.
|
|
*
|
|
* Returns the same payload shape as the git path, with every git-derived field
|
|
* null, so one client renders both.
|
|
*/
|
|
async function pluginStatus(root, pluginInstall, options = {}) {
|
|
const localVersion = readLocalVersion(root);
|
|
const repoUrl = pluginMarketplaceUrl(pluginInstall.marketplace, pluginInstall.plugin);
|
|
const release =
|
|
options.latestRelease ??
|
|
(options.skipFetch ? { ok: false, reason: "skipped" } : await fetchReleaseFromUrl(repoUrl));
|
|
|
|
// No local version means nothing to compare against — report "could not
|
|
// look" rather than a false "up to date".
|
|
const comparable = release.ok && Boolean(localVersion);
|
|
const releaseIsNewer = comparable && compareVersions(release.tag, localVersion) > 0;
|
|
|
|
// `claude plugin update` run from a terminal unpacks the new build into a
|
|
// sibling cache dir and starts NOTHING — only a SessionStart or this
|
|
// dashboard's own update chain hands the server over. Without a Claude Code
|
|
// session this server ran the old build forever, and with the forge
|
|
// unreachable it said "up to date". The local record needs no network, and
|
|
// "Update now" already re-bootstraps from the newest sibling.
|
|
const installedRoot = require("../../scripts/plugin-bootstrap").supersededBy(root);
|
|
const installedNotRunning = installedRoot
|
|
? { root: installedRoot, version: readLocalVersion(installedRoot) }
|
|
: null;
|
|
const updateAvailable = releaseIsNewer || Boolean(installedNotRunning);
|
|
|
|
const manualParts = [
|
|
`claude plugin marketplace update ${pluginInstall.marketplace}`,
|
|
`claude plugin update ${pluginInstall.plugin}@${pluginInstall.marketplace} -y`,
|
|
];
|
|
|
|
return {
|
|
// Truthfully false: there is no clone here. Consumers must branch on
|
|
// `install_kind`, not on this, or they will report "check manually" for an
|
|
// install the dashboard can in fact check AND update.
|
|
git_repo: false,
|
|
update_available: updateAvailable,
|
|
installed_not_running: installedNotRunning,
|
|
repo_root: root,
|
|
situation: "plugin_managed",
|
|
situation_note:
|
|
`This dashboard runs from the Claude Code plugin cache (marketplace ` +
|
|
`'${pluginInstall.marketplace}'). Updating is handled by Claude Code's own plugin ` +
|
|
`commands — "Update now" runs them for you and restarts the dashboard. An already-open ` +
|
|
`Claude Code session still needs '/reload-plugins' to pick up new skills and commands.`,
|
|
manual_command: manualParts.join(" && "),
|
|
// A plugin install compares versions exactly like a checkout does, so it
|
|
// gets the same sentence. Left null, `agenthub update-check` printed the
|
|
// literal "null" as its headline on every plugin-cache install — the one
|
|
// install kind that cannot fall back to a commit count.
|
|
message: installedNotRunning
|
|
? `Version ${installedNotRunning.version || "(unknown)"} is installed but not running ` +
|
|
`(this server runs ${localVersion || "no version"}); "Update now" switches the dashboard to it.`
|
|
: releaseIsNewer
|
|
? `Release ${release.tag} is available (this install reports ${localVersion || "no version"}).`
|
|
: comparable
|
|
? `Running the newest release (${release.tag}).`
|
|
: release.ok
|
|
? `This install reports no version, so it cannot be compared with release ${release.tag}.`
|
|
: `Could not read the latest release (${release.reason}); version comparison unavailable.`,
|
|
|
|
install_kind: "plugin",
|
|
plugin_marketplace: pluginInstall.marketplace,
|
|
plugin_name: pluginInstall.plugin,
|
|
last_plugin_update: readLastPluginUpdate(),
|
|
|
|
local_version: localVersion,
|
|
// Not a clone, so there is nothing to be shallow about and no distances to
|
|
// report. Kept present-and-null so the payload shape never varies.
|
|
shallow_clone: false,
|
|
local_sha: null,
|
|
remote_sha: null,
|
|
commits_behind: 0,
|
|
release_check: release.ok ? (localVersion ? "ok" : "no_local_version") : release.reason,
|
|
latest_release: release.ok ? release.tag : null,
|
|
latest_release_name: release.ok ? release.name : null,
|
|
latest_release_url: release.ok ? release.url : null,
|
|
latest_release_published_at: release.ok ? release.published_at : null,
|
|
latest_release_notes: release.ok ? release.notes : null,
|
|
release_sha: null,
|
|
commits_behind_release: null,
|
|
commits_ahead_of_release: null,
|
|
// A plugin install is whatever the marketplace shipped; it cannot be ahead.
|
|
dev_build: false,
|
|
};
|
|
}
|
|
|
|
async function isShallowClone(root) {
|
|
try {
|
|
const out = await execGit(root, ["rev-parse", "--is-shallow-repository"], { timeout: 10_000 });
|
|
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]
|
|
* @returns {Promise<object>}
|
|
*/
|
|
async function getUpdatesStatus(gitRoot = DEFAULT_ROOT, options = {}) {
|
|
const root = path.resolve(gitRoot);
|
|
|
|
// A plugin-cache install is answered WITHOUT git, and this must come before
|
|
// the `.git` probe below. When a marketplace entry uses `"source": "url"`,
|
|
// Claude Code unpacks an archive and there is no `.git` at all — so the probe
|
|
// would return "not a git clone; check for updates manually" and skip the
|
|
// entire release layer, leaving update_available permanently false however
|
|
// many releases get published. Version compare needs no clone; the repository
|
|
// URL comes from the marketplace manifest.
|
|
const earlyPluginInstall = detectPluginInstall(root);
|
|
if (earlyPluginInstall) return pluginStatus(root, earlyPluginInstall, options);
|
|
|
|
const gitDir = path.join(root, ".git");
|
|
if (!fs.existsSync(gitDir)) {
|
|
return {
|
|
git_repo: false,
|
|
update_available: false,
|
|
repo_root: root,
|
|
manual_command: null,
|
|
message: "Install directory is not a git clone; check for updates manually.",
|
|
};
|
|
}
|
|
|
|
const canonicalRemote = await pickCanonicalRemote(root);
|
|
if (!canonicalRemote) {
|
|
return {
|
|
git_repo: true,
|
|
update_available: false,
|
|
repo_root: root,
|
|
remote_ref: null,
|
|
local_sha: null,
|
|
remote_sha: null,
|
|
commits_behind: 0,
|
|
message: "No git remotes configured; automatic update check skipped.",
|
|
};
|
|
}
|
|
|
|
if (!options.skipFetch) {
|
|
try {
|
|
// `--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,
|
|
update_available: false,
|
|
repo_root: root,
|
|
canonical_remote: canonicalRemote,
|
|
fetch_error: err.message || String(err),
|
|
message: `Could not reach ${canonicalRemote}; try again when online.`,
|
|
};
|
|
}
|
|
}
|
|
|
|
const remoteRef = await resolveCompareRefForRemote(root, canonicalRemote);
|
|
if (!remoteRef) {
|
|
return {
|
|
git_repo: true,
|
|
update_available: false,
|
|
repo_root: root,
|
|
canonical_remote: canonicalRemote,
|
|
message: `Could not resolve ${canonicalRemote}/master, ${canonicalRemote}/main, or ${canonicalRemote}/HEAD.`,
|
|
};
|
|
}
|
|
|
|
const currentBranch = await getCurrentBranch(root);
|
|
const branchUpstream = await getBranchUpstream(root);
|
|
const tracksCanonical = branchUpstream === remoteRef;
|
|
|
|
let localSha;
|
|
let remoteSha;
|
|
let commitsBehind = 0;
|
|
try {
|
|
localSha = await execGit(root, ["rev-parse", "HEAD"], { timeout: 10_000 });
|
|
remoteSha = await execGit(root, ["rev-parse", remoteRef], { timeout: 10_000 });
|
|
const countStr = await execGit(root, ["rev-list", "--count", `HEAD..${remoteRef}`], {
|
|
timeout: 30_000,
|
|
});
|
|
commitsBehind = Number.parseInt(countStr, 10);
|
|
if (Number.isNaN(commitsBehind)) commitsBehind = 0;
|
|
} catch (err) {
|
|
return {
|
|
git_repo: true,
|
|
update_available: false,
|
|
repo_root: root,
|
|
canonical_remote: canonicalRemote,
|
|
remote_ref: remoteRef,
|
|
message: err.message || String(err),
|
|
};
|
|
}
|
|
|
|
// ── 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");
|
|
|
|
// Branch-aware manual_command. Three situations:
|
|
// 1. tracksCanonical: HEAD's tracked upstream IS the canonical ref. A
|
|
// plain `git pull --ff-only` does the right thing — typical clone on
|
|
// the default branch.
|
|
// 2. Same branch *name* as canonical but different upstream (the fork
|
|
// case: local master tracking origin/master, canonical is
|
|
// upstream/master). Need to fetch the canonical remote and merge it
|
|
// into the local branch.
|
|
// 3. Anything else (feature branch, detached HEAD): pulling the current
|
|
// branch wouldn't bring in canonical commits, so don't suggest it.
|
|
// Offer a fetch and let the user decide how to integrate.
|
|
const canonicalBranchName = stripRemotePrefix(remoteRef);
|
|
let manualParts;
|
|
let situationNote;
|
|
let situation;
|
|
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;
|
|
} else if (currentBranch && currentBranch === canonicalBranchName) {
|
|
situation = "fork_or_diverged_tracking";
|
|
manualParts = [
|
|
`cd "${root}"`,
|
|
`git fetch ${canonicalRemote}`,
|
|
`git merge --ff-only ${remoteRef}`,
|
|
...installSteps,
|
|
];
|
|
situationNote = `You're on '${currentBranch}' tracking '${
|
|
branchUpstream || "no upstream"
|
|
}'. This command fast-forwards your branch from ${remoteRef} (the canonical default).`;
|
|
} else {
|
|
situation = currentBranch ? "feature_branch" : "detached_head";
|
|
manualParts = [`cd "${root}"`, `git fetch ${canonicalRemote}`];
|
|
situationNote = currentBranch
|
|
? `You're on '${currentBranch}', not the canonical default branch (${remoteRef}). Fetched commits won't be pulled into your branch — rebase or merge ${remoteRef} when you're ready.`
|
|
: `HEAD is detached. Fetched commits stay under ${remoteRef}; check out the canonical default branch when ready.`;
|
|
}
|
|
|
|
const manualCommand = manualParts.join(" && ");
|
|
|
|
return {
|
|
git_repo: true,
|
|
update_available: updateAvailable,
|
|
repo_root: root,
|
|
remote_ref: remoteRef,
|
|
canonical_remote: canonicalRemote,
|
|
current_branch: currentBranch,
|
|
tracking_upstream: branchUpstream,
|
|
tracks_canonical: tracksCanonical,
|
|
situation,
|
|
local_sha: localSha,
|
|
remote_sha: remoteSha,
|
|
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
|
|
? 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.",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fast-forwards the checkout to the canonical remote and rebuilds, for the
|
|
* "Update now" button — the one path in this dashboard that mutates the
|
|
* working tree and restarts the process on its own initiative (everywhere
|
|
* else, `server/routes/updates.js` only ever prints a command for the user
|
|
* to run). Only acts when {@link getUpdatesStatus} reports a situation that
|
|
* is actually fast-forwardable (`tracking_canonical` or
|
|
* `fork_or_diverged_tracking`); a feature branch or detached HEAD is left
|
|
* alone since there's no safe automatic move.
|
|
*
|
|
* @param {string} [gitRoot]
|
|
* @returns {Promise<object>} the refreshed status, plus `applied` (and
|
|
* `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"
|
|
) {
|
|
return { ...status, applied: false, reason: "not_fast_forwardable" };
|
|
}
|
|
|
|
const root = path.resolve(gitRoot);
|
|
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 });
|
|
await execGit(root, ["merge", "--ff-only", status.remote_ref], { timeout: 30_000 });
|
|
}
|
|
|
|
await execNpm(root, ["run", "setup"]);
|
|
if (process.env.NODE_ENV === "production") {
|
|
await execNpm(root, ["run", "build"]);
|
|
}
|
|
|
|
const refreshed = await getUpdatesStatus(root, { skipFetch: true });
|
|
return { ...refreshed, applied: true };
|
|
}
|
|
|
|
module.exports = {
|
|
getUpdatesStatus,
|
|
applyUpdate,
|
|
DEFAULT_ROOT,
|
|
// Exported for tests: each is a pure decision the status shape depends on.
|
|
compareVersions,
|
|
releaseApiUrl,
|
|
detectPluginInstall,
|
|
pluginMarketplaceUrl,
|
|
fetchReleaseFromUrl,
|
|
fetchLatestRelease,
|
|
repoUrlFor,
|
|
isShallowClone,
|
|
};
|