import { existsSync, readdirSync, statSync } from 'node:fs'; import { join, sep } 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 `/plugins//` — 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', '/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// 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.`, }; } const problems: string[] = []; const routes = routesOf(manifest?.routes, webDir, layout, problems); if (problems.length) return { web: null, error: `${appName}: ${problems.join('; ')}.` }; return { web: { screens: [ { segment: null, label: manifest?.label ?? appName, icon: manifest?.icon, art: null, panels, layout, routes }, ], // 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//`, 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, // On the screen entry, not the manifest root: a route belongs to the screen it renders inside. routes: routesOf(screen.routes, join(webDir, screen.segment), layout, problems), }); } // 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')) } }; } /** * A screen's route table, resolved against the plugin directory. * * ── Every layout is checked HERE ── * * A route naming a file that is not there would be a build error in generated code — a stack trace * pointing at `Plugins.gen.tsx`, which nobody wrote. Refused at discovery it is a sentence naming the * plugin and the missing file, which is the same trade every other check in this file makes. * * A path is not validated beyond being a string: it is react-router's syntax, not ours, and a * half-understood copy of their grammar would refuse patterns that work. */ function routesOf( declared: { path: string; layout?: string }[] | undefined, webDir: string, fallbackLayout: string, problems: string[], ): { path: string; layout: string }[] { const seen = new Set(); const routes: { path: string; layout: string }[] = []; for (const route of declared ?? []) { // Two entries on one path is one of them never matching, silently — react-router takes the first. if (seen.has(route.path)) { problems.push(`route ${JSON.stringify(route.path)} is declared twice`); continue; } seen.add(route.path); if (!route.layout) { routes.push({ path: route.path, layout: fallbackLayout }); continue; } // Under web/, like everything else a screen declares. `join` on a traversal would leave the plugin // directory, so the resolved path is checked to still be inside it. const resolved = join(webDir, route.layout.replace(/^web\//, '')); if (!resolved.startsWith(webDir + sep) || !existsSync(resolved)) { problems.push(`route ${JSON.stringify(route.path)} names layout ${JSON.stringify(route.layout)}, which is not in web/`); continue; } routes.push({ path: route.path, layout: resolved }); } return routes; } 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 { 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 { 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 }; }