/** * @file Detects whether this install is behind the newest published RELEASE of * its own repository, and can fast-forward onto that release tag on request. * * The release is read straight from the forge's REST API, derived from the * `origin` remote URL (`/api/v1/repos///releases/latest` — the * Gitea/Forgejo shape this project is hosted on), so the check needs no * hardcoded host and no credentials for a public repo. When no release can be * read — a non-HTTP remote, an offline machine, a repo with no releases yet — * detection degrades to the original commit-count comparison against the * canonical default branch rather than reporting a false "up to date". * * Two states the release model makes visible that a commit count cannot: * a checkout sitting AHEAD of the newest release tag is a `dev_build`, not an * up-to-date install; and an install living under `~/.claude/plugins/cache/` * is `plugin_managed`, where this process must NOT self-update — Claude Code * only re-resolves `${CLAUDE_PLUGIN_ROOT}` on `/reload-plugins`, a REPL-only * action no server can trigger, so a self-pull would leave a cache directory * whose name (a commit sha) no longer matches its contents. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("fs"); const path = require("path"); const { execFile } = require("child_process"); const { readLastPluginUpdate } = require("./plugin-update"); 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///`, else null. * Read from the path because it is the one place the marketplace name is * available locally — shelling out to `claude plugin list` to learn it would * make a status poll depend on another CLI being on PATH. */ function detectPluginInstall(root) { const posix = root.split(path.sep).join("/"); const m = posix.match(/\/\.claude\/plugins\/cache\/([^/]+)\/([^/]+)(?:\/|$)/); return m ? { marketplace: m[1], plugin: m[2] } : null; } /** * Numeric-triple version compare: -1 / 0 / 1. Tolerates the inconsistent tag * naming this repo already carries (`v1.5.0` alongside `1.4.6`) by stripping a * leading `v`. Prerelease and build suffixes are dropped rather than ordered — * this project publishes plain releases, and a wrong prerelease ordering would * be worse than ignoring one. */ function compareVersions(a, b) { const parts = (v) => String(v) .trim() .replace(/^v/i, "") .split(/[-+]/)[0] .split(".") .map((n) => Number.parseInt(n, 10) || 0); const x = parts(a); const y = parts(b); for (let i = 0; i < 3; i += 1) { const diff = (x[i] || 0) - (y[i] || 0); if (diff !== 0) return diff < 0 ? -1 : 1; } return 0; } /** * Derives the forge's "latest release" REST endpoint from a git remote URL. * Returns null for anything that isn't owner/repo shaped, which is what keeps * the local file-path remotes used by the tests off the network entirely. */ function releaseApiUrl(remoteUrl) { const raw = String(remoteUrl).trim(); let m = raw.match(/^https?:\/\/(?:[^@/]*@)?([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/); if (!m) { // scp-style (`git@host:owner/repo.git`) and ssh:// forms. m = raw.match(/^(?:ssh:\/\/)?[^@/]+@([^:/]+)(?::\d+)?[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/); } if (!m) return null; const [, host, owner, repo] = m; if (!host.includes(".")) return null; return `https://${host}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent( repo )}/releases/latest`; } /** * Reads the newest published release from the forge API. Never throws: every * failure resolves to `{ ok: false, reason }` so a status poll degrades to the * commit-count path instead of surfacing an error to the user. * * @returns {Promise<{ok: true, tag: string, name: string|null, url: string|null, * published_at: string|null, notes: string|null} | {ok: false, reason: string}>} */ async function fetchLatestRelease(root, timeoutMs = 10_000) { let remoteUrl; try { const remote = await pickCanonicalRemote(root); if (!remote) return { ok: false, reason: "no_remote" }; remoteUrl = await execGit(root, ["remote", "get-url", remote], { timeout: 10_000 }); } catch { return { ok: false, reason: "no_remote" }; } const api = releaseApiUrl(remoteUrl); if (!api) return { ok: false, reason: "remote_not_http" }; try { const res = await fetch(api, { signal: AbortSignal.timeout(timeoutMs), headers: { accept: "application/json" }, redirect: "follow", }); // A repo with no releases yet answers 404 — a normal state, not a fault. if (res.status === 404) return { ok: false, reason: "no_releases" }; if (!res.ok) return { ok: false, reason: `http_${res.status}` }; const body = await res.json(); if (!body || typeof body.tag_name !== "string" || !body.tag_name) { return { ok: false, reason: "bad_payload" }; } return { ok: true, tag: body.tag_name, name: typeof body.name === "string" && body.name ? body.name : null, url: typeof body.html_url === "string" ? body.html_url : null, published_at: typeof body.published_at === "string" ? body.published_at : null, // Release notes are rendered in a modal, not stored — cap the size so a // pathological changelog can't bloat every status poll and WS broadcast. notes: typeof body.body === "string" && body.body ? body.body.slice(0, 4000) : null, }; } catch (err) { return { ok: false, reason: "unreachable", error: err.message || String(err) }; } } /** * True for a shallow clone (`--depth`), where EVERY commit-distance number is * a lie: history is grafted, so `git rev-list --count ..HEAD` counts the * graft boundary instead of real ancestry. The Claude Code plugin cache is * exactly this — a depth-1 clone — which reported "306 behind AND 1 ahead" of * a tag that is actually a plain ancestor. Callers must fall back to comparing * version strings, the one signal a shallow clone still answers correctly. */ async function isShallowClone(root) { try { const out = await execGit(root, ["rev-parse", "--is-shallow-repository"], { timeout: 10_000 }); return out.trim() === "true"; } catch { return false; } } /** Commit count for `range`, or 0 when it can't be resolved. */ async function countCommits(root, range) { try { const out = await execGit(root, ["rev-list", "--count", range], { timeout: 30_000 }); const n = Number.parseInt(out, 10); return Number.isNaN(n) ? 0 : n; } catch { return 0; } } /** * @param {string} [gitRoot] * @param {{ skipFetch?: boolean }} [options] * @returns {Promise} */ async function getUpdatesStatus(gitRoot = DEFAULT_ROOT, options = {}) { const root = path.resolve(gitRoot); 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 ` 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} 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, fetchLatestRelease, isShallowClone, };