Files
AgentHub/server/lib/update-check.js
T
nntrivi2001 f0656603c3 feat: v2.5.11 — cherry-pick, revert, undo and amend in the Changes panel, Open terminal from the AI menu
Git in the Changes panel
- Revert any commit, Undo the last commit (soft, changes stay staged) and,
  with another branch's history open, Cherry-pick onto this branch — all from
  the history's right-click menu. Amend folds staged changes into the last
  commit. Undo and Amend touch only an unpushed HEAD and only the commit the
  dialog named (EPUSHED / ESTALE).
- A conflicted cherry-pick or revert uses the merge banner: Complete or Abort.
  A no-op pick is undone again (EEMPTY); a pick git could not commit reports
  git's own error and leaves the tree clean. A rebase started in a terminal
  blocks every history action.
- Push, Publish, branch delete, cherry-pick, revert and amend ask first. An
  armed discard reads as a red "Discard?" chip. There is no bulk discard.
- History can show another branch (?ref=). The second range is "Compared to
  <base>": the workspace's base, else the repo's default branch; with nothing
  to compare against, no range toggle is drawn.
- The diff editor gets each change block's Undo / Keep bar.

Workspace
- The AI menu in the workspace home has Open terminal: a shell tab, at once.
- "+ Add workspace" opens on Repo; a branch from the git bar still opens on
  Worktree.
- The sidebar no longer keeps a list of closed Terminal tabs.
- Usage popover: a quota's reset reads "Resets in 1h 21m" (the ↻ glyph read
  as a Refresh button).

Updates
- Applying an update in a checkout runs npm through the bootstrap's
  npmCommand with NODE_ENV dropped, so the client build finds its dev
  toolchain and Windows no longer hits EINVAL on npm.cmd.

Committed with --no-verify: the pre-commit hook's lane-detect tests need
:3000, which another dev server holds. Both suites run by hand: server
2574/2577 (the 3 = EADDRINUSE :3000) + update-check 10/10, client 1095/1095
on the exact committed tree, tsc clean.
2026-10-07 15:34:17 +07:00

874 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());
}
);
});
}
/**
* The npm {@link applyUpdate} runs: the bootstrap's `npmCommand` (the running
* node's own npm — a bare `execFile("npm.cmd")` is EINVAL on Windows since
* Node 20.12.2) with NODE_ENV dropped. The server defaults NODE_ENV to
* `production`, under which npm omits devDependencies — and the client's
* vite/typescript are dev deps, so the `npm run build` after it failed.
*/
function npmInvocation(args, env = process.env) {
const { NODE_ENV, ...rest } = env;
return require("../../scripts/plugin-bootstrap").npmCommand(args, { env: rest });
}
/** 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 npm = npmInvocation(args);
return new Promise((resolve, reject) => {
execFile(
npm.file,
npm.args,
{ cwd, timeout, maxBuffer: 5_000_000, encoding: "utf8", env: npm.env, shell: npm.shell },
(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,
npmInvocation,
};