Files
nntrivi2001 f0dc1445fc fix: v2.6.2 — Windows panes no longer read as dead (psmux ignores -F), Update now's setup works under cmd.exe
- psmux prints `name: 1 windows (created …)` whatever `list-sessions -F`
  asks for. Read as names, every live pane looked gone and the heal beat
  closed its run (and its tab) within seconds while the session kept
  running. tmux.js (and the Update all helper) now take the text before the
  first `:` — a session name never holds one.
- `npm run setup` used `(cd client && npm install)`; cmd.exe's ( ) is no
  subshell, so `npm run link-cli` ran inside client/ ("Missing script") and
  Update now failed on Windows. Now `npm --prefix client install`.
- Update now: an npm step names itself in its error and shows the END of
  npm's output (the error, not the warnings), says when it hit the time
  limit, and gets 20 min instead of 5.
2026-10-10 12:01:01 +07:00

898 lines
38 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 });
}
// `npm run setup` installs the client's dependencies more than once and
// `npm run build` type-checks before bundling; on Windows (Defender scanning
// every file) each step ran past the old 5-minute cap.
const NPM_TIMEOUT_MS = 20 * 60_000;
/** Same shape as {@link execGit}, for the `npm run setup`/`npm run build` steps
* {@link applyUpdate} runs after fast-forwarding. A failure names its step and
* carries the END of npm's output — the error, not the warnings before it. */
function execNpm(cwd, args, opts = {}) {
const timeout = opts.timeout ?? NPM_TIMEOUT_MS;
const npm = npmInvocation(args);
const step = `npm ${args.join(" ")}`;
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) return resolve(String(stdout).trim());
if (err.killed) {
return reject(
new Error(`${step} did not finish within ${Math.round(timeout / 60_000)} min and was stopped`)
);
}
const tail = String(stderr || err.message || err).trim().split(/\r?\n/).slice(-15).join("\n");
reject(new Error(`${step} failed:\n${tail}`));
}
);
});
}
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 && commitsAheadOfRelease > 0) {
// HEAD carries commits the release lacks (e.g. local wip that the release
// squashed into a new commit): `merge --ff-only` can only fail, and how to
// reconcile is the user's call, so fetch the tag and say why.
situation = "diverged_from_release";
manualParts = [`cd "${root}"`, `git fetch ${canonicalRemote} --tags`];
situationNote =
`Your checkout has ${commitsAheadOfRelease} commit(s) that release ${release.tag} doesn't contain, ` +
`so it can't be fast-forwarded. If they're already in the release, move onto it with ` +
`'git reset --keep ${release.tag}'; otherwise merge or rebase onto ${release.tag}.`;
} 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,
execNpm,
};