import { existsSync, readdirSync } from 'node:fs'; import { releaseState } from './release'; import { join } from 'node:path'; import { PLUGINS_DIR } from './discover'; import { OFFICER_ITEMS_DIR, PLATFORM_DIR } from '../data-path'; import { installPlugin, setPluginRunning } from './install'; import type { OnStep, PluginActionResult } from './install'; import type { OnLog } from './run-logged'; // Pulling a plugin's repository and re-applying it. // // ── Why this did not exist ── // // `fetchPlugin` REFUSES a directory that already exists, deliberately: cloning over the top of one is // how somebody's uncommitted work disappears. That made install a one-way door — a plugin could be // installed and removed but never moved forward, and the answer to "my plugin changed" was to uninstall // and reinstall, which drops its tables on the way past. // // ── What an update actually is ── // // A `git pull` and then the ORDINARY install path. `installPlugin` already skips the clone when the // directory is present and re-runs everything else — os dependencies, schema push, assets, the // ecosystem entry, the sidecar, the remount and the frontend rebuild. So there is no second // implementation of "apply a plugin" here to drift from the first one; there is a pull, and then that. // // ── The two refusals ── // // A DIRTY working tree is refused rather than stashed or force-pulled. Every plugin directory on a // developer's machine is a live checkout — this repository's own plugins are edited in place — and // silently discarding that is a worse outcome than an update that did not happen. // // Non-fast-forward is refused for the same reason: a local commit that diverged is somebody's work, and // merging it automatically would be a decision the platform is not entitled to make. export type PullResult = { ok: boolean; /** False when the pull succeeded and there was simply nothing new — the common case. */ updated: boolean; before: string | null; after: string | null; error?: string; }; const git = async (dir: string, args: string[]): Promise<{ code: number; out: string; err: string }> => { const proc = Bun.spawn(['git', ...args], { cwd: dir, stdout: 'pipe', stderr: 'pipe' }); const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); return { code: await proc.exited, out: out.trim(), err: err.trim() }; }; /** Fast-forward a plugin's checkout, refusing anything that would touch local work. */ export async function pullPlugin(appName: string, onLog?: OnLog): Promise { const dir = join(PLUGINS_DIR, appName); const none = { updated: false, before: null, after: null }; if (!existsSync(dir)) return { ok: false, ...none, error: `"${appName}" is not on this machine` }; if (!existsSync(join(dir, '.git'))) { // A plugin can legitimately live here without a repository — copied in by hand, or bundled. Nothing // to pull, and saying so is more useful than an error about git. return { ok: false, ...none, error: `"${appName}" is not a git checkout — nothing to pull` }; } if (!Bun.which('git')) return { ok: false, ...none, error: 'git is not installed on this machine' }; const dirty = await git(dir, ['status', '--porcelain']); if (dirty.out) { const files = dirty.out.split('\n').length; return { ok: false, ...none, error: `"${appName}" has ${files} uncommitted change(s) — commit or discard them first, refusing to overwrite local work`, }; } const before = (await git(dir, ['rev-parse', 'HEAD'])).out; const branch = (await git(dir, ['rev-parse', '--abbrev-ref', 'HEAD'])).out; onLog?.(`[${appName}] pulling ${branch}`); // FETCH `HEAD`, THEN FAST-FORWARD. Not `pull origin `. // // The local branch and the remote's default are two different things, and they stopped agreeing the // day the plugin repositories were standardised: a checkout made before that is on `main`, the remote // has only `master`, and `git pull origin main` fails with "couldn't find remote ref main" — an Update // button that can never work again on exactly the installs that most need it. // // `HEAD` resolves to whatever the remote's default is, so the local name stops mattering. The // fast-forward then applies it to whichever branch this checkout happens to be on. // // `--tags` and `--update-head-ok` are not decoration. Tags are how `releaseState` decides whether there // is anything to update to, so a fetch that skipped them would leave the Update button answering from // refs older than the release it is offering. const fetched = await git(dir, ['fetch', '--tags', 'origin', 'HEAD']); if (fetched.code !== 0) { return { ok: false, updated: false, before, after: before, error: fetched.err || 'git fetch failed' }; } /* ALSO refresh the remote-tracking ref, which fetching `HEAD` does not. `git fetch origin HEAD` writes FETCH_HEAD and nothing else — `refs/remotes/origin/` keeps whatever it had. So after a successful pull the checkout is at the newest commit while `origin/master` still points at an older one, and every `rev-list origin/master...HEAD` reports the checkout as AHEAD. That is exactly what `plugins:check` reported on 2026-08-27: twenty-one installed plugins each with "2 commit(s) NOT PUSHED", on a machine where nothing had been committed at all. A check that cries wolf is one people stop reading, and the cause was here rather than there. Best effort: the branch may not exist on the remote under this name, which is the same reason the fetch above asks for `HEAD`. A failure leaves things exactly as they were before this line. */ const branchRef = branch && branch !== 'HEAD' ? branch : null; if (branchRef) await git(dir, ['fetch', 'origin', `+refs/heads/${branchRef}:refs/remotes/origin/${branchRef}`]); const pulled = await git(dir, ['merge', '--ff-only', 'FETCH_HEAD']); if (pulled.code !== 0) { return { ok: false, updated: false, before, after: before, error: pulled.err || pulled.out || 'git pull failed' }; } const after = (await git(dir, ['rev-parse', 'HEAD'])).out; if (pulled.out) onLog?.(`[${appName}] ${pulled.out.split('\n').slice(-1)[0]}`); return { ok: true, updated: before !== after, before, after }; } /** * Pull, then re-apply through the ordinary install path. * * A pull that brought nothing new still RE-APPLIES. That is deliberate: the reason to press update is * usually that something on disk is not reflected in the running server — assets that were pulled by * hand, a sidecar that needs restarting — and "already up to date, did nothing" would be the least * useful possible answer to that. */ export async function updatePlugin( appName: string, onStep?: OnStep, userId?: number, onLog?: OnLog, ): Promise { const steps: string[] = []; /* Refuse when there is nothing to update to. The UI already hides the button, and that is not enough: a plugins list is fetched once and a tag can be pushed a second later, so the copy someone clicks is routinely older than the truth. More to the point, `updateAll` iterates a list rather than asking per plugin — without this it would re-install twenty-one plugins to land on the versions they were already on. Checked BEFORE the pull, because a pull is what would make the answer stale. */ const state = await releaseState(join(PLUGINS_DIR, appName)); if (!state.updateable && state.reason === 'at-latest') { steps.push(`already at ${state.tagAtHead}, the newest release`); onStep?.(steps[steps.length - 1]!); return { ok: true, appName, steps }; } const pulled = await pullPlugin(appName, onLog); if (!pulled.ok) return { ok: false, appName, steps, error: pulled.error }; const short = (sha: string | null) => sha?.slice(0, 7) ?? '?'; steps.push( pulled.updated ? `pulled ${short(pulled.before)} → ${short(pulled.after)}` : 'already at the latest commit', ); onStep?.(steps[steps.length - 1]!); const result = await installPlugin(appName, onStep, userId, onLog); return { ...result, steps: [...steps, ...result.steps] }; } /** * Enable a plugin, refreshing it first. * * ── Why enabling pulls ── * * A disabled plugin keeps its directory, and the directory is what the next start actually runs. So the * longer something stays disabled the more certainly it is stale, and turning it back on would quietly * run whatever was current on the day it was switched off. Deleting no longer has that problem — it takes * the directory with it, so installing is always a fresh clone — which leaves disable/enable as the one * remaining way to run old code without being told. * * That is not hypothetical either. It is the same shape as the jellyfin install on alpha, which re-applied * a two-day-old checkout and reported success at every step. * * ── The pull cannot be allowed to fail the enable ── * * `updatePlugin` returns `ok: false` when the pull is refused, and it is refused for reasons that have * nothing to do with whether the plugin can run: a dirty working tree (every plugin directory on a * developer's machine is one), a forge that cannot be reached, a checkout that is not a git repository at * all. Enabling is the reversible half of a pair with disable, and a switch that will not turn back on * because a network is down is worse than a plugin running last week's code. * * So the refresh is best-effort and SAYS which of the three happened, and the enable runs either way. The * re-apply is likewise allowed to fail without taking the enable with it — the code already on disk was * running before it was disabled, so falling back to it is a known-good state rather than a guess. */ export async function enablePlugin( appName: string, onStep?: OnStep, userId?: number, onLog?: OnLog, ): Promise { const steps: string[] = []; const say = async (line: string) => { steps.push(line); await onStep?.(line); }; const pulled = await pullPlugin(appName, onLog); const short = (sha: string | null) => sha?.slice(0, 7) ?? '?'; if (!pulled.ok) { // Not an error here, only a fact worth stating: this is running the code that is on disk. await say(`not refreshed — ${pulled.error}`); } else if (pulled.updated) { await say(`pulled ${short(pulled.before)} → ${short(pulled.after)}`); const applied = await installPlugin(appName, onStep, userId, onLog); steps.push(...applied.steps); if (!applied.ok) await say(`re-apply failed (${applied.error}) — enabling what is on disk instead`); } else { await say('already at the latest commit'); } // Always. `recordPluginInstall` deliberately never touches `enabled`, so the re-apply above cannot have // switched it on as a side effect — which is correct, and means this line is the one that enables. const enabled = await setPluginRunning(appName, true, onStep); return { ...enabled, steps: [...steps, ...enabled.steps] }; } // ── The capabilities repository ── // // `capabilities/` is the file-based item store, and most of what is in it belongs to the owner. A small // part does not: the affordances the platform ships, which tell an agent what the client on the other // end will do. Those live in `officerdev/capabilities` and are cloned by the setup script. // // Updated from the same button as the plugins, because it is the same question — "get me what has been // published since I installed" — and because an owner who has to remember a second, different gesture for // it will not remember it. // // The three states mirror `capabilities_sync` in the setup script: a checkout is pulled, an empty // directory is cloned into, and a directory that already has the platform's generated items in it gets a // repository initialised around them. That last case is the normal one on a machine installed before this // existed. const CAPABILITIES_REPO = 'https://gitea.officer.dev/officerdev/capabilities.git'; export type CapabilitiesResult = { ok: boolean; action: 'pulled' | 'cloned' | 'adopted' | 'failed'; error?: string }; export async function syncCapabilities(onLog?: OnLog): Promise { const dir = OFFICER_ITEMS_DIR; if (!Bun.which('git')) return { ok: false, action: 'failed', error: 'git is not installed on this machine' }; try { if (existsSync(join(dir, '.git'))) { // Refused rather than merged, exactly as a plugin is: whatever diverged here is somebody's work. const dirty = await git(dir, ['status', '--porcelain']); if (dirty.out) { // Untracked files are the ORDINARY state — the owner's own skills and tools live here, and the // platform writes its generated directories in. Only tracked changes block a pull. const tracked = dirty.out.split('\n').filter((l) => !l.startsWith('??')); if (tracked.length) { return { ok: false, action: 'failed', error: `capabilities has ${tracked.length} uncommitted change(s)` }; } } // `HEAD`, not a branch name: it resolves to whatever the remote's default is. Naming `main` here // broke the day the repositories were standardised on `master`, and naming `master` would break // the day anything moves back. Same reasoning as the marketplace icon URLs. const pulled = await git(dir, ['pull', '--ff-only', 'origin', 'HEAD']); if (pulled.code !== 0) return { ok: false, action: 'failed', error: pulled.err || 'git pull failed' }; onLog?.('[capabilities] pulled'); return { ok: true, action: 'pulled' }; } if (!existsSync(dir) || readdirSync(dir).length === 0) { const cloned = await git(PLATFORM_DIR, ['clone', '--quiet', CAPABILITIES_REPO, dir]); if (cloned.code !== 0) return { ok: false, action: 'failed', error: cloned.err || 'git clone failed' }; onLog?.('[capabilities] cloned'); return { ok: true, action: 'cloned' }; } for (const argv of [ ['init', '--quiet'], ['remote', 'add', 'origin', CAPABILITIES_REPO], ['fetch', '--quiet', 'origin', 'HEAD'], // The LOCAL branch still needs a name; the remote's default is whatever FETCH_HEAD just pointed at. ['checkout', '--quiet', '-B', 'master', 'FETCH_HEAD'], ]) { const r = await git(dir, argv); // `remote add` fails when one is already there, which is not a problem worth stopping for. if (r.code !== 0 && argv[0] !== 'remote') { return { ok: false, action: 'failed', error: r.err || `git ${argv[0]} failed` }; } } onLog?.('[capabilities] adopted the existing directory'); return { ok: true, action: 'adopted' }; } catch (err) { return { ok: false, action: 'failed', error: err instanceof Error ? err.message : String(err) }; } }