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>
333 lines
15 KiB
TypeScript
333 lines
15 KiB
TypeScript
import { createRouter } from '../../create-router';
|
|
import * as errors from '../../custom-errors';
|
|
import { isSuperAdmin } from '../../super-admin';
|
|
import { mountPrefix } from '../../plugins/manifest';
|
|
import { snapshotPlugins } from '../../plugins/mount';
|
|
import { manualInstallHint, reportDependencies } from '../../plugins/os-deps';
|
|
import { fetchCatalogue, fetchReadme } from '../../plugins/marketplace';
|
|
import { findOrphanTables } from '../../plugins/orphans';
|
|
import { iconUrl } from '../../app-store/assets';
|
|
import {
|
|
installPlugin,
|
|
pluginProcessStatus,
|
|
setPluginRunning,
|
|
purgePlugin,
|
|
uninstallPlugin,
|
|
type PluginActionResult,
|
|
} from '../../plugins/install';
|
|
import { updatePlugin, syncCapabilities } from '../../plugins/update';
|
|
|
|
// /api/plugins — what is on this machine, what is installed, and the four verbs that change it.
|
|
//
|
|
// Owner only, in its own right. Installing a plugin mounts routes and (later) starts a process, which is
|
|
// an administrative act however many members share the server. The permission layer covers it too; this
|
|
// is the belt to that braces, the same shape `/api/app-store` uses.
|
|
//
|
|
// ── This is not the app store ──
|
|
//
|
|
// The app store installs SIDECARS from a compiled-in catalogue, provisioning containers and asking the
|
|
// user questions. This installs PLUGINS from the tree, and asks nothing: put the code there, push the
|
|
// schema, mount the routes. The two coexist until the app store is rebuilt on this.
|
|
|
|
export const pluginsRouter = createRouter();
|
|
|
|
pluginsRouter.use(async (ctx, next) => {
|
|
if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('Plugins are owner-only');
|
|
return next();
|
|
});
|
|
|
|
/**
|
|
* GET /api/plugins — every plugin in the tree, with what the database knows about each.
|
|
*
|
|
* Reports `broken` alongside rather than failing: a directory with an unreadable manifest is something to
|
|
* show the owner, and refusing the whole list because one plugin is malformed would hide the nine that
|
|
* are fine.
|
|
*/
|
|
// GET /plugins/catalogue/:appName/readme — the store's own description of a plugin, for the detail
|
|
// pane of something not installed yet.
|
|
//
|
|
// Under `/plugins` so the existing `plugins` permission covers it: reading the store is the same
|
|
// privilege as installing from it, and a second key would be a second thing to grant for no separate
|
|
// decision.
|
|
pluginsRouter.get('/catalogue/:appName/readme', async (ctx) => {
|
|
const readme = await fetchReadme(ctx.req.param('appName'));
|
|
return ctx.json({ readme });
|
|
});
|
|
|
|
pluginsRouter.get('/', async (ctx) => {
|
|
const { states, broken } = await snapshotPlugins();
|
|
// Asked per plugin rather than once, because `pm2 jlist` is a fork and most plugins have no sidecar to
|
|
// ask about. A plugin that is installed and enabled but whose process is not online is the state worth
|
|
// rendering differently — it is the difference between "off" and "broken".
|
|
const statuses = await Promise.all(states.map(({ plugin }) => pluginProcessStatus(plugin)));
|
|
// Synchronous and cheap — `Bun.which` per declared binary, and most plugins declare none. The one
|
|
// genuinely slow call in here is the `sudo -n` probe, which only runs when something is missing.
|
|
const dependencyReports = states.map(({ plugin }) => {
|
|
const report = reportDependencies(plugin);
|
|
return {
|
|
manager: report.manager,
|
|
items: report.dependencies,
|
|
satisfied: report.missing.length === 0,
|
|
canInstall: report.canInstall,
|
|
blockedReason: report.blockedReason,
|
|
packagesToInstall: report.packagesToInstall,
|
|
manualHint: manualInstallHint(report.manager, report.packagesToInstall),
|
|
};
|
|
});
|
|
// What the marketplace offers that this machine does not have.
|
|
//
|
|
// Merged here rather than in `snapshotPlugins`, which answers "what is on disk joined to what is
|
|
// installed" and should keep answering exactly that — the catalogue is a third, remote source and
|
|
// giving discovery a network dependency would make the plugins page fail when the store is down.
|
|
//
|
|
// A catalogue entry whose plugin is already on disk is DROPPED: the directory is the truth once it
|
|
// exists, and showing both would offer to install something already installed.
|
|
const onDisk = new Set(states.map((s) => s.plugin.appName));
|
|
const catalogue = (await fetchCatalogue().catch(() => [])).filter((e) => !onDisk.has(e.appName));
|
|
|
|
// Tables nothing on this machine claims. Cheap — one pg_class query and one import per plugin — and
|
|
// computed here rather than behind its own route so the screen cannot show a plugin list without it.
|
|
// Never fatal: a database that will not answer this must not take the plugins page down with it.
|
|
const orphans = await findOrphanTables(catalogue.map((e) => e.appName)).catch((err) => {
|
|
console.error('[plugins] orphan scan failed —', err instanceof Error ? err.message : err);
|
|
return [];
|
|
});
|
|
|
|
return ctx.json({
|
|
orphans,
|
|
available: catalogue.map((e) => ({
|
|
appName: e.appName,
|
|
prefix: e.publisher === 'officerdev' ? `/${e.appName}` : `/p/${e.publisher}/${e.appName}`,
|
|
label: e.label,
|
|
summary: e.summary,
|
|
image: e.iconUrl,
|
|
// Same precedence the installed list uses: artwork wins, the glyph name is the fallback.
|
|
...(e.iconUrl ? {} : { icon: e.icon }),
|
|
tile: e.tile ?? 'badge',
|
|
color: e.color,
|
|
publisher: e.publisher,
|
|
version: e.version,
|
|
platform: e.platform,
|
|
permissions: e.permissions,
|
|
has: e.has,
|
|
source: e.source,
|
|
// The same live PATH probe a local plugin gets, so ffmpeg/ffprobe are visible BEFORE the clone
|
|
// rather than after. `reportDependencies` takes a DiscoveredPlugin, so the entry is adapted to
|
|
// that shape — the only field it reads is `manifest.osDependencies`.
|
|
dependencies: (() => {
|
|
const r = reportDependencies({ manifest: { osDependencies: e.osDependencies } } as never);
|
|
return {
|
|
manager: r.manager,
|
|
items: r.dependencies,
|
|
satisfied: r.missing.length === 0,
|
|
canInstall: r.canInstall,
|
|
blockedReason: r.blockedReason,
|
|
packagesToInstall: r.packagesToInstall,
|
|
manualHint: manualInstallHint(r.manager, r.packagesToInstall),
|
|
};
|
|
})(),
|
|
})),
|
|
plugins: states.map(({ plugin, install, outdated }, i) => ({
|
|
appName: plugin.appName,
|
|
prefix: mountPrefix(plugin),
|
|
label: plugin.manifest.label,
|
|
summary: plugin.manifest.summary,
|
|
icon: plugin.manifest.icon,
|
|
// The plugin's own artwork, once published. Only while INSTALLED: `publishAssets` copies
|
|
// `assets/` into `public/plugins/<appName>/` at install and `unpublishAssets` removes it, so a
|
|
// plugin that has never been installed has nothing at that URL and an <img> would 404. The list
|
|
// falls back to a generic glyph, which is what it drew for everything until now.
|
|
//
|
|
// `[open]` That means you cannot see a plugin's icon BEFORE installing it, which is the one place
|
|
// an app store most wants to. Fixing it means either publishing on discovery rather than install —
|
|
// which breaks "assets are a property of the install" — or an authenticated icon route, which an
|
|
// <img> cannot use because it sends no Authorization header.
|
|
...(plugin.icon && install ? { image: iconUrl(plugin.appName) } : {}),
|
|
color: plugin.manifest.color,
|
|
publisher: plugin.manifest.publisher,
|
|
version: plugin.manifest.version,
|
|
platform: plugin.manifest.platform,
|
|
permissions: plugin.manifest.permissions,
|
|
// What the tree declared. The UI shows these so "installed but does nothing" is legible.
|
|
has: {
|
|
api: !!plugin.api,
|
|
schema: !!plugin.schema,
|
|
sidecar: !!plugin.sidecar,
|
|
web: !!plugin.web,
|
|
},
|
|
installed: !!install,
|
|
enabled: install?.enabled ?? false,
|
|
installedVersion: install?.version ?? null,
|
|
outdated,
|
|
processStatus: statuses[i] ?? null,
|
|
// Probed live, against this machine's PATH, on every read.
|
|
//
|
|
// Deliberately here rather than only inside the installer: the owner decides whether to let the
|
|
// server run `apt-get install` as root, and that decision needs the answer BEFORE the button, not
|
|
// in the log afterwards. It also lets them install the packages by hand first and watch this flip
|
|
// to present, which is the escape hatch for a machine with no passwordless sudo.
|
|
dependencies: dependencyReports[i]!,
|
|
})),
|
|
broken,
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The four verbs. Each returns the ordered list of what actually happened, rather than a bare `ok` —
|
|
* "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));
|
|
}
|
|
|
|
// The capabilities repository rides along. It is the same question the button asks — "get me what has
|
|
// been published since I installed" — and a second, different gesture for it is one nobody remembers.
|
|
// On a machine installed before affordances existed there is no checkout at all, so this is also how it
|
|
// first arrives.
|
|
const capabilities = await syncCapabilities();
|
|
|
|
return ctx.json({
|
|
results,
|
|
capabilities,
|
|
updated: results.filter((r) => r.ok).length,
|
|
failed: [
|
|
...results.filter((r) => !r.ok).map((r) => ({ appName: r.appName, error: r.error })),
|
|
...(capabilities.ok ? [] : [{ appName: 'capabilities', error: capabilities.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);
|
|
});
|
|
|
|
// Destructive, and the only route here that is. Named `purge` rather than being a flag on uninstall so
|
|
// nothing can reach it by passing a body field to the verb people already use.
|
|
pluginsRouter.post('/:appName/purge', async (ctx) => {
|
|
return ctx.json(await purgePlugin(ctx.req.param('appName')));
|
|
});
|
|
|
|
pluginsRouter.post('/:appName/uninstall', async (ctx) => {
|
|
const result = await uninstallPlugin(ctx.req.param('appName'));
|
|
return ctx.json(result, result.ok ? 200 : 400);
|
|
});
|
|
|
|
pluginsRouter.post('/:appName/enable', async (ctx) => {
|
|
const result = await setPluginRunning(ctx.req.param('appName'), true);
|
|
return ctx.json(result, result.ok ? 200 : 400);
|
|
});
|
|
|
|
pluginsRouter.post('/:appName/disable', async (ctx) => {
|
|
const result = await setPluginRunning(ctx.req.param('appName'), false);
|
|
return ctx.json(result, result.ok ? 200 : 400);
|
|
});
|
|
|
|
// ── The same four verbs, streamed ──
|
|
//
|
|
// An install writes an ecosystem entry, starts a process and rebuilds the router. Collecting all of that
|
|
// and answering once means a spinner that stops, with no way to tell "started the sidecar" from "could
|
|
// not". Streaming each step as it completes turns the same work into something you can watch, and — more
|
|
// usefully — leaves the log on screen naming the step that failed.
|
|
//
|
|
// POST rather than GET, so `EventSource` cannot be used: it sends no `Authorization` header, and these
|
|
// routes are owner-only. The client reads the body and parses frames itself, which is exactly what
|
|
// a plugin's own log-stream hooks already do.
|
|
|
|
// `userId` is the caller, threaded through so install can pin the new tile to THEIR dock rather than
|
|
// guessing at the owner. Only install uses it; the others take it and ignore it so the map stays one shape.
|
|
type VerbFn = (
|
|
appName: string,
|
|
onStep: (s: string) => Promise<void>,
|
|
userId: number,
|
|
onLog: (line: string) => Promise<void>,
|
|
) => Promise<PluginActionResult>;
|
|
|
|
const VERBS = {
|
|
install: (appName, onStep, userId, onLog) => installPlugin(appName, onStep, userId, onLog),
|
|
uninstall: (appName, onStep, _userId, onLog) => uninstallPlugin(appName, onStep, onLog),
|
|
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<string, VerbFn>;
|
|
|
|
type Verb = keyof typeof VERBS;
|
|
const isVerb = (v: string): v is Verb => v in VERBS;
|
|
|
|
pluginsRouter.post('/:appName/:verb/stream', async (ctx) => {
|
|
const appName = ctx.req.param('appName');
|
|
const verb = ctx.req.param('verb');
|
|
if (!isVerb(verb)) throw errors.NOT_FOUND(`Unknown action: ${verb}`);
|
|
// Read before the stream opens: once we are inside ReadableStream.start the request context is gone.
|
|
const userId = ctx.get('user').id;
|
|
|
|
const encoder = new TextEncoder();
|
|
const frame = (event: string, data: unknown) => encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
|
|
const stream = new ReadableStream({
|
|
async start(controller) {
|
|
const send = (event: string, data: unknown) => {
|
|
// The client may have navigated away mid-install. The work continues — it is the server's job
|
|
// now — but writing to a closed stream would throw and abort it halfway.
|
|
try {
|
|
controller.enqueue(frame(event, data));
|
|
} catch {
|
|
/* client gone */
|
|
}
|
|
};
|
|
|
|
let result: PluginActionResult;
|
|
try {
|
|
result = await VERBS[verb](
|
|
appName,
|
|
async (step) => send('step', { step }),
|
|
userId,
|
|
// Raw output from whatever the step is running — `git clone`, `db:push`, a package manager.
|
|
// A separate event so the client can render it as what it is: the command talking, not the
|
|
// platform reporting.
|
|
async (line) => send('log', { line }),
|
|
);
|
|
} catch (err) {
|
|
result = { ok: false, appName, steps: [], error: err instanceof Error ? err.message : String(err) };
|
|
}
|
|
send('done', result);
|
|
try {
|
|
controller.close();
|
|
} catch {
|
|
/* already closed */
|
|
}
|
|
},
|
|
});
|
|
|
|
return new Response(stream, {
|
|
headers: {
|
|
'content-type': 'text/event-stream',
|
|
'cache-control': 'no-cache',
|
|
// Through nginx as well as our own proxy chain: without it a buffering hop holds every frame until
|
|
// the response ends, which is precisely the behaviour this endpoint exists to avoid.
|
|
'x-accel-buffering': 'no',
|
|
},
|
|
});
|
|
});
|