import type { ApijackPlugin, CustomResolver } from '../types'; import { BUILTIN_RESOLVER_NAMES } from '../routine/resolver'; import { PluginNamespaceError, PluginCollisionError, PluginRegistrationError } from './errors'; const PLUGIN_NAME_RE = /^[a-z][a-z0-9_]*$/; interface PluginInfo { staticKeys: string[]; factoryKeys: string[] | null; } export class PluginRegistry { private plugins = new Map(); register(plugin: ApijackPlugin): void { if (!PLUGIN_NAME_RE.test(plugin.name)) { throw new PluginRegistrationError( plugin.name, `Invalid plugin name "${plugin.name}". Must match /^[a-z][a-z0-9_]*$/.`, ); } if (this.plugins.has(plugin.name)) { throw new PluginRegistrationError( plugin.name, `Plugin "${plugin.name}" is already registered.`, ); } this.plugins.set(plugin.name, plugin); } getAll(): ApijackPlugin[] { return Array.from(this.plugins.values()); } get(name: string): ApijackPlugin | undefined { return this.plugins.get(name); } /** * Validate all registered plugins. Returns a list of validation errors instead of throwing. * Useful for `plugins check` which needs to report all issues, not just the first. */ validateAllCollected(projectResolvers?: Map): Error[] { const errors: Error[] = []; for (const plugin of this.plugins.values()) { const info = this.collectPluginInfo(plugin); try { this.validateNamespace(plugin, info); } catch (e) { errors.push(e as Error); // Skip collision check for this plugin if namespace failed continue; } try { this.validateCollisionsForPlugin(plugin, info, projectResolvers); } catch (e) { errors.push(e as Error); } } return errors; } /** Convenience: throws the first validation error. Preserves original behavior for cli.run() startup. */ validateAll(projectResolvers?: Map): void { const errors = this.validateAllCollected(projectResolvers); if (errors.length > 0) throw errors[0]; } private collectPluginInfo(plugin: ApijackPlugin): PluginInfo { const staticKeys = Object.keys(plugin.resolvers ?? {}); if (!plugin.createRoutineResolvers) { return { staticKeys, factoryKeys: null }; } try { return { staticKeys, factoryKeys: Object.keys(plugin.createRoutineResolvers({})), }; } catch (e) { process.stderr.write( `Warning: plugin "${plugin.name}" createRoutineResolvers({}) threw "${(e as Error).message}"; ` + 'skipping dynamic resolver validation.\n', ); return { staticKeys, factoryKeys: null }; } } private validateNamespace(plugin: ApijackPlugin, info: PluginInfo): void { const prefix = `_${plugin.name}`; const check = (name: string): void => { if (name !== prefix && !name.startsWith(`${prefix}_`)) { throw new PluginNamespaceError(plugin.name, name, prefix); } }; for (const key of info.staticKeys) check(key); if (info.factoryKeys) { for (const key of info.factoryKeys) check(key); } } private validateCollisionsForPlugin( plugin: ApijackPlugin, info: PluginInfo, projectResolvers?: Map, ): void { const allKeys = info.factoryKeys ? [...info.staticKeys, ...info.factoryKeys] : info.staticKeys; for (const key of allKeys) { if (BUILTIN_RESOLVER_NAMES.has(key)) { throw new PluginCollisionError(key, `plugin "${plugin.name}"`, 'core built-in'); } if (projectResolvers?.has(key)) { throw new PluginCollisionError( key, `plugin "${plugin.name}"`, 'consumer resolver (cli.resolver() or .apijack/resolvers/)', ); } } } }