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, } from "./types.ts"; import { definePlugin, flattenPlugins } from "./base.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; } 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; 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); 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]; }