import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { createRequire } from "node:module"; import { pathToFileURL } from "node:url"; import type { PackagePluginManifest, PluginInput, WrnexusPackageManifest, WrnexusPlugin, ClientRuntimeDefinition, PackageAssetDefinition, PackageStyleDefinition, PackageRouteDefinition, PackageMigrationDefinition, PluginPermission, } from "./types.ts"; import { definePlugin, flattenPlugins } from "./base.ts"; import { testPluginCompatibility } from "./compatibility.ts"; interface PackageJson { name?: string; version?: string; dependencies?: Record; optionalDependencies?: Record; devDependencies?: Record; peerDependencies?: Record; workspaces?: string[] | { packages?: string[] }; wrnexus?: WrnexusPackageManifest; } export interface DiscoverPluginOptions { includeDevDependencies?: boolean; /** Fail package discovery immediately instead of degrading to a warning. */ strict?: boolean; includePeerDependencies?: boolean; warn?: (message: string) => void; runtime?: string; capabilities?: readonly string[]; runtimeVersion?: string; os?: string; /** When true, every requested capability must be declared and granted by the application. */ enforcePermissions?: boolean; grantedPermissions?: Readonly>; } function manifestPermissions(manifest: WrnexusPackageManifest): PluginPermission[] { const permissions: PluginPermission[] = []; if (manifest.components?.length) permissions.push("components"); if (manifest.clientRuntimes?.length) permissions.push("client-runtime"); if (manifest.assets?.length) permissions.push("assets"); if (manifest.styles?.length) permissions.push("styles"); if (manifest.routes?.length) permissions.push("routes"); if (manifest.middleware?.length) permissions.push("middleware"); if (manifest.migrations?.length) permissions.push("migrations"); return permissions; } function modulePermissions(plugin: WrnexusPlugin): PluginPermission[] { const permissions: PluginPermission[] = []; if (plugin.componentDirs) permissions.push("components"); if (plugin.clientRuntimes) permissions.push("client-runtime"); if (plugin.assets) permissions.push("assets"); if (plugin.styleSources) permissions.push("styles"); if (plugin.routeEntries || plugin.routes) permissions.push("routes"); if (plugin.middleware) permissions.push("middleware"); if (plugin.migrations) permissions.push("migrations"); if (plugin.configure || plugin.configResolved) permissions.push("config"); if (plugin.transformAst || plugin.transformCode) permissions.push("transform"); if (plugin.diagnostics || plugin.devToolbarPanels) permissions.push("diagnostics"); if (plugin.configureServer || plugin.buildStart || plugin.buildEnd) permissions.push("server"); if (plugin.cliCommands) permissions.push("cli"); if (plugin.directives) permissions.push("directives"); if (plugin.virtualModules) permissions.push("virtual-modules"); if (plugin.deploymentAdapters || plugin.deploy) permissions.push("deployment"); if (plugin.documentation) permissions.push("documentation"); if (plugin.typeDefinitions) permissions.push("types"); return [...new Set(permissions)]; } function enforcePermissions( packageName: string, manifest: WrnexusPackageManifest, required: readonly PluginPermission[], options: DiscoverPluginOptions, ): void { if (!options.enforcePermissions) return; const declared = new Set(manifest.permissions ?? []); const undeclared = required.filter((permission) => !declared.has(permission)); if (undeclared.length) { throw new Error( `WRN-PLUGIN-PERMISSION-UNDECLARED: ${packageName} uses ${undeclared.join(", ")} without declaring them.`, ); } const grants = new Set(options.grantedPermissions?.[packageName] ?? []); const denied = required.filter((permission) => !grants.has(permission)); if (denied.length) { throw new Error( `WRN-PLUGIN-PERMISSION-DENIED: ${packageName} is not granted ${denied.join(", ")}.`, ); } } function readJson(path: string): PackageJson | null { try { return JSON.parse(readFileSync(path, "utf8")) as PackageJson; } catch { return null; } } function nearestWorkspaceRoot(start: string): string | null { let current = resolve(start); while (true) { const packageJson = readJson(join(current, "package.json")); if (packageJson?.workspaces) return current; const parent = dirname(current); if (parent === current) return null; current = parent; } } function workspacePatterns(pkg: PackageJson): string[] { if (Array.isArray(pkg.workspaces)) return pkg.workspaces; return pkg.workspaces?.packages ?? []; } function expandSimpleWorkspacePattern(root: string, pattern: string): string[] { const normalized = pattern.replace(/\\/g, "/").replace(/\/$/, ""); if (!normalized.includes("*")) return [join(root, normalized)]; const star = normalized.indexOf("*"); const before = normalized.slice(0, star).replace(/\/$/, ""); const after = normalized.slice(star + 1).replace(/^\//, ""); const base = join(root, before); if (!existsSync(base)) return []; return readdirSync(base) .map((name) => join(base, name, after)) .filter((path) => existsSync(path) && statSync(path).isDirectory()); } function workspacePackageRoots(root: string): Map { const result = new Map(); const rootPackage = readJson(join(root, "package.json")); if (!rootPackage) return result; for (const pattern of workspacePatterns(rootPackage)) { for (const candidate of expandSimpleWorkspacePattern(root, pattern)) { const pkg = readJson(join(candidate, "package.json")); if (pkg?.name) result.set(pkg.name, candidate); } } return result; } function resolvePackageRoot( appRoot: string, name: string, workspaces: Map, ): string | null { const workspace = workspaces.get(name); if (workspace) return workspace; const require = createRequire(join(appRoot, "package.json")); try { return dirname(require.resolve(`${name}/package.json`)); } catch { try { const entry = require.resolve(name); let current = dirname(entry); while (true) { const pkg = readJson(join(current, "package.json")); if (pkg?.name === name) return current; const parent = dirname(current); if (parent === current) return null; current = parent; } } catch { return null; } } } function resolveRuntime( packageRoot: string, value: ClientRuntimeDefinition, ): ClientRuntimeDefinition { return { ...value, entry: value.entry ? resolve(packageRoot, value.entry) : undefined, }; } function resolveAsset(packageRoot: string, value: PackageAssetDefinition): PackageAssetDefinition { return { ...value, entry: value.entry ? resolve(packageRoot, value.entry) : undefined, }; } function resolveStyle(packageRoot: string, value: PackageStyleDefinition): PackageStyleDefinition { return { ...value, entry: value.entry ? resolve(packageRoot, value.entry) : undefined, source: value.source ? resolve(packageRoot, value.source) : undefined, }; } function resolveRoute(packageRoot: string, value: PackageRouteDefinition): PackageRouteDefinition { return { ...value, entry: resolve(packageRoot, value.entry) }; } function resolveMigration( packageRoot: string, value: PackageMigrationDefinition, ): PackageMigrationDefinition { return { ...value, entry: value.entry ? resolve(packageRoot, value.entry) : undefined }; } function manifestContributionPlugin( packageRoot: string, packageName: string, packageVersion: string | undefined, manifest: WrnexusPackageManifest, ): WrnexusPlugin | null { const components = (manifest.components ?? []).map((value) => resolve(packageRoot, value)); const runtimes = (manifest.clientRuntimes ?? []).map((value) => resolveRuntime(packageRoot, value), ); const assets = (manifest.assets ?? []).map((value) => resolveAsset(packageRoot, value)); const styles = (manifest.styles ?? []).map((value) => resolveStyle(packageRoot, value)); const routes = (manifest.routes ?? []).map((value) => resolveRoute(packageRoot, value)); const middleware = (manifest.middleware ?? []).map((value) => resolve(packageRoot, value)); const migrations = (manifest.migrations ?? []).map((value) => resolveMigration(packageRoot, value), ); if ( !components.length && !runtimes.length && !assets.length && !styles.length && !routes.length && !middleware.length && !migrations.length ) return null; return definePlugin({ name: `${packageName}/manifest`, version: manifest.version ?? packageVersion, componentDirs: components, clientRuntimes: runtimes, assets, styleSources: styles, routeEntries: routes, middleware, migrations, }); } function pluginManifest(value: string | PackagePluginManifest): PackagePluginManifest { return typeof value === "string" ? { plugin: value } : value; } async function loadPlugin( packageRoot: string, manifest: PackagePluginManifest, ): Promise { const modulePath = resolve(packageRoot, manifest.plugin); const mod = (await import(pathToFileURL(modulePath).href)) as Record; const exportName = manifest.export; let candidate = exportName ? mod[exportName] : (mod.default ?? mod.plugin); if (candidate === undefined) { throw new Error(`WRN-PLUGIN-DISCOVERY-EXPORT: ${modulePath} has no plugin export.`); } const shouldInvoke = manifest.factory === true || (manifest.factory !== false && typeof candidate === "function"); if (shouldInvoke) candidate = await (candidate as () => unknown)(); return definePlugin(candidate as WrnexusPlugin); } function dependencyNames(pkg: PackageJson, options: DiscoverPluginOptions): string[] { const all = { ...(pkg.dependencies ?? {}), ...(pkg.optionalDependencies ?? {}), ...(options.includeDevDependencies === false ? {} : (pkg.devDependencies ?? {})), ...(options.includePeerDependencies ? (pkg.peerDependencies ?? {}) : {}), }; return Object.keys(all).sort(); } /** * Discover package plugins declared through package.json `wrnexus.plugin`. * Explicit plugins win; automatic duplicates are skipped instead of failing. */ export async function discoverPlugins( appRoot: string, explicit: PluginInput, options: DiscoverPluginOptions = {}, ): Promise { const appPackage = readJson(join(appRoot, "package.json")); if (!appPackage) return explicit; const explicitPlugins = flattenPlugins(explicit); const explicitNames = new Set(explicitPlugins.map((plugin) => plugin.name)); const workspaceRoot = nearestWorkspaceRoot(appRoot); const workspaces = workspaceRoot ? workspacePackageRoots(workspaceRoot) : new Map(); const automatic: WrnexusPlugin[] = []; for (const name of dependencyNames(appPackage, options)) { const packageRoot = resolvePackageRoot(appRoot, name, workspaces); if (!packageRoot) continue; const packageJson = readJson(join(packageRoot, "package.json")); const packageManifest = packageJson?.wrnexus; if (!packageManifest) continue; enforcePermissions(name, packageManifest, manifestPermissions(packageManifest), options); if ( options.runtime && packageManifest.runtimes?.length && !packageManifest.runtimes.includes(options.runtime as never) ) { throw new Error(`WRN-PLUGIN-RUNTIME: ${name} does not support ${options.runtime}.`); } const missingCapabilities = (packageManifest.requires ?? []).filter( (capability) => !options.capabilities?.includes(capability), ); if (options.runtime && missingCapabilities.length) { throw new Error( `WRN-PLUGIN-CAPABILITY: ${name} requires unavailable capabilities: ${missingCapabilities.join(", ")}.`, ); } if (options.runtime) { const matrix = testPluginCompatibility(packageManifest, [ { runtime: options.runtime as "bun" | "node" | "edge" | "worker" | "service-worker" | "browser", version: options.runtimeVersion ?? (options.runtime === "bun" && typeof Bun !== "undefined" ? Bun.version : undefined), os: options.os ?? process.platform, capabilities: options.capabilities, }, ])[0]!; const compatibilityIssue = matrix.issues.find((issue) => ["WRN-PLUGIN-MATRIX-VERSION", "WRN-PLUGIN-MATRIX-OS"].includes(issue.code), ); if (compatibilityIssue) throw new Error(`${compatibilityIssue.code}: ${name}: ${compatibilityIssue.message}`); } const manifestPlugin = manifestContributionPlugin( packageRoot, packageJson?.name ?? name, packageJson?.version, packageManifest, ); if (manifestPlugin && !explicitNames.has(manifestPlugin.name)) automatic.push(manifestPlugin); const declared = packageManifest.plugin; if (!declared) continue; const manifest = pluginManifest(declared); if (manifest.autoDiscover === false) continue; try { const plugin = await loadPlugin(packageRoot, manifest); enforcePermissions(name, packageManifest, modulePermissions(plugin), options); if (!explicitNames.has(plugin.name) && !automatic.some((item) => item.name === plugin.name)) { automatic.push(plugin); } } catch (error) { const message = `Failed to auto-load ${name}: ${error instanceof Error ? error.message : String(error)}`; if (options.strict) throw new Error(`WRN-PLUGIN-DISCOVERY: ${message}`, { cause: error }); options.warn?.(message); } } return [automatic, explicitPlugins]; }