Files
platform/src/servers/plugins/update.ts
T
pastilhasandClaude Opus 5 16dd67a3ca plugins: enabling pulls first, so a plugin off for a week comes back current
A disabled plugin keeps its directory, and the directory is what the next start runs. The longer it
stays off the more certainly it is stale, and enabling would quietly run whatever was current on the
day it was switched off — with every step reporting success, which is exactly how the jellyfin
install on alpha re-applied a two-day-old checkout.

Deleting no longer has that problem, since it takes the directory with it and installing is always a
fresh clone. That left disable/enable as the one remaining way to run old code without being told.

The pull is best-effort and the enable is not. `updatePlugin` returns ok:false when a pull is
refused, and it is refused for reasons that say nothing about whether the plugin can run: a dirty
working tree — every plugin directory on this machine is one — an unreachable forge, or a directory
copied in rather than cloned. Enable 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 one running last week's code. So the
refresh reports which of those happened and the enable proceeds regardless; a re-apply that fails
falls back to the code already on disk, which was running before it was disabled.

Tested by the refusal path rather than the happy one: a directory that is not a checkout comes back
having reported "not refreshed", and the error that surfaces is about not being installed rather than
about git — which is only possible if the refusal was stepped past instead of returned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:34:38 +00:00

256 lines
12 KiB
TypeScript

import { existsSync, readdirSync } from 'node:fs';
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.
const fetched = await git(dir, ['fetch', 'origin', 'HEAD']);
if (fetched.code !== 0) {
return { ok: false, updated: false, before, after: before, error: fetched.err || 'git fetch failed' };
}
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[] = [];
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) };
}
}