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>
254 lines
11 KiB
TypeScript
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 };
|
|
}
|