capabilities/ is the file-based item store, and most of it belongs to the owner: the six item directories are created by the platform at boot and extensions/tool-loader is written from platform source, so neither is tracked anywhere. What IS tracked is the small set the platform ships — today one affordance, telling an agent that markdown images render and that the USER'S DEVICE fetches the URL, which it cannot discover from the filesystem. That now lives in officerdev/capabilities, public so a fresh install can clone it without credentials. Two ways in. The setup script syncs it during the layout step — non-fatal, and it says what an install without it costs rather than failing: agents go back to guessing, which is what they did before this existed. And "Update all" on the marketplace screen carries it alongside the plugins, because it is the same question — get me what has been published since I installed — and a second different gesture for it is one nobody remembers. Three states in both places, and the third is the normal one on an existing machine: a checkout is pulled, an empty directory is cloned into, and a directory that already holds the platform's generated items gets a repository initialised around them. UNTRACKED files never block a pull, because the owner's own skills and tools living here is the ordinary state — only tracked changes do. `cloned` and `adopted` are reported in the UI where `pulled` is not: they mean this machine had no affordances at all until the button was pressed. Verified against the real checkout: sync reports pulled, and the loader finds officer/core/image-rendering afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
180 lines
8.4 KiB
TypeScript
180 lines
8.4 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 } 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}`);
|
|
|
|
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<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] };
|
|
}
|
|
|
|
// ── 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)` };
|
|
}
|
|
}
|
|
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) };
|
|
}
|
|
}
|