UNPKG

tuix

Version:

A performant TUI framework for Bun with JSX and reactive state management

831 lines (708 loc) 22.8 kB
/** * Plugin System for CLI Framework * * Provides extensibility through plugins that can: * - Add new commands * - Extend existing commands * - Hook into command lifecycle * - Provide middleware functionality */ import { z } from "zod" import type { CommandConfig, CLIConfig, CLIHooks, Handler, LazyHandler } from "./types" export interface PluginMetadata { name: string version: string description?: string author?: string repository?: string homepage?: string license?: string keywords?: string[] dependencies?: Record<string, string> peerDependencies?: Record<string, string> engines?: { node?: string bun?: string "cli-kit"?: string } } export interface PluginCommands { [commandName: string]: CommandConfig } export interface PluginExtensions { [commandPath: string]: { options?: Record<string, z.ZodSchema> args?: Record<string, z.ZodSchema> wrapper?: HandlerWrapper hooks?: Partial<CommandHooks> } } export type HandlerWrapper = ( originalHandler: Handler | LazyHandler, context: PluginContext ) => Handler | LazyHandler export interface CommandHooks { beforeCommand: (args: any, context: PluginContext) => Promise<void> | void afterCommand: (args: any, result: any, context: PluginContext) => Promise<void> | void onError: (error: Error, args: any, context: PluginContext) => Promise<void> | void } export interface PluginContext { command: string[] config: any plugins: Plugin[] metadata: PluginMetadata services?: Map<string, any> logger?: { log: (...args: any[]) => void error: (...args: any[]) => void warn: (...args: any[]) => void debug: (...args: any[]) => void } router?: any parser?: any addCommand?: (name: string, config: any) => void addHook?: (name: string, hook: any) => void addMiddleware?: (middleware: any) => void extendCommand?: (name: string, extension: any) => void getCommands?: () => any[] getHooks?: () => any emit?: (event: string, ...args: any[]) => void on?: (event: string, handler: any) => any } export interface Plugin { metadata: PluginMetadata commands?: PluginCommands extensions?: PluginExtensions hooks?: Partial<CLIHooks> middleware?: PluginMiddleware | HandlerWrapper[] // Lifecycle methods install?: (context: PluginContext) => Promise<void> | void uninstall?: (context: PluginContext) => Promise<void> | void activate?: (context: PluginContext) => Promise<void> | void deactivate?: (context: PluginContext) => Promise<void> | void init?: (context: PluginContext) => Promise<void> | void // Configuration config?: z.ZodSchema configSchema?: z.ZodSchema defaultConfig?: any configPath?: string // Services provided by plugin services?: Record<string, any> } export interface PluginMiddleware { // Global middleware (runs for all commands) beforeCommand?: (command: string[], args: any) => Promise<void> | void afterCommand?: (command: string[], args: any, result: any) => Promise<void> | void onError?: (error: Error, command: string[], args: any) => Promise<void> | void // Request/response transformation transformArgs?: (args: any, command: string[]) => any transformResult?: (result: any, command: string[]) => any // Validation validateArgs?: (args: any, command: string[]) => boolean | string validateResult?: (result: any, command: string[]) => boolean | string } /** * Define a plugin with type safety */ export function definePlugin(plugin: Plugin): Plugin { // Validate plugin structure if (!plugin.metadata?.name) { throw new Error("Plugin must have a name in metadata") } if (!plugin.metadata?.version) { throw new Error("Plugin must have a version in metadata") } // Ensure all command handlers are functions if (plugin.commands) { for (const [name, command] of Object.entries(plugin.commands)) { if (command.handler && typeof command.handler !== 'function') { throw new Error(`Command '${name}' handler must be a function`) } } } return plugin } /** * Create a plugin from an object or function */ export function createPlugin(options: Plugin | ((api: PluginAPI) => void), metadata?: PluginMetadata): Plugin { // If options is already a plugin object, just validate and return it if (typeof options === 'object' && 'metadata' in options) { return definePlugin(options) } // If options is a function, create plugin using the API if (typeof options === 'function' && metadata) { const plugin: Plugin = { metadata, commands: {}, extensions: {}, hooks: {} } const api: PluginAPI = { addCommand(name: string, config: CommandConfig) { plugin.commands![name] = config }, extendCommand(path: string, extension: PluginExtensions[string]) { plugin.extensions![path] = extension }, addHook(name: keyof CLIHooks, handler: any) { if (!plugin.hooks) plugin.hooks = {} plugin.hooks[name] = handler }, provideService(name: string, service: any) { if (!plugin.services) plugin.services = {} plugin.services[name] = service } } options(api) return plugin } throw new Error('Invalid createPlugin usage') } /** * Plugin API for easier plugin creation */ export interface PluginAPI { addCommand(name: string, config: CommandConfig): void extendCommand(path: string, extension: PluginExtensions[string]): void addHook(name: keyof CLIHooks, handler: any): void provideService(name: string, service: any): void } /** * Check if a plugin is compatible with current CLI version */ export function checkPluginCompatibility( plugin: Plugin, cliVersion: string ): { compatible: boolean; reason?: string } { const requiredVersion = plugin.metadata.engines?.["cli-kit"] if (!requiredVersion) { return { compatible: true } } // Simple version check (in production, use semver) const [reqMajor] = requiredVersion.split('.') const [cliMajor] = cliVersion.split('.') if (reqMajor !== cliMajor) { return { compatible: false, reason: `Plugin requires CLI version ${requiredVersion}, but ${cliVersion} is installed` } } return { compatible: true } } /** * Compose multiple plugins into one */ export function composePlugins(plugins: Plugin[] | Plugin, ...morePlugins: Plugin[]): Plugin { // Handle both array and variadic forms const allPlugins = Array.isArray(plugins) ? plugins : [plugins, ...morePlugins] return { metadata: { name: "composed-plugin", version: "1.0.0", description: `Composed from: ${allPlugins.map(p => p.metadata.name).join(', ')}` }, commands: allPlugins.reduce((acc, plugin) => ({ ...acc, ...plugin.commands }), {}), extensions: allPlugins.reduce((acc, plugin) => ({ ...acc, ...plugin.extensions }), {}), hooks: allPlugins.reduce((acc, plugin) => { const hooks = { ...acc } // Merge hooks by chaining them with proper type safety if (plugin.hooks?.beforeCommand) { const existing = hooks.beforeCommand if (existing) { hooks.beforeCommand = async (command, args) => { await existing(command, args) await plugin.hooks!.beforeCommand!(command, args) } } else { hooks.beforeCommand = plugin.hooks.beforeCommand } } if (plugin.hooks?.afterCommand) { const existing = hooks.afterCommand if (existing) { hooks.afterCommand = async (command, args, result) => { await existing(command, args, result) await plugin.hooks!.afterCommand!(command, args, result) } } else { hooks.afterCommand = plugin.hooks.afterCommand } } if (plugin.hooks?.onError) { const existing = hooks.onError if (existing) { hooks.onError = async (error, command, args) => { await existing(error, command, args) await plugin.hooks!.onError!(error, command, args) } } else { hooks.onError = plugin.hooks.onError } } return hooks }, {} as Partial<CLIHooks>), install: async (context) => { for (const plugin of allPlugins) { if (plugin.install) { await plugin.install(context) } } } } } /** * Validate a plugin structure */ export function validatePlugin(plugin: Plugin): { valid: boolean; errors: string[] } { const errors: string[] = [] if (!plugin || typeof plugin !== 'object') { errors.push("Plugin must be an object") return { valid: false, errors } } if (!plugin.metadata) { errors.push("Plugin must have metadata") return { valid: false, errors } } if (!plugin.metadata.name || plugin.metadata.name.trim() === '') { errors.push("Plugin must have a valid name") } if (!plugin.metadata.version) { errors.push("Plugin must have a version in metadata") } // Validate version format (simple check) if (plugin.metadata.version && !plugin.metadata.version.match(/^\d+\.\d+\.\d+/)) { errors.push("Plugin version must be in semver format (e.g., 1.0.0)") } // Validate command handlers and schemas if (plugin.commands) { for (const [name, command] of Object.entries(plugin.commands)) { if (command.handler && typeof command.handler !== 'function') { errors.push(`Command '${name}' handler must be a function`) } // Check if args is a valid zod schema if (command.args && (!command.args._def || !command.args.parse)) { errors.push(`Command '${name}' args must be a valid Zod schema`) } // Check if options is a valid schema object if (command.options) { for (const [optName, schema] of Object.entries(command.options)) { if (!schema || !schema._def || !schema.parse) { errors.push(`Command '${name}' option '${optName}' must be a valid Zod schema`) } } } } } return { valid: errors.length === 0, errors } } /** * Plugin Manager for managing multiple plugins */ export class PluginManager { private plugins: Map<string, Plugin> = new Map() private hooks: Partial<CLIHooks> = {} private initialized = false constructor(private context: PluginContext) {} async load(plugin: Plugin): Promise<void> { const validation = validatePlugin(plugin) if (!validation.valid) { throw new Error(`Invalid plugin: ${validation.errors?.join(', ')}`) } this.plugins.set(plugin.metadata.name, plugin) if (plugin.install) { await plugin.install(this.context) } } async register(plugin: Plugin): Promise<void> { // Check dependencies before registering if (plugin.metadata.dependencies) { for (const [depName, depVersion] of Object.entries(plugin.metadata.dependencies)) { const dependency = this.plugins.get(depName) if (!dependency) { throw new Error(`Plugin ${plugin.metadata.name} requires ${depName}@${depVersion} but it is not loaded`) } // Check for circular dependencies if (dependency.metadata.dependencies?.[plugin.metadata.name]) { throw new Error(`Circular dependency detected between ${plugin.metadata.name} and ${depName}`) } } } await this.load(plugin) } async initialize(context: PluginContext): Promise<boolean> { if (this.initialized) return true // Resolve plugin load order based on dependencies const sortedPlugins = resolvePluginDependencies(Array.from(this.plugins.values())) // Re-initialize plugins in dependency order this.plugins.clear() for (const plugin of sortedPlugins) { this.plugins.set(plugin.metadata.name, plugin) } this.initialized = true return true } async unload(pluginName: string): Promise<void> { const plugin = this.plugins.get(pluginName) if (!plugin) return if (plugin.uninstall) { await plugin.uninstall(this.context) } this.plugins.delete(pluginName) } getPlugin(name: string): Plugin | undefined { return this.plugins.get(name) } getAllPlugins(): Plugin[] { return Array.from(this.plugins.values()) } } export function createPluginManager(context: PluginContext): PluginManager { return new PluginManager(context) } /** * Apply plugin transforms to args and results */ export function applyPluginTransforms( plugins: Plugin[], command: string[], args: any, phase: 'args' | 'result' ): any export function applyPluginTransforms( originalCommand: any, commandPath: string, plugins: Plugin[] ): any export function applyPluginTransforms( ...args: any[] ): any { // Handle two different call signatures if (args.length === 4 && typeof args[3] === 'string') { // (plugins, command, args, phase) const [plugins, command, transformArgs, phase] = args let transformed = transformArgs for (const plugin of plugins) { if (plugin.middleware && typeof plugin.middleware === 'object' && !Array.isArray(plugin.middleware)) { if (phase === 'args' && plugin.middleware.transformArgs) { transformed = plugin.middleware.transformArgs(transformed, command) } else if (phase === 'result' && plugin.middleware.transformResult) { transformed = plugin.middleware.transformResult(transformed, command) } } } return transformed } else if (args.length === 3) { // (originalCommand, commandPath, plugins) const [originalCommand, commandPath, plugins] = args let transformed = { ...originalCommand } for (const plugin of plugins) { if (plugin.extensions && plugin.extensions[commandPath]) { const extension = plugin.extensions[commandPath] // Merge args if (extension.args) { transformed.args = { ...transformed.args, ...extension.args } } // Merge options if (extension.options) { transformed.options = { ...transformed.options, ...extension.options } } // Apply wrapper if present if (extension.wrapper && transformed.handler) { transformed.handler = extension.wrapper(transformed.handler, { command: [commandPath], config: {}, plugins: [], metadata: plugin.metadata }) } } } return transformed } throw new Error('Invalid arguments for applyPluginTransforms') } /** * Create a middleware chain from plugins */ export function createMiddlewareFromPlugins( plugins: Plugin[], phase: 'before' | 'after' | 'error' ): ((context: any) => Promise<void>)[] { const middlewares: ((context: any) => Promise<void>)[] = [] for (const plugin of plugins) { if (plugin.middleware) { if (phase === 'before' && plugin.middleware.beforeCommand) { middlewares.push(async (ctx) => { await plugin.middleware!.beforeCommand!(ctx.command, ctx.args) }) } else if (phase === 'after' && plugin.middleware.afterCommand) { middlewares.push(async (ctx) => { await plugin.middleware!.afterCommand!(ctx.command, ctx.args, ctx.result) }) } else if (phase === 'error' && plugin.middleware.onError) { middlewares.push(async (ctx) => { await plugin.middleware!.onError!(ctx.error, ctx.command, ctx.args) }) } } } return middlewares } /** * Create a middleware chain from handler wrappers */ export function createMiddlewareChain( middlewares: HandlerWrapper[], handler: Handler | LazyHandler, context: PluginContext ): Handler { return middlewares.reduceRight((nextHandler, middleware) => { return middleware(nextHandler, context) }, handler) as Handler } function deepMerge(target: any, source: any): any { const result = { ...target } for (const key in source) { if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { if (result[key] && typeof result[key] === 'object' && !Array.isArray(result[key])) { result[key] = deepMerge(result[key], source[key]) } else { result[key] = { ...source[key] } } } else { result[key] = source[key] } } return result } /** * Merge plugin configs with CLI config */ export function mergePluginConfigs( baseConfig: CLIConfig, plugins: Plugin[] ): CLIConfig export function mergePluginConfigs(configs: any[]): any export function mergePluginConfigs(...args: any[]): any { if (args.length === 2 && Array.isArray(args[1])) { // (baseConfig, plugins) const [baseConfig, plugins] = args let config = { ...baseConfig } for (const plugin of plugins) { // Merge commands if (plugin.commands) { config.commands = { ...config.commands, ...plugin.commands } } // Merge hooks if (plugin.hooks) { config.hooks = { ...config.hooks, ...plugin.hooks } } } return config } else if (args.length === 1 && Array.isArray(args[0])) { // (configs) const [configs] = args let merged = {} for (const config of configs) { merged = deepMerge(merged, config) } return merged } throw new Error('Invalid arguments for mergePluginConfigs') } /** * Resolve plugin dependencies and return load order */ export function resolvePluginDependencies(plugins: Plugin[]): Plugin[] { // Simple topological sort - in a real implementation, // this would check actual dependencies const sorted: Plugin[] = [] const visited = new Set<string>() const visiting = new Set<string>() function visit(plugin: Plugin) { if (visited.has(plugin.metadata.name)) return if (visiting.has(plugin.metadata.name)) { throw new Error(`Circular dependency detected: ${plugin.metadata.name}`) } visiting.add(plugin.metadata.name) // Visit dependencies first (simplified - assumes no real deps) // In real implementation, would check plugin.metadata.dependencies visiting.delete(plugin.metadata.name) visited.add(plugin.metadata.name) sorted.push(plugin) } for (const plugin of plugins) { visit(plugin) } return sorted } /** * Plugin Builder for fluent plugin creation */ export class PluginBuilder { private plugin: Partial<Plugin> = {} metadata(metadata: PluginMetadata): this { this.plugin.metadata = metadata return this } addCommand(name: string, config: CommandConfig): this { if (!this.plugin.commands) { this.plugin.commands = {} } this.plugin.commands[name] = config return this } command(name: string, config: CommandConfig): this { return this.addCommand(name, config) } addHook(hookName: keyof CLIHooks, hook: any): this { if (!this.plugin.hooks) { this.plugin.hooks = {} } this.plugin.hooks[hookName] = hook return this } hook(hookName: keyof CLIHooks, hook: any): this { return this.addHook(hookName, hook) } addMiddleware(middleware: HandlerWrapper): this { if (!this.plugin.middleware) { this.plugin.middleware = [] } if (Array.isArray(this.plugin.middleware)) { this.plugin.middleware.push(middleware) } else { this.plugin.middleware = [middleware] } return this } middleware(middleware: HandlerWrapper): this { return this.addMiddleware(middleware) } service(name: string, service: any): this { if (!this.plugin.services) { this.plugin.services = {} } this.plugin.services[name] = service return this } extend(path: string, extension: PluginExtensions[string]): this { if (!this.plugin.extensions) { this.plugin.extensions = {} } this.plugin.extensions[path] = extension return this } install(installFn: (context: PluginContext) => Promise<void>): this { this.plugin.install = installFn return this } init(initFn: (context: PluginContext) => Promise<void>): this { this.plugin.init = initFn return this } uninstall(uninstallFn: (context: PluginContext) => Promise<void>): this { this.plugin.uninstall = uninstallFn return this } config(configSchema: z.ZodSchema): this { this.plugin.configSchema = configSchema return this } build(): Plugin { if (!this.plugin.metadata) { throw new Error("Plugin metadata is required") } return this.plugin as Plugin } } /** * Create a plugin from a builder */ export function createPluginFromBuilder(builderFn: (builder: PluginBuilder) => PluginBuilder): Plugin export function createPluginFromBuilder(builder: PluginBuilder): Plugin export function createPluginFromBuilder(arg: any): Plugin { if (typeof arg === 'function') { // (builderFn) const builder = new PluginBuilder() return arg(builder).build() } else if (arg instanceof PluginBuilder) { // (builder) return arg.build() } throw new Error('Invalid argument for createPluginFromBuilder') } /** * Plugin utilities */ export const PluginUtils = { /** * Load a plugin from a file path */ async loadFromPath(path: string): Promise<Plugin> { const module = await import(path) const plugin = module.default || module.plugin if (!plugin) { throw new Error(`No default export or 'plugin' export found in ${path}`) } return plugin }, /** * Load a plugin from npm package */ async loadFromPackage(packageName: string): Promise<Plugin> { try { const module = await import(packageName) return module.default || module.plugin } catch (error) { throw new Error(`Failed to load plugin from package '${packageName}': ${error}`) } }, /** * Create a plugin that adds a single command */ commandPlugin( name: string, command: string, config: CommandConfig ): Plugin { return definePlugin({ metadata: { name, version: "1.0.0" }, commands: { [command]: config } }) }, /** * Create a plugin that adds middleware */ middlewarePlugin( name: string, middleware: PluginMiddleware ): Plugin { return definePlugin({ metadata: { name, version: "1.0.0" }, middleware }) } }