cc85e35439
Update detection was a commit count against the default branch, which says nothing about whether a *version* shipped and is pure noise on the plugin cache (a depth-1 clone, where every distance is a graft artefact). It now reads the forge's own releases API and compares the published tag to this install's package.json version, falling back to the commit count only when no release can be read — with the reason in release_check, so "up to date" never silently means "we could not look". Two states a commit count cannot express: dev_build (ahead of the newest release, missing nothing from it) now reads "Dev build - N ahead" instead of a false "Up to date"; and on a shallow clone every commits_*_release field is null and dev_build is never asserted, because the numbers would be meaningless. POST /api/updates/apply gains a second mode, chosen by install kind and never by the request body. A checkout still fast-forwards onto the release tag, rebuilds and self-restarts. A ~/.claude/plugins/cache/ install is refused by applyUpdate (that directory is Claude Code's to replace) and handed instead to a detached chain: claude plugin marketplace update -> claude plugin update -y -> plugin-bootstrap.js --force from the NEW cache dir, which retires this server and starts its replacement. Both write the HTTP response before anything stops the process. The chain records every outcome to plugin-update.json in the runtime dir, outside the cache the update replaces, so the next server reports it as last_plugin_update: a failed chain is otherwise indistinguishable from a slow one, and the tab would poll a server that never returns. /reload-plugins is deliberately not in the chain - it is REPL-scoped to the session that runs it, so no background process can perform it, and plugin-bootstrap derives PLUGIN_ROOT from its own __dirname, which is what makes it unnecessary for the dashboard. Also rebrands the sidebar subtitle, splash and page title to SmartGift Solution, and corrects three ARCHITECTURE.md statements that claimed /apply did not exist. Verified: server 1683/1683, client 486/486, build, prettier, file-header audit, openapi.yaml regenerated.
638 lines
25 KiB
JavaScript
638 lines
25 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, derived from the
|
|
* `origin` remote URL (`/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 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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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/<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" };
|
|
}
|
|
|
|
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 <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.
|
|
*/
|
|
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);
|
|
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,
|
|
fetchLatestRelease,
|
|
isShallowClone,
|
|
};
|