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
296 lines
15 KiB
TypeScript
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) };
|
|
}
|
|
}
|