Files
platform/src/servers/plugins/update.ts
T
pastilhasandClaude Opus 5 68f3643df7 a pull leaves origin/master where it found it, so every checkout looked ahead
plugins:check reported "2 commit(s) NOT PUSHED" for all twenty-one installed
plugins on a machine where nothing had been committed. the check was right about
what it measured and the cause was here.

`git fetch origin HEAD` writes FETCH_HEAD and nothing else. refs/remotes/origin/
<branch> keeps whatever it had, so after a successful pull the checkout sits 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 by however many
commits it just pulled.

so the remote-tracking ref is refreshed too, explicitly, after the fetch that
cannot do it. best effort, because the local branch may not exist on the remote
under that name — which is the same reason the fetch asks for HEAD rather than
for a branch.

fetch also asks for --tags now. tags are how releaseState decides whether there
is anything to update to, so a fetch that skipped them left the Update button
answering from refs older than the release it was offering.

verified by staling a ref on purpose: one false positive before, none after a
single pullPlugin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 08:46:18 +00:00

296 lines
15 KiB
TypeScript

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<PullResult> {
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 <local branch name>`.
//
// 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/<branch>` 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<PluginActionResult> {
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<PluginActionResult> {
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<CapabilitiesResult> {
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) };
}
}