Organize src/ into concern folders (http, auth, admin, plugin-host, ui); co-locate tests, move plugin-api barrel into plugin-host, sync docs + AGENTS layout

This commit is contained in:
2026-06-24 00:23:55 +02:00
parent 6d316c4888
commit de22f51c12
113 changed files with 282 additions and 265 deletions
+118
View File
@@ -0,0 +1,118 @@
// Plugin discovery: scan plugins/, import each folder's plugin.ts default export,
// validate it, assemble the loaded Plugin[]. The imperative shell over plugin.ts's pure rules
// (isValidPluginId, checkApiVersion, findConflicts). Fails loud: every per-plugin problem and
// error-level conflict is collected into one boot-stopping Error; warn-level diagnostics
// (older-minor apiVersion, shared permission token) log and load continues. Folder name = id.
import { existsSync, readdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { checkApiVersion, findConflicts, isValidPluginId, RESERVED_PLUGIN_IDS, type Plugin, type PluginManifest } from "./plugin.ts";
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
// Default scan root — <repo>/plugins, i.e. the /app/plugins the container mounts (README).
export const PLUGINS_DIR = join(rootDir, "plugins");
export interface DiscoverOptions {
dir?: string;
logger?: Pick<Console, "warn">; // warn-level diagnostics; defaults to console
}
export async function discoverPlugins(options: DiscoverOptions = {}): Promise<Plugin[]> {
const dir = options.dir ?? PLUGINS_DIR;
const logger = options.logger ?? console;
if (!existsSync(dir)) return []; // a clean clone has no plugins/ yet — zero plugins is valid
const errors: string[] = [];
const plugins: Plugin[] = [];
for (const id of pluginFolders(dir)) {
const fail = (msg: string): void => void errors.push(`plugins/${id}: ${msg}`);
if (!isValidPluginId(id)) {
errors.push(`"${id}" is not a valid plugin folder name (lowercase az, digits, dashes)`);
continue;
}
if (RESERVED_PLUGIN_IDS.has(id)) { fail(`"${id}" is a reserved id — it would shadow a built-in host route`); continue; }
const file = join(dir, id, "plugin.ts");
if (!existsSync(file)) { fail("no plugin.ts found"); continue; }
let mod: { default?: unknown };
try {
mod = (await import(pathToFileURL(file).href)) as { default?: unknown };
} catch (err) {
fail(`failed to import plugin.ts — ${messageOf(err)}`);
continue;
}
const manifest = asManifest(mod.default);
if (!manifest) { fail("plugin.ts must default-export a manifest object"); continue; }
const version = checkApiVersion(manifest.apiVersion);
if (version.level === "refuse") { fail(version.message); continue; }
if (version.level === "warn") logger.warn(`[plugins] ${id}: ${version.message}`);
const shape = shapeError(manifest);
if (shape) { fail(shape); continue; }
plugins.push({ ...manifest, id }); // identity is the folder, not the manifest
}
for (const conflict of findConflicts(plugins)) {
if (conflict.level === "error") errors.push(conflict.message);
else logger.warn(`[plugins] ${conflict.message}`);
}
if (errors.length) {
throw new Error(`Plugin discovery failed:\n${errors.map((e) => ` - ${e}`).join("\n")}`);
}
return plugins;
}
// Subfolders of plugins/, sorted for deterministic load order + stable conflict messages. Hidden
// entries (.git, .DS_Store, …) and non-directories are skipped — only folders are plugins.
function pluginFolders(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true })
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
.map((e) => e.name)
.sort();
}
function asManifest(value: unknown): PluginManifest | null {
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as PluginManifest) : null;
}
// The collection fields feed findConflicts, which iterates them — a non-array crashes it opaquely.
function shapeError(manifest: PluginManifest): string | null {
for (const field of ["nav", "permissions", "routes"] as const) {
if (manifest[field] !== undefined && !Array.isArray(manifest[field])) return `"${field}" must be an array`;
}
// `home` / `dashboard` (the landing-page overrides) are route handlers; the host calls them, so
// a non-function fails loud.
for (const slot of ["home", "dashboard"] as const) {
if (manifest[slot] !== undefined && typeof manifest[slot] !== "function") return `"${slot}" must be a function (a route handler)`;
}
// `public` and `permission` are contradictory on the same route/nav node — "open to all" vs
// "needs this role". Refuse rather than silently pick one, so the author's intent is unambiguous.
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
if (route?.public === true && route.permission != null) return `route "${route.method} ${route.path}" sets both public and permission — they are mutually exclusive`;
}
const navContradiction = findPublicNavContradiction(manifest.nav);
if (navContradiction) return navContradiction;
return null;
}
// Recurse the nav fragment: a node that is both `public` and `permission`-gated is contradictory.
function findPublicNavContradiction(nodes: PluginManifest["nav"]): string | null {
for (const node of Array.isArray(nodes) ? nodes : []) {
if (node?.public === true && node.permission != null) return `nav node "${node.label ?? node.id ?? "?"}" sets both public and permission — they are mutually exclusive`;
const inChild = findPublicNavContradiction(node?.children);
if (inChild) return inChild;
}
return null;
}
function messageOf(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}