import { existsSync } from 'node:fs'; import { join } from 'node:path'; /** * Is there anything to update to? * * ── The rule, and why it is about TAGS rather than commits ── * * Until 2026-08-27 "update" meant `git pull` and re-install, unconditionally, for every plugin. Nothing * could say whether there was anything to pull, so the button was always offered and usually did nothing * — and "Update all" was a row of no-ops with a spinner on it. * * Tags fix that, and the owner's rule has two halves: * * · **A checkout NOT at a tag is always updateable.** It is somewhere between releases — mid-development, * or on a commit somebody pushed without tagging. There is no claim being made about it, so pulling is * always reasonable. * · **A checkout AT a tag is updateable only when a NEWER tag exists.** It is at a release, and a release * is a claim that this is a finished thing. Offering an update that lands on the same release is how * you teach someone to ignore the button. * * ── Why "newer" is not a version comparison ── * * The obvious implementation parses `2026.08.27.01` and compares. It is wrong the first time a plugin is * tagged `v2` or `1.0.0-rc1`, and wrong SILENTLY — a plugin that never offers an update looks like a * plugin nobody has released. * * So newness is asked of git instead: the newest tag REACHABLE FROM THE REMOTE BRANCH. If the tag at HEAD * is that tag, this checkout is at the latest release. If it is a different one, the remote has moved on. * That holds for any tag names anyone ever chooses, including ones that do not sort. * * ── Where "the newest tag" comes from: the catalogue, not this machine's refs ── * * Every answer here is local, and this is called once per plugin on a request that renders a list — * twenty-one network round trips would make the plugins screen feel broken. That left one hard problem: * refs on disk go stale. Tags arrived at clone and at the fetch `pullPlugin` does, so you learned an * update existed BY UPDATING. A machine that had not touched a plugin since August compared this * morning's release against a snapshot from before it existed and said, truthfully and uselessly, that * it was at the latest. * * That was patched with a rate-limited background `git fetch` per checkout, which worked and was the * wrong shape: every host in the fleet polling twenty-one repositories to re-derive a fact one place * already knows. The marketplace publishes releases now — see `servers/marketplace/sync.ts` — so the * newest release is simply asked of the catalogue, which every host already fetches for the store. * * published tag (catalogue) vs tag at HEAD (this checkout) → differ, so there is an update * * DIFFERENCE, not ordering. `2026.08.27.01` parses fine until somebody tags `v2`, and then it fails * silently. Two strings that are not equal is a comparison that cannot rot. * * ── The git rule stays, for a plugin the store cannot speak for ── * * A checkout of something unpublished — a plugin being written, one never registered — has no catalogue * entry, and the fallback below is the old rule against local remote-tracking refs. Those refs are no * longer refreshed here, so for that case this can miss a tag until something else fetches. That is the * accepted cost: the alternative was every machine polling every repository forever to cover a case that * only arises on a developer's own box, where `git fetch` is a thing they were about to run anyway. */ export type ReleaseState = { /** The tag this checkout is exactly at, or null when it is between releases. */ tagAtHead: string | null; /** The newest tag reachable from the tracked remote branch, as git orders them. */ newestTag: string | null; updateable: boolean; /** Why, in words a UI can show without restating the rule. */ reason: 'untagged' | 'new-release' | 'at-latest' | 'not-a-checkout'; }; const run = async (dir: string, args: string[]): Promise => { const proc = Bun.spawn(['git', '-C', dir, ...args], { stdout: 'pipe', stderr: 'ignore' }); const out = await new Response(proc.stdout).text(); return (await proc.exited) === 0 ? out.trim() || null : null; }; /** * @param publishedTag The tag the marketplace says is this plugin's newest release, or null when the * catalogue has nothing to say — an unregistered plugin, one never tagged, or a store that could not * be reached. Null falls through to the git rule rather than to a guess. */ export async function releaseState(dir: string, publishedTag: string | null = null): Promise { const none = { tagAtHead: null, newestTag: null }; if (!existsSync(join(dir, '.git'))) { // Copied in by hand or bundled. Nothing to compare, and nothing to pull either — `pullPlugin` says // the same thing when asked. return { ...none, updateable: false, reason: 'not-a-checkout' }; } // `--exact-match`, so this is "the tag AT this commit", not "the last tag before it". Fails, and // therefore returns null, when HEAD is between releases — which is the first half of the rule. const tagAtHead = await run(dir, ['describe', '--exact-match', '--tags', 'HEAD']); if (publishedTag) { // Reported even when HEAD is untagged, so the UI can say what pressing Update lands on. The old // code returned a bare null here and the button read "Update" with nothing after it. if (!tagAtHead) return { tagAtHead: null, newestTag: publishedTag, updateable: true, reason: 'untagged' }; return publishedTag === tagAtHead ? { tagAtHead, newestTag: publishedTag, updateable: false, reason: 'at-latest' } : { tagAtHead, newestTag: publishedTag, updateable: true, reason: 'new-release' }; } if (!tagAtHead) return { ...none, updateable: true, reason: 'untagged' }; // Asked of the REMOTE branch, not of HEAD: a tag pushed after this checkout was made is not reachable // from HEAD and would be invisible if we described HEAD instead. const branch = (await run(dir, ['rev-parse', '--abbrev-ref', 'HEAD'])) ?? 'master'; const newestTag = (await run(dir, ['describe', '--tags', '--abbrev=0', `origin/${branch}`])) ?? (await run(dir, ['describe', '--tags', '--abbrev=0', 'origin/HEAD'])); // No remote tags known yet — a checkout whose origin has never been fetched for tags. Not updateable // rather than "always updateable": claiming an update exists on no evidence is the worse error. if (!newestTag) return { tagAtHead, newestTag: null, updateable: false, reason: 'at-latest' }; return newestTag === tagAtHead ? { tagAtHead, newestTag, updateable: false, reason: 'at-latest' } : { tagAtHead, newestTag, updateable: true, reason: 'new-release' }; }