Files
platform/src/servers/plugins/discover.ts
T
pastilhasandClaude Opus 5 898209725a a plugin owns the routing under its mount
Reverts the manifest route table. Declaring a plugin's URLs to the platform put
the platform in the middle of a decision that is not its own, and it was more
code than what it replaced.

One splat where there were two routes. A splat matches zero segments, so the
bare route still works, and the plugin reads its own position off the location.
The platform owns where a plugin starts and nothing after it — the same line
mountPrefix already draws around its API.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 07:17:33 +01:00

254 lines
11 KiB
TypeScript

import { existsSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { PLATFORM_DIR } from '../data-path';
import {
manifestProblems,
FIRST_PARTY_PUBLISHER,
type DiscoveredPlugin,
type DiscoveredScreen,
type PluginManifest,
} from './manifest';
import { importFresh } from './import-fresh';
// Finding plugins on disk.
//
// They live at `<platform>/plugins/<app-name>/` — INSIDE the repository, not beside it, and that is what
// makes the whole developer story work. Bun links the workspace packages into the root `node_modules`, so
// anything under the repo can `import { useClient } from 'hooks/useClient'` with no publishing, no package
// registry and no version negotiation. A plugin author clones the platform, drops their plugin in, and
// runs it in dev — the WordPress model — and the same tree is what the server builds from.
//
// Verified: `Bun.resolveSync('hooks/useClient', '<platform>/plugins/anything')` resolves.
//
// Discovery is by CONVENTION. Presence is the declaration:
//
// manifest.ts required — everything a directory listing cannot say
// api/router.ts a backend router
// db/schema.ts tables
// sidecar/index.ts a process (`.mjs` instead means node — see below)
// web/panels.ts panel apps — REQUIRED with web/
// web/layout.ts how they are arranged — REQUIRED with web/
// assets/icon.png the dock tile's image, published to public/plugins/<app-name>/ on install
//
// Nothing here reads the database. This answers "what is on disk", which is a different question from
// "what is installed" — the install table answers that, and the two disagreeing is a state the app store
// has to render rather than a bug to prevent.
/** Where plugins live. Inside the repo, so the workspace packages resolve. */
export const PLUGINS_DIR = join(PLATFORM_DIR, 'plugins');
/**
* The runtime is the file extension, not a manifest field.
*
* `sidecar/index.mjs` runs under node, `sidecar/index.ts` under bun. Implicit, but it is the rule this
* repository already follows — `officer-pty` is `pty/index.mjs` under node because node-pty is a native
* module built against Node's ABI, and everything else is bun. Better than a field that can contradict
* the file it describes.
*/
function findSidecar(dir: string): DiscoveredPlugin['sidecar'] {
const ts = join(dir, 'sidecar', 'index.ts');
if (existsSync(ts)) return { script: ts, runtime: 'bun' };
const mjs = join(dir, 'sidecar', 'index.mjs');
if (existsSync(mjs)) return { script: mjs, runtime: 'node' };
return null;
}
/**
* A plugin's frontend, or a reason it is refused.
*
* `web/` present means BOTH `panels.ts` and `layout.ts` must be. That is how "every route renders a
* workspace with at least one panel" is enforced: there is no way to ship a frontend that is not a
* workspace, because the plugin never renders the screen — it hands over panels and an arrangement.
*/
function findWeb(
dir: string,
appName: string,
manifest: PluginManifest | null,
): { web: DiscoveredPlugin['web']; error?: string } {
const webDir = join(dir, 'web');
const declared = manifest?.screens ?? [];
if (!existsSync(webDir)) {
// No `web/` and no claim to one is the ordinary backend-only plugin. No `web/` while DECLARING
// screens is a manifest describing a frontend that is not there — every route it names would 404,
// and the dock would draw tiles pointing at them.
if (!declared.length) return { web: null };
return {
web: null,
error: `${appName}: declares ${declared.length} screen(s) but has no web/ directory.`,
};
}
// The flat form: one screen at the mount prefix. Every plugin but caldav, and the reason adding
// multi-screen support moved none of them.
if (!declared.length) {
const panels = join(webDir, 'panels.ts');
const layout = join(webDir, 'layout.ts');
const missing = [!existsSync(panels) && 'web/panels.ts', !existsSync(layout) && 'web/layout.ts'].filter(Boolean);
if (missing.length) {
return {
web: null,
error:
`${appName}: has a web/ directory but is missing ${missing.join(' and ')}. ` +
`Every plugin route renders a Workspace: contribute panels and a layout, not a screen.`,
};
}
return {
web: {
screens: [
{ segment: null, label: manifest?.label ?? appName, icon: manifest?.icon, art: null, panels, layout },
],
// Optional and unannounced — presence is the declaration, as it is for panels and layout.
fileActions: fileOrNull(join(webDir, 'file-actions.ts')),
},
};
}
// A named segment is a URL the plugin chose rather than one derived from its name, and `mountPrefix`
// is what stops that being a way to shadow a core route — it confines third parties to
// `/p/<publisher>/`, which a chosen segment would step straight out of. So this is first-party only,
// refused here rather than anywhere later: by the time a route exists it is already serving.
if (manifest && manifest.publisher !== FIRST_PARTY_PUBLISHER) {
return {
web: null,
error:
`${appName}: declares screens, which chooses its own URLs, and only a first-party plugin may. ` +
`A third-party plugin mounts under /p/${manifest.publisher}/ so it cannot shadow a core route.`,
};
}
const screens: DiscoveredScreen[] = [];
const problems: string[] = [];
for (const screen of declared) {
// A segment is a URL segment. Anything else — a separator, a traversal, a space — would land the
// route somewhere other than where it reads, so it is refused rather than escaped.
if (!/^[a-z0-9][a-z0-9-]*$/.test(screen.segment)) {
problems.push(`screen segment ${JSON.stringify(screen.segment)} must be lowercase letters, digits and dashes`);
continue;
}
const panels = join(webDir, screen.segment, 'panels.ts');
const layout = join(webDir, screen.segment, 'layout.ts');
const missing = [
!existsSync(panels) && `web/${screen.segment}/panels.ts`,
!existsSync(layout) && `web/${screen.segment}/layout.ts`,
].filter(Boolean);
if (missing.length) {
problems.push(`screen "${screen.segment}" is declared but missing ${missing.join(' and ')}`);
continue;
}
screens.push({
segment: screen.segment,
label: screen.label,
icon: screen.icon ?? manifest?.icon,
// Its own artwork if it ships any — see DiscoveredScreen.art.
art: fileOrNull(join(dir, 'assets', `${screen.segment}.png`)),
panels,
layout,
});
}
// A directory nobody declared is a screen with no tile and no label — it would render at a URL with
// nothing pointing at it. Reported for the same reason the reverse is: both are the quiet kind of broken.
const undeclared = readdirSync(webDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && existsSync(join(webDir, entry.name, 'panels.ts')))
.map((entry) => entry.name)
.filter((name) => !declared.some((screen) => screen.segment === name));
for (const name of undeclared) {
problems.push(`web/${name}/ has panels but no screens entry — it would have no dock tile and no label`);
}
if (problems.length) return { web: null, error: `${appName}: ${problems.join('; ')}.` };
return { web: { screens, fileActions: fileOrNull(join(webDir, 'file-actions.ts')) } };
}
const fileOrNull = (path: string): string | null => (existsSync(path) ? path : null);
/**
* Read one plugin directory.
*
* Throws with every problem at once rather than the first, because a manifest fixed one field per attempt
* is a manifest nobody finishes.
*/
export async function loadPlugin(dir: string, appName: string): Promise<DiscoveredPlugin> {
const manifestPath = join(dir, 'manifest.ts');
if (!existsSync(manifestPath)) throw new Error(`${appName}: no manifest.ts`);
let manifest: PluginManifest | null = null;
try {
// `importFresh`, not `import` — a reinstalled plugin writes a new manifest at the old path, and a
// plain import would hand back the deleted plugin's. See import-fresh.ts for what that cost.
const module = await importFresh<{ manifest?: PluginManifest }>(manifestPath);
manifest = module.manifest ?? null;
} catch (err) {
throw new Error(`${appName}: manifest.ts failed to load — ${err instanceof Error ? err.message : String(err)}`);
}
const problems = manifestProblems(appName, manifest);
if (problems.length) throw new Error(`${appName}: ${problems.join('; ')}`);
const web = findWeb(dir, appName, manifest);
if (web.error) throw new Error(web.error);
return {
appName,
dir,
manifest: manifest as PluginManifest,
// The dock tile's image. A FILE rather than a manifest field, because an icon is a file and
// "presence is the declaration" is how everything else here works — and because a lucide name
// could only ever name a glyph the platform had already bundled, which is a ceiling a plugin
// from a marketplace has no way to see coming.
icon: fileOrNull(join(dir, 'assets', 'icon.png')),
// The launch scene, carried for whoever fronts this plugin on a phone. Same rule as the icon:
// the file's presence is the declaration, and there is no manifest field to keep in step.
splash: fileOrNull(join(dir, 'assets', 'splash.png')),
api: fileOrNull(join(dir, 'api', 'router.ts')),
schema: fileOrNull(join(dir, 'db', 'schema.ts')),
sidecar: findSidecar(dir),
web: web.web,
};
}
export type DiscoveryResult = {
plugins: DiscoveredPlugin[];
/** Directories that look like plugins but could not be read. Reported, never thrown — see below. */
broken: { appName: string; error: string }[];
};
/**
* Every plugin directory under `PLUGINS_DIR`.
*
* A broken plugin is COLLECTED, not thrown. One unreadable manifest must not stop the platform from
* booting or hide the nine plugins beside it that are fine — and "this one is broken, here is why" is
* something the app store can render, where a failed boot is something only a log can.
*/
export async function discoverPlugins(root: string = PLUGINS_DIR): Promise<DiscoveryResult> {
if (!existsSync(root)) return { plugins: [], broken: [] };
const plugins: DiscoveredPlugin[] = [];
const broken: { appName: string; error: string }[] = [];
for (const entry of readdirSync(root)) {
// `_`-prefixed directories are scratch space, and dotfiles are not plugins.
if (entry.startsWith('.') || entry.startsWith('_')) continue;
// Nor is the shared install target for plugin npm packages, which lives here because bun resolves
// node_modules up the tree — see npm-deps.ts. It was reported as a broken plugin on every discovery
// ("node_modules: no manifest.ts"), which is a warning nobody can act on and a denominator that
// makes the marketplace sync say 15/16.
if (entry === 'node_modules') continue;
const dir = join(root, entry);
try {
if (!statSync(dir).isDirectory()) continue;
} catch {
continue;
}
try {
plugins.push(await loadPlugin(dir, entry));
} catch (err) {
broken.push({ appName: entry, error: err instanceof Error ? err.message : String(err) });
}
}
return { plugins: plugins.sort((a, b) => a.appName.localeCompare(b.appName)), broken };
}