diff --git a/src/servers/api/plugins/router.ts b/src/servers/api/plugins/router.ts index 3f47a0f0..185c9425 100644 --- a/src/servers/api/plugins/router.ts +++ b/src/servers/api/plugins/router.ts @@ -15,6 +15,7 @@ import { uninstallPlugin, type PluginActionResult, } from '../../plugins/install'; +import { updatePlugin } from '../../plugins/update'; // /api/plugins — what is on this machine, what is installed, and the four verbs that change it. // @@ -176,6 +177,31 @@ pluginsRouter.get('/', async (ctx) => { * "installed" and "installed but the sidecar would not start" are different outcomes and the second is * the one worth reading. See `plugins/install.ts` for why the order inside each is what it is. */ +/** + * Update every installed plugin, in sequence. + * + * SEQUENTIAL, not parallel: each update runs a schema push, republishes assets, rewrites the ecosystem + * file and rebuilds the frontend. Two of those doing it at once is a race over one file each. + * + * One plugin failing does NOT stop the rest — a dirty checkout is the expected failure here (every + * plugin directory on a developer's machine is a live one), and it should not prevent the other eleven + * from moving. The per-plugin result says which, and why. + */ +pluginsRouter.post('/update-all', async (ctx) => { + const userId = ctx.get('user').id; + const { states } = await snapshotPlugins(); + const installed = states.filter((st) => st.install).map((st) => st.plugin.appName); + const results: PluginActionResult[] = []; + for (const appName of installed) { + results.push(await updatePlugin(appName, undefined, userId)); + } + return ctx.json({ + results, + updated: results.filter((r) => r.ok).length, + failed: results.filter((r) => !r.ok).map((r) => ({ appName: r.appName, error: r.error })), + }); +}); + pluginsRouter.post('/:appName/install', async (ctx) => { const result = await installPlugin(ctx.req.param('appName'), undefined, ctx.get('user').id); return ctx.json(result, result.ok ? 200 : 400); @@ -228,6 +254,9 @@ const VERBS = { enable: (appName, onStep) => setPluginRunning(appName, true, onStep), disable: (appName, onStep) => setPluginRunning(appName, false, onStep), purge: (appName, onStep, _userId, onLog) => purgePlugin(appName, onStep, onLog), + // A pull and then the ordinary install path — see plugins/update.ts. It is a verb rather than its own + // route so it inherits the streaming door and the UI that already drives install. + update: (appName, onStep, userId, onLog) => updatePlugin(appName, onStep, userId, onLog), // `satisfies` rather than an annotation: an annotation would widen the keys to `string`, which turns // `isVerb` into a guard that proves nothing and lets an unknown verb index the map. } satisfies Record; diff --git a/src/servers/plugins/update.test.ts b/src/servers/plugins/update.test.ts new file mode 100644 index 00000000..4bd9e265 --- /dev/null +++ b/src/servers/plugins/update.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test, afterAll } from 'bun:test'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { PLUGINS_DIR } from './discover'; +import { pullPlugin } from './update'; + +// The REFUSALS are what this test is for. A pull that works is easy to believe; a pull that quietly +// discarded somebody's uncommitted work is the failure that matters, and every plugin directory on a +// developer's machine is a live checkout — this repository's own plugins are edited in place. + +const tmp = (name: string) => join(PLUGINS_DIR, name); +const made: string[] = []; +const scratch = (name: string) => { + const dir = tmp(name); + mkdirSync(dir, { recursive: true }); + made.push(dir); + return dir; +}; + +afterAll(() => made.forEach((d) => rmSync(d, { recursive: true, force: true }))); + +describe('pullPlugin refuses rather than guesses', () => { + test('a plugin that is not on this machine', async () => { + const r = await pullPlugin('definitely-not-installed-xyz'); + expect(r.ok).toBe(false); + expect(r.error).toContain('not on this machine'); + }); + + test('a directory that is not a git checkout — copied in by hand, or bundled', async () => { + scratch('test-update-nogit'); + const r = await pullPlugin('test-update-nogit'); + expect(r.ok).toBe(false); + expect(r.error).toContain('not a git checkout'); + // Not phrased as a git error: there is nothing wrong here, there is simply nothing to pull. + expect(r.updated).toBe(false); + }); + + test('a checkout with uncommitted work — the one that would destroy something', async () => { + const dir = scratch('test-update-dirty'); + Bun.spawnSync(['git', 'init', '-q'], { cwd: dir }); + writeFileSync(join(dir, 'manifest.ts'), 'export const manifest = {};\n'); + const r = await pullPlugin('test-update-dirty'); + expect(r.ok).toBe(false); + expect(r.error).toContain('uncommitted'); + expect(r.updated).toBe(false); + }); + + test('a refusal never reports having updated anything', async () => { + for (const name of ['definitely-not-installed-xyz', 'test-update-nogit', 'test-update-dirty']) { + const r = await pullPlugin(name); + expect(r.ok).toBe(false); + expect(r.updated).toBe(false); + } + }); +}); diff --git a/src/servers/plugins/update.ts b/src/servers/plugins/update.ts new file mode 100644 index 00000000..81e8c7fe --- /dev/null +++ b/src/servers/plugins/update.ts @@ -0,0 +1,111 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { PLUGINS_DIR } from './discover'; +import { installPlugin } 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}`); + + const pulled = await git(dir, ['pull', '--ff-only', 'origin', branch]); + 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[] = []; + 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] }; +} diff --git a/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx b/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx index a63b4f41..78cddd4e 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx +++ b/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx @@ -460,11 +460,14 @@ export const PluginDetail = () => { Enable )} - {plugin.outdated ? ( - - ) : null} + {/* Always offered, not gated on `outdated`. That flag compares the manifest's version to the + recorded one, and nothing bumps a version today — so an artwork or code change lands with + the number unmoved and the button would never appear. It also used to run `install`, + which cannot fetch: the directory exists, so the clone is skipped and only the apply + steps re-ran. `update` pulls first. */} + diff --git a/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx b/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx index 1aa86de0..489e4559 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx +++ b/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx @@ -17,7 +17,7 @@ const Status = ({ plugin }: { plugin: PluginItem }) => { }; export const PluginsList = () => { - const { plugins, available, broken, orphans, isLoading } = usePlugins(); + const { plugins, available, broken, orphans, isLoading, updateAll, updatingAll, updateAllResult } = usePlugins(); const [params] = useSearchParams(); const selected = params.get('selected'); @@ -25,6 +25,42 @@ export const PluginsList = () => { return (
+ {/* Update-all lives on the LIST, not in a plugin's detail: it is the one action here that is about + all of them at once. Hidden when nothing is installed, because "update" of nothing reads as a + broken button rather than a no-op. + + There is no versioning yet, so this cannot know which plugins have anything to pull — it pulls + each and re-applies. Saying "Update all" rather than showing a count is the honest label for + that: a count would imply a check that has not happened. */} + {plugins.length ? ( +
+ Installed + +
+ ) : null} + {updateAllResult ? ( +
+ + {updateAllResult.updated} updated + {updateAllResult.failed.length ? `, ${updateAllResult.failed.length} failed` : ''} + + {/* Named, with the reason. The expected failure is a dirty checkout, and "2 failed" without + saying which is a message that sends someone to the terminal to find out. */} + {updateAllResult.failed.map((f) => ( +
+ {f.appName}: {f.error} +
+ ))} +
+ ) : null} + {plugins.length === 0 && available.length === 0 && broken.length === 0 ? (
No plugins in plugins/ yet. diff --git a/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts b/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts index 4eb2d31b..84501467 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts +++ b/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts @@ -75,7 +75,14 @@ export type PluginItem = { /** What a verb actually did, in order. Shown rather than collapsed to a spinner. */ export type PluginActionResult = { ok: boolean; appName: string; steps: string[]; error?: string }; -export type PluginVerb = 'install' | 'uninstall' | 'enable' | 'disable' | 'purge'; +/** What `POST /plugins/update-all` answers with. Per-plugin, because one failing must not hide the rest. */ +export type UpdateAllResult = { + results: PluginActionResult[]; + updated: number; + failed: { appName: string; error?: string }[]; +}; + +export type PluginVerb = 'install' | 'uninstall' | 'enable' | 'disable' | 'purge' | 'update'; /** * One line in the action log. @@ -232,6 +239,27 @@ export function usePlugins() { [invalidate], ); + // Update-all is a PLAIN POST, not the streaming door the single verbs use. Twelve interleaved step + // streams is not a readable log, and the useful answer here is the summary: how many moved, and which + // refused. A dirty checkout is the expected refusal — every plugin directory on a developer's machine + // is a live one — and it must not stop the rest, which is why the server runs them in sequence and + // collects failures rather than aborting. + const [updatingAll, setUpdatingAll] = useState(false); + const [updateAllResult, setUpdateAllResult] = useState(null); + + const updateAll = useCallback(async () => { + setUpdatingAll(true); + setUpdateAllResult(null); + try { + const res = await client.post('/plugins/update-all', {}); + setUpdateAllResult(res); + invalidate(); + return res; + } finally { + setUpdatingAll(false); + } + }, [invalidate]); + return { plugins: data?.plugins ?? [], /** Offered by the marketplace, absent from this machine. Empty when the catalogue cannot be reached. */ @@ -247,5 +275,8 @@ export function usePlugins() { /** Which verb is in flight, or null. */ running, run, + updateAll, + updatingAll, + updateAllResult, }; }