30bc6debde
An archive-sourced marketplace entry ("source": "url" — this project's own)
makes Claude Code unpack the plugin rather than clone it, so the cache dir
has no .git. getUpdatesStatus probed for .git first and returned "not a git
clone; check for updates manually", skipping the entire release layer:
update_available stayed false however many releases were published, and
plugin_marketplace/plugin_name were never set, so POST /api/updates/apply's
plugin branch could never fire (it answered 409, claiming nothing newer was
published). The bug reads as "up to date", so it stayed invisible for as
long as the install happened to be current.
The plugin branch now runs BEFORE the .git probe and answers without git at
all: the repository URL comes from the marketplace manifest on disk
(known_marketplaces.json -> .claude-plugin/marketplace.json -> source.url,
null rather than a guessed host for a github owner/repo shorthand), and
package.json is the only local comparison signal. A failed release lookup
is reported as release_check, never as "nothing newer".
UI/CLI: the "not a git clone" note is gated on install_kind !== "plugin", so
the Update now button is offered where the dashboard can actually check and
apply.
802 lines
32 KiB
JavaScript
802 lines
32 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: when a
|
|
* marketplace entry declares `"source": "url"` (this project's own does) Claude
|
|
* Code unpacks an archive, so the cache directory has no `.git` and there is no
|
|
* remote to read. That path is answered entirely without git — the repository
|
|
* URL comes from the marketplace manifest on disk (`pluginMarketplaceUrl`) 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 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 or owner/repo) or an object with
|
|
// its own `source` discriminator plus `url`/`repo`.
|
|
const src = entry.source;
|
|
if (typeof src === "string") return src.includes("://") ? src : null;
|
|
if (src && typeof src.url === "string" && src.url) return src.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;
|
|
|
|
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: releaseIsNewer,
|
|
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(" && "),
|
|
message: null,
|
|
|
|
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,
|
|
isShallowClone,
|
|
};
|