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>
This commit is contained in:
2026-08-20 21:34:38 +00:00
co-authored by Claude Opus 5
parent 287cec39d0
commit 16dd67a3ca
4 changed files with 91 additions and 6 deletions
+60 -1
View File
@@ -2,7 +2,7 @@ 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 } from './install';
import { installPlugin, setPluginRunning } from './install';
import type { OnStep, PluginActionResult } from './install';
import type { OnLog } from './run-logged';
@@ -124,6 +124,65 @@ export async function updatePlugin(
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