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 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] }; } // ── 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 { 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)` }; } } const pulled = await git(dir, ['pull', '--ff-only', 'origin', 'main']); 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', 'main'], ['checkout', '--quiet', '-B', 'main', '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) }; } }