diff --git a/bin/index.js b/bin/index.js index 2ce376e..e22f0af 100755 --- a/bin/index.js +++ b/bin/index.js @@ -199,6 +199,255 @@ program process.exit(1); } }); + +// Plugin management commands +const pluginCmd = program + .command('plugin') + .description('Manage plugins'); + +pluginCmd + .command('list') + .description('List all plugins in the current workspace') + .option('--json', 'Output as JSON') + .option('--enabled-only', 'Show only enabled plugins') + .action(async (opts) => { + try { + const { pluginSystem } = await import('./lib/plugin-system.js'); + const cwd = process.cwd(); + + await pluginSystem.initialize(cwd); + const plugins = pluginSystem.getAllPlugins(); + + let filteredPlugins = plugins; + if (opts.enabledOnly) { + filteredPlugins = plugins.filter(p => p.enabled); + } + + if (opts.json) { + console.log(JSON.stringify(filteredPlugins, null, 2)); + } else { + if (filteredPlugins.length === 0) { + console.log(chalk.yellow('No plugins found.')); + return; + } + + console.log(chalk.blue(`\nšŸ“¦ Found ${filteredPlugins.length} plugin(s):\n`)); + for (const plugin of filteredPlugins) { + const status = plugin.enabled ? chalk.green('enabled') : chalk.red('disabled'); + const type = plugin.type === 'local' ? chalk.cyan('local') : chalk.magenta('external'); + console.log(` ${chalk.bold(plugin.name)} [${status}] (${type})`); + if (plugin.plugin?.description) { + console.log(` ${chalk.gray(plugin.plugin.description)}`); + } + if (plugin.plugin?.version) { + console.log(` ${chalk.gray('v' + plugin.plugin.version)}`); + } + console.log(); + } + } + } catch (e) { + console.error(chalk.red('Failed to list plugins:'), e.message); + process.exit(1); + } + }); + +pluginCmd + .command('enable') + .description('Enable a plugin') + .argument('', 'Plugin name') + .action(async (name) => { + try { + const { pluginSystem } = await import('./lib/plugin-system.js'); + const cwd = process.cwd(); + + await pluginSystem.initialize(cwd); + await pluginSystem.enablePlugin(name); + + console.log(chalk.green(`āœ… Plugin '${name}' enabled successfully`)); + } catch (e) { + console.error(chalk.red('Failed to enable plugin:'), e.message); + process.exit(1); + } + }); + +pluginCmd + .command('disable') + .description('Disable a plugin') + .argument('', 'Plugin name') + .action(async (name) => { + try { + const { pluginSystem } = await import('./lib/plugin-system.js'); + const cwd = process.cwd(); + + await pluginSystem.initialize(cwd); + await pluginSystem.disablePlugin(name); + + console.log(chalk.green(`āœ… Plugin '${name}' disabled successfully`)); + } catch (e) { + console.error(chalk.red('Failed to disable plugin:'), e.message); + process.exit(1); + } + }); + +pluginCmd + .command('info') + .description('Show detailed information about a plugin') + .argument('', 'Plugin name') + .option('--json', 'Output as JSON') + .action(async (name, opts) => { + try { + const { pluginSystem, HOOK_POINTS } = await import('./lib/plugin-system.js'); + const cwd = process.cwd(); + + await pluginSystem.initialize(cwd); + const plugin = pluginSystem.getPlugin(name); + + if (!plugin) { + console.log(chalk.yellow(`Plugin '${name}' not found.`)); + process.exit(1); + } + + if (opts.json) { + const info = { + name: plugin.name, + type: plugin.type, + enabled: plugin.enabled, + loadedAt: plugin.loadedAt, + plugin: plugin.plugin, + config: plugin.config + }; + console.log(JSON.stringify(info, null, 2)); + } else { + console.log(chalk.blue(`\nšŸ“¦ Plugin: ${chalk.bold(plugin.name)}\n`)); + console.log(`Type: ${plugin.type === 'local' ? chalk.cyan('Local') : chalk.magenta('External')}`); + console.log(`Status: ${plugin.enabled ? chalk.green('Enabled') : chalk.red('Disabled')}`); + console.log(`Loaded: ${plugin.loadedAt ? new Date(plugin.loadedAt).toLocaleString() : 'Not loaded'}`); + + if (plugin.plugin) { + if (plugin.plugin.version) { + console.log(`Version: ${plugin.plugin.version}`); + } + if (plugin.plugin.description) { + console.log(`Description: ${plugin.plugin.description}`); + } + + if (plugin.plugin.hooks) { + console.log(`\nHooks (${Object.keys(plugin.plugin.hooks).length}):`); + for (const [hookName, handler] of Object.entries(plugin.plugin.hooks)) { + const description = HOOK_POINTS[hookName] || 'Custom hook'; + console.log(` ${chalk.cyan(hookName)} - ${chalk.gray(description)}`); + } + } + + if (plugin.plugin.methods) { + console.log(`\nMethods (${Object.keys(plugin.plugin.methods).length}):`); + for (const methodName of Object.keys(plugin.plugin.methods)) { + console.log(` ${chalk.cyan(methodName)}`); + } + } + } + + if (plugin.config) { + console.log(`\nConfiguration:`); + console.log(JSON.stringify(plugin.config, null, 2)); + } + console.log(); + } + } catch (e) { + console.error(chalk.red('Failed to get plugin info:'), e.message); + process.exit(1); + } + }); + +pluginCmd + .command('configure') + .description('Configure a plugin') + .argument('', 'Plugin name') + .option('--config ', 'Configuration as JSON string') + .option('--priority ', 'Plugin loading priority (higher = loads first)') + .option('--external ', 'Set external plugin path or npm package') + .action(async (name, opts) => { + try { + const { pluginSystem } = await import('./lib/plugin-system.js'); + const cwd = process.cwd(); + + await pluginSystem.initialize(cwd); + + const config = {}; + + if (opts.config) { + try { + Object.assign(config, JSON.parse(opts.config)); + } catch (e) { + console.error(chalk.red('Invalid JSON in --config option')); + process.exit(1); + } + } + + if (opts.priority !== undefined) { + config.priority = parseInt(opts.priority); + } + + if (opts.external) { + config.external = opts.external; + } + + if (Object.keys(config).length === 0) { + console.log(chalk.yellow('No configuration options provided. Use --config, --priority, or --external.')); + process.exit(1); + } + + await pluginSystem.configurePlugin(name, config); + + console.log(chalk.green(`āœ… Plugin '${name}' configured successfully`)); + console.log('New configuration:', JSON.stringify(config, null, 2)); + } catch (e) { + console.error(chalk.red('Failed to configure plugin:'), e.message); + process.exit(1); + } + }); + +pluginCmd + .command('stats') + .description('Show plugin system statistics') + .option('--json', 'Output as JSON') + .action(async (opts) => { + try { + const { pluginSystem } = await import('./lib/plugin-system.js'); + const cwd = process.cwd(); + + await pluginSystem.initialize(cwd); + const stats = pluginSystem.getStats(); + + if (opts.json) { + console.log(JSON.stringify(stats, null, 2)); + } else { + console.log(chalk.blue('\nšŸ“Š Plugin System Statistics\n')); + console.log(`Initialized: ${stats.initialized ? chalk.green('Yes') : chalk.red('No')}`); + console.log(`Project Directory: ${stats.projectDir || 'N/A'}`); + console.log(`Total Plugins: ${stats.totalPlugins}`); + console.log(`Enabled Plugins: ${stats.enabledPlugins}`); + console.log(`Available Hook Points: ${stats.hookPoints}`); + + console.log('\nRegistered Hooks:'); + for (const [hookName, count] of Object.entries(stats.registeredHooks)) { + console.log(` ${chalk.cyan(hookName)}: ${count} handler(s)`); + } + + if (Object.keys(stats.config).length > 0) { + console.log('\nPlugin Configuration:'); + for (const [pluginName, config] of Object.entries(stats.config)) { + console.log(` ${chalk.bold(pluginName)}:`); + console.log(` ${JSON.stringify(config, null, 4).replace(/^/gm, ' ')}`); + } + } + console.log(); + } + } catch (e) { + console.error(chalk.red('Failed to get plugin stats:'), e.message); + process.exit(1); + } + }); program.parse(); diff --git a/bin/lib/admin.js b/bin/lib/admin.js index fdc28d9..defc15f 100644 --- a/bin/lib/admin.js +++ b/bin/lib/admin.js @@ -6,6 +6,7 @@ import { spawn } from 'node:child_process'; import { WebSocketServer, WebSocket } from 'ws'; import { getLogsForAPI, LogFileWatcher } from './logs.js'; import { startService, stopService, restartService, getServiceStatus, getAllServiceStatuses, validateServiceCanRun } from './service-manager.js'; +import { initializePlugins, callHook } from './plugin-system.js'; // ws helper function sendWebSocketMessage(ws, message) { @@ -886,6 +887,15 @@ export async function startAdminDashboard(options = {}) { process.exit(1); } + // Initialize plugins + await initializePlugins(cwd); + + // Call before:admin:start hook + await callHook('before:admin:start', { + projectDir: cwd, + options + }); + const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8')); const port = options.port || 8080; const refreshInterval = options.refresh || 5000; @@ -1064,9 +1074,18 @@ export async function startAdminDashboard(options = {}) { res.end(html); }); - server.listen(port, () => { + server.listen(port, async () => { console.log(chalk.green(`āœ… Admin Dashboard running at http://localhost:${port}`)); + // Call after:admin:start hook + await callHook('after:admin:start', { + projectDir: cwd, + port, + dashboardUrl: `http://localhost:${port}`, + options, + services: cfg.services + }); + // Auto-open browser if requested if (options.open !== false) { const url = `http://localhost:${port}`; diff --git a/bin/lib/dev.js b/bin/lib/dev.js index 9f57818..bc45966 100644 --- a/bin/lib/dev.js +++ b/bin/lib/dev.js @@ -4,6 +4,7 @@ import chalk from 'chalk'; import { spawn } from 'node:child_process'; import http from 'http'; import { initializeServiceLogs } from './logs.js'; +import { initializePlugins, callHook } from './plugin-system.js'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); @@ -70,6 +71,17 @@ export async function runDev({ docker=false } = {}) { console.error(chalk.red('polyglot.json not found. Run inside a generated workspace.')); process.exit(1); } + + // Initialize plugins + await initializePlugins(cwd); + + // Call before:dev:start hook + await callHook('before:dev:start', { + projectDir: cwd, + docker, + mode: docker ? 'docker' : 'local' + }); + const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8')); const servicesDir = path.join(cwd, 'services'); if (!fs.existsSync(servicesDir)) { @@ -164,9 +176,31 @@ const args = ['run', useScript]; } await Promise.all(healthPromises); + // Call after:dev:start hook + await callHook('after:dev:start', { + projectDir: cwd, + docker, + mode: docker ? 'docker' : 'local', + processes: procs.length, + services: procs.map(p => p.serviceName).filter(Boolean) + }); + if (procs.length > 0) { console.log(chalk.blue('Watching services. Press Ctrl+C to exit.')); - process.on('SIGINT', () => { procs.forEach(p => p.kill('SIGINT')); process.exit(0); }); + process.on('SIGINT', async () => { + await callHook('before:dev:stop', { + projectDir: cwd, + docker, + mode: docker ? 'docker' : 'local' + }); + procs.forEach(p => p.kill('SIGINT')); + await callHook('after:dev:stop', { + projectDir: cwd, + docker, + mode: docker ? 'docker' : 'local' + }); + process.exit(0); + }); } } diff --git a/bin/lib/plugin-system.js b/bin/lib/plugin-system.js new file mode 100644 index 0000000..b05a369 --- /dev/null +++ b/bin/lib/plugin-system.js @@ -0,0 +1,501 @@ +import { createHooks } from 'hookable'; +import fs from 'fs-extra'; +import path from 'path'; +import chalk from 'chalk'; +import { pathToFileURL } from 'url'; + +/** + * Plugin Hook System for create-polyglot + * + * Provides a robust, configurable plugin execution pipeline with: + * - Lifecycle hooks at key points in the CLI workflow + * - Plugin dependency resolution and ordering + * - Error handling and graceful degradation + * - Plugin enable/disable functionality + * - Comprehensive logging and debugging + */ + +// Define all available hook points in the create-polyglot lifecycle +export const HOOK_POINTS = { + // Project initialization hooks + 'before:init': 'Called before project scaffolding begins', + 'after:init': 'Called after project scaffolding completes', + 'before:template:copy': 'Called before copying service templates', + 'after:template:copy': 'Called after copying service templates', + 'before:dependencies:install': 'Called before installing dependencies', + 'after:dependencies:install': 'Called after installing dependencies', + + // Service management hooks + 'before:service:add': 'Called before adding a new service', + 'after:service:add': 'Called after adding a new service', + 'before:service:remove': 'Called before removing a service', + 'after:service:remove': 'Called after removing a service', + + // Development workflow hooks + 'before:dev:start': 'Called before starting dev server(s)', + 'after:dev:start': 'Called after dev server(s) have started', + 'before:dev:stop': 'Called before stopping dev server(s)', + 'after:dev:stop': 'Called after dev server(s) have stopped', + + // Docker hooks + 'before:docker:build': 'Called before building Docker images', + 'after:docker:build': 'Called after building Docker images', + 'before:compose:up': 'Called before running docker compose up', + 'after:compose:up': 'Called after docker compose up completes', + + // Hot reload hooks + 'before:hotreload:start': 'Called before starting hot reload', + 'after:hotreload:start': 'Called after hot reload is active', + 'before:hotreload:restart': 'Called before restarting a service', + 'after:hotreload:restart': 'Called after a service restarts', + + // Admin dashboard hooks + 'before:admin:start': 'Called before starting admin dashboard', + 'after:admin:start': 'Called after admin dashboard is running', + + // Log management hooks + 'before:logs:view': 'Called before viewing logs', + 'after:logs:view': 'Called after log viewing session', + 'before:logs:clear': 'Called before clearing logs', + 'after:logs:clear': 'Called after clearing logs', + + // Plugin lifecycle hooks + 'before:plugin:load': 'Called before loading a plugin', + 'after:plugin:load': 'Called after loading a plugin', + 'before:plugin:unload': 'Called before unloading a plugin', + 'after:plugin:unload': 'Called after unloading a plugin' +}; + +class PluginSystem { + constructor() { + this.hooks = createHooks(); + this.plugins = new Map(); + this.pluginOrder = []; + this.config = null; + this.projectDir = null; + this.isInitialized = false; + this.debug = process.env.DEBUG_PLUGINS === 'true'; + } + + /** + * Initialize the plugin system for a project + */ + async initialize(projectDir) { + this.projectDir = projectDir; + + try { + // Load project configuration + const configPath = path.join(projectDir, 'polyglot.json'); + if (await fs.pathExists(configPath)) { + this.config = await fs.readJson(configPath); + } else { + this.config = { plugins: {} }; + } + + // Discover and load plugins + await this.discoverPlugins(); + await this.loadPlugins(); + + this.isInitialized = true; + this.log('Plugin system initialized', { pluginCount: this.plugins.size }); + + // Call the plugin load hooks + await this.callHook('after:plugin:load', { system: this }); + + } catch (error) { + this.logError('Failed to initialize plugin system', error); + // Don't throw - allow CLI to continue without plugins + } + } + + /** + * Discover plugins in the plugins directory and from configuration + */ + async discoverPlugins() { + const discoveredPlugins = []; + + // Discover local and external plugins + const localPlugins = await this.discoverLocalPlugins(); + const externalPlugins = this.discoverExternalPlugins(); + + discoveredPlugins.push(...localPlugins, ...externalPlugins); + + // Sort plugins by priority (higher priority loads first) + this.sortPluginsByPriority(discoveredPlugins); + + this.pluginOrder = discoveredPlugins; + this.log('Discovered plugins', { count: discoveredPlugins.length }); + } + + /** + * Discover local plugins in the .polyglot/plugins directory + */ + async discoverLocalPlugins() { + const pluginsDir = path.join(this.projectDir, '.polyglot', 'plugins'); + const localPlugins = []; + + if (await fs.pathExists(pluginsDir)) { + const entries = await fs.readdir(pluginsDir, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isDirectory()) { + const pluginPath = path.join(pluginsDir, entry.name, 'index.js'); + if (await fs.pathExists(pluginPath)) { + localPlugins.push({ + name: entry.name, + type: 'local', + path: pluginPath, + enabled: this.config?.plugins?.[entry.name]?.enabled !== false + }); + } + } + } + } + + return localPlugins; + } + + /** + * Discover external plugins from configuration + */ + discoverExternalPlugins() { + const externalPlugins = []; + + if (this.config?.plugins) { + for (const [name, config] of Object.entries(this.config.plugins)) { + if (config.external && config.enabled !== false) { + externalPlugins.push({ + name, + type: 'external', + path: config.external, + config: config, + enabled: true + }); + } + } + } + + return externalPlugins; + } + + /** + * Sort plugins by priority + */ + sortPluginsByPriority(plugins) { + plugins.sort((a, b) => { + const priorityA = this.config?.plugins?.[a.name]?.priority || 0; + const priorityB = this.config?.plugins?.[b.name]?.priority || 0; + return priorityB - priorityA; + }); + } + + /** + * Load all discovered plugins + */ + async loadPlugins() { + for (const pluginInfo of this.pluginOrder) { + if (!pluginInfo.enabled) { + this.log(`Skipping disabled plugin: ${pluginInfo.name}`); + continue; + } + + try { + await this.callHook('before:plugin:load', { pluginInfo }); + await this.loadPlugin(pluginInfo); + } catch (error) { + this.logError(`Failed to load plugin: ${pluginInfo.name}`, error); + // Continue loading other plugins + } + } + } + + /** + * Load a specific plugin + */ + async loadPlugin(pluginInfo) { + try { + let pluginModule; + + if (pluginInfo.type === 'local') { + // Load local plugin using file:// URL + const pluginUrl = pathToFileURL(pluginInfo.path).href; + pluginModule = await import(pluginUrl); + } else { + // Load external plugin (npm module or URL) + pluginModule = await import(pluginInfo.path); + } + + const plugin = pluginModule.default || pluginModule; + + // Validate plugin structure + if (!plugin || typeof plugin !== 'object') { + throw new Error('Plugin must export an object'); + } + + if (!plugin.name) { + plugin.name = pluginInfo.name; + } + + // Register plugin hooks + if (plugin.hooks && typeof plugin.hooks === 'object') { + for (const [hookName, handler] of Object.entries(plugin.hooks)) { + if (typeof handler === 'function') { + this.hooks.hook(hookName, handler.bind(plugin)); + } + } + } + + // Store plugin reference + this.plugins.set(pluginInfo.name, { + ...pluginInfo, + plugin, + loadedAt: new Date() + }); + + this.log(`Loaded plugin: ${pluginInfo.name}`, { + type: pluginInfo.type, + hooks: Object.keys(plugin.hooks || {}) + }); + + } catch (error) { + throw new Error(`Failed to load plugin ${pluginInfo.name}: ${error.message}`); + } + } + + /** + * Call a specific hook with context data + */ + async callHook(hookName, context = {}) { + if (!this.isInitialized && !hookName.includes('plugin:load')) { + this.log(`Plugin system not initialized, skipping hook: ${hookName}`); + return; + } + + try { + const enrichedContext = { + ...context, + projectDir: this.projectDir, + config: this.config, + timestamp: new Date(), + hookName + }; + + this.log(`Calling hook: ${hookName}`, { + contextKeys: Object.keys(enrichedContext), + hookCount: this.hooks.hookMap?.[hookName]?.length || 0 + }); + + await this.hooks.callHook(hookName, enrichedContext); + + } catch (error) { + this.logError(`Hook execution failed: ${hookName}`, error); + // Don't throw - allow CLI to continue + } + } + + /** + * Get information about a specific plugin + */ + getPlugin(name) { + return this.plugins.get(name); + } + + /** + * Get all loaded plugins + */ + getPlugins() { + return Array.from(this.plugins.values()); + } + + /** + * Get all discovered plugins (both enabled and disabled) + */ + getAllPlugins() { + const result = []; + + for (const pluginInfo of this.pluginOrder) { + const loadedPlugin = this.plugins.get(pluginInfo.name); + if (loadedPlugin) { + // Plugin is loaded + result.push(loadedPlugin); + } else { + // Plugin was discovered but not loaded (likely disabled) + result.push({ + name: pluginInfo.name, + type: pluginInfo.type, + path: pluginInfo.path, + enabled: pluginInfo.enabled, + plugin: null // Not loaded, so no plugin object + }); + } + } + + return result; + } + + /** + * Check if a plugin is loaded and enabled + */ + isPluginLoaded(name) { + return this.plugins.has(name); + } + + /** + * Enable a plugin + */ + async enablePlugin(name) { + if (!this.config) { + throw new Error('Plugin system not initialized'); + } + + if (!this.config.plugins) { + this.config.plugins = {}; + } + + if (!this.config.plugins[name]) { + this.config.plugins[name] = {}; + } + + this.config.plugins[name].enabled = true; + await this.saveConfig(); + + // Reload if not currently loaded + if (!this.isPluginLoaded(name)) { + await this.discoverPlugins(); + const pluginInfo = this.pluginOrder.find(p => p.name === name); + if (pluginInfo?.enabled) { + await this.loadPlugin(pluginInfo); + } + } + + this.log(`Enabled plugin: ${name}`); + } + + /** + * Disable a plugin + */ + async disablePlugin(name) { + if (!this.config) { + throw new Error('Plugin system not initialized'); + } + + if (!this.config.plugins) { + this.config.plugins = {}; + } + + if (!this.config.plugins[name]) { + this.config.plugins[name] = {}; + } + + this.config.plugins[name].enabled = false; + await this.saveConfig(); + + // Remove from loaded plugins + if (this.plugins.has(name)) { + await this.callHook('before:plugin:unload', { plugin: this.plugins.get(name) }); + this.plugins.delete(name); + await this.callHook('after:plugin:unload', { pluginName: name }); + } + + this.log(`Disabled plugin: ${name}`); + } + + /** + * Configure a plugin + */ + async configurePlugin(name, config) { + if (!this.config) { + throw new Error('Plugin system not initialized'); + } + + if (!this.config.plugins) { + this.config.plugins = {}; + } + + if (!this.config.plugins[name]) { + this.config.plugins[name] = {}; + } + + Object.assign(this.config.plugins[name], config); + await this.saveConfig(); + + this.log(`Configured plugin: ${name}`, config); + } + + /** + * Save plugin configuration to polyglot.json + */ + async saveConfig() { + if (this.config && this.projectDir) { + const configPath = path.join(this.projectDir, 'polyglot.json'); + await fs.writeJson(configPath, this.config, { spaces: 2 }); + } + } + + /** + * Get hook points and their descriptions + */ + getHookPoints() { + return HOOK_POINTS; + } + + /** + * Get statistics about the plugin system + */ + getStats() { + const hooks = {}; + // Handle case where hookMap might be undefined + if (this.hooks.hookMap) { + for (const [hookName, handlers] of Object.entries(this.hooks.hookMap)) { + hooks[hookName] = handlers.length; + } + } + + const allPlugins = this.getAllPlugins(); + const enabledPlugins = allPlugins.filter(p => p.enabled); + + return { + initialized: this.isInitialized, + projectDir: this.projectDir, + totalPlugins: allPlugins.length, + enabledPlugins: enabledPlugins.length, + hookPoints: Object.keys(HOOK_POINTS).length, + registeredHooks: hooks, + config: this.config?.plugins || {} + }; + } + + /** + * Logging helper + */ + log(message, data = {}) { + if (this.debug) { + console.log(chalk.blue(`[plugins] ${message}`), data); + } + } + + /** + * Error logging helper + */ + logError(message, error) { + console.error(chalk.red(`[plugins] ${message}:`), error?.message || error); + if (this.debug && error?.stack) { + console.error(error.stack); + } + } +} + +// Global plugin system instance +export const pluginSystem = new PluginSystem(); + +// Convenience function to initialize plugins for a project +export async function initializePlugins(projectDir) { + await pluginSystem.initialize(projectDir); +} + +// Convenience function to call hooks +export async function callHook(hookName, context = {}) { + await pluginSystem.callHook(hookName, context); +} + +export default pluginSystem; \ No newline at end of file diff --git a/bin/lib/scaffold.js b/bin/lib/scaffold.js index 59f65b8..832f169 100644 --- a/bin/lib/scaffold.js +++ b/bin/lib/scaffold.js @@ -6,12 +6,19 @@ import url from 'url'; import { execa } from 'execa'; import { renderServicesTable, printBoxMessage } from './ui.js'; import { initializeServiceLogs } from './logs.js'; +import { initializePlugins, callHook } from './plugin-system.js'; const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); // Extracted core scaffold logic so future subcommands (e.g. add service, plugins) can reuse pieces. export async function scaffoldMonorepo(projectNameArg, options) { try { + // Call before:init hook + await callHook('before:init', { + projectName: projectNameArg, + options: { ...options } + }); + // Collect interactive data if arguments / flags not provided let projectName = projectNameArg; const interactiveQuestions = []; @@ -275,7 +282,17 @@ export async function scaffoldMonorepo(projectNameArg, options) { } fs.mkdirSync(projectDir, { recursive: true }); - console.log(chalk.yellow('\nšŸ“ Setting up monorepo structure...')); + // Initialize plugin system for the new project + await initializePlugins(projectDir); + + // Call template copy hooks + await callHook('before:template:copy', { + projectName, + projectDir, + services + }); + + console.log(chalk.yellow('\nšŸ“ Setting up monorepo structure...')); // New structure: services/, gateway/, infra/ const servicesDir = path.join(projectDir, 'services'); const gatewayDir = path.join(projectDir, 'gateway'); @@ -345,6 +362,14 @@ export async function scaffoldMonorepo(projectNameArg, options) { console.log(chalk.green(`āœ… Created ${svcName} (${svcType}) service on port ${svcPort}`)); } + // Call template copy complete hook + await callHook('after:template:copy', { + projectName, + projectDir, + services, + generatedServices: services.map(s => ({ ...s, path: path.join(servicesDir, s.name) })) + }); + const rootPkgPath = path.join(projectDir, 'package.json'); const rootPkg = { name: projectName, @@ -464,12 +489,36 @@ export async function scaffoldMonorepo(projectNameArg, options) { const pm = options.packageManager || 'npm'; // Commander maps --no-install to options.install = false if (options.install !== false) { + await callHook('before:dependencies:install', { + projectName, + projectDir, + packageManager: pm, + services + }); + console.log(chalk.cyan(`\nšŸ“¦ Installing root dependencies using ${pm}...`)); const installCmd = pm === 'yarn' ? ['install'] : pm === 'pnpm' ? ['install'] : pm === 'bun' ? ['install'] : ['install']; try { await execa(pm, installCmd, { cwd: projectDir, stdio: 'inherit' }); + + await callHook('after:dependencies:install', { + projectName, + projectDir, + packageManager: pm, + success: true, + services + }); } catch (e) { console.log(chalk.yellow('āš ļø Failed to install dependencies:', e.message)); + + await callHook('after:dependencies:install', { + projectName, + projectDir, + packageManager: pm, + success: false, + error: e.message, + services + }); } } @@ -497,10 +546,20 @@ export async function scaffoldMonorepo(projectNameArg, options) { name: projectName, preset: options.preset || 'none', packageManager: options.packageManager, - services: services.map(s => ({ name: s.name, type: s.type, port: s.port, path: `services/${s.name}` })) + services: services.map(s => ({ name: s.name, type: s.type, port: s.port, path: `services/${s.name}` })), + plugins: {} }; await fs.writeJSON(path.join(projectDir, 'polyglot.json'), polyglotConfig, { spaces: 2 }); + // Call after:init hook + await callHook('after:init', { + projectName, + projectDir, + services, + config: polyglotConfig, + options + }); + printBoxMessage([ 'šŸŽ‰ Monorepo setup complete!', `cd ${projectName}`, @@ -524,6 +583,17 @@ export async function addService(projectDir, { type, name, port }, options = {}) if (!(await fs.pathExists(configPath))) { throw new Error('polyglot.json not found. Are you in a create-polyglot project?'); } + + // Initialize plugins for this project if not already done + await initializePlugins(projectDir); + + // Call before:service:add hook + await callHook('before:service:add', { + projectDir, + service: { type, name, port }, + options + }); + const cfg = await fs.readJSON(configPath); if (cfg.services.find(s => s.name === name)) { throw new Error(`Service '${name}' already exists.`); @@ -562,10 +632,19 @@ export async function addService(projectDir, { type, name, port }, options = {}) // Initialize logging for the service initializeServiceLogs(dest); - console.log(chalk.green(`āœ… Added service '${name}' (${type}) on port ${port}`)); cfg.services.push({ name, type, port, path: `services/${name}` }); await fs.writeJSON(configPath, cfg, { spaces: 2 }); + // Call after:service:add hook + await callHook('after:service:add', { + projectDir, + service: { type, name, port, path: `services/${name}` }, + config: cfg, + options + }); + + console.log(chalk.green(`āœ… Added service '${name}' (${type}) on port ${port}`)); + // Update compose.yaml (append or create) const composePath = path.join(projectDir, 'compose.yaml'); let composeObj; @@ -606,7 +685,331 @@ export async function scaffoldPlugin(projectDir, pluginName) { const pluginDir = path.join(pluginsDir, pluginName); if (await fs.pathExists(pluginDir)) throw new Error(`Plugin '${pluginName}' already exists.`); await fs.mkdirp(pluginDir); - await fs.writeFile(path.join(pluginDir, 'index.js'), `// Example plugin '${pluginName}'\nexport default {\n name: '${pluginName}',\n hooks: {\n afterInit(ctx){\n // ctx: { projectDir, config }\n console.log('[plugin:${pluginName}] afterInit hook');\n }\n }\n};\n`); - await fs.writeFile(path.join(pluginDir, 'README.md'), `# Plugin ${pluginName}\n\nScaffolded plugin. Implement hooks in index.js.\n`); - console.log(chalk.green(`āœ… Created plugin scaffold '${pluginName}'`)); + + const pluginCode = `// Example plugin '${pluginName}' +// This plugin demonstrates the available hooks and how to use them +export default { + name: '${pluginName}', + version: '1.0.0', + description: 'Generated plugin for ${pluginName}', + + // Plugin initialization (optional) + // Called when the plugin is loaded + init(context) { + this.context = context; + console.log(\`[plugin:\${this.name}] Loaded successfully\`); + }, + + // Plugin configuration (optional) + config: { + // Plugin-specific configuration options + enabled: true, + logLevel: 'info' + }, + + // Hook handlers + hooks: { + // Project initialization hooks + 'before:init': function(ctx) { + console.log(\`[plugin:\${this.name}] Project initialization starting for: \${ctx.projectName}\`); + // You can modify the context or perform pre-initialization tasks + }, + + 'after:init': function(ctx) { + console.log(\`[plugin:\${this.name}] Project '\${ctx.projectName}' initialized successfully\`); + console.log(\`[plugin:\${this.name}] Services created: \${ctx.services.map(s => s.name).join(', ')}\`); + + // Example: Create custom files or modify the project structure + // await this.createCustomFiles(ctx.projectDir); + }, + + // Template handling hooks + 'before:template:copy': function(ctx) { + console.log(\`[plugin:\${this.name}] Copying templates for \${ctx.services.length} services\`); + // You can modify templates before they are copied + }, + + 'after:template:copy': function(ctx) { + console.log(\`[plugin:\${this.name}] Templates copied successfully\`); + // You can post-process generated files + }, + + // Service management hooks + 'before:service:add': function(ctx) { + console.log(\`[plugin:\${this.name}] Adding service: \${ctx.service.name} (\${ctx.service.type})\`); + // Validate service configuration or modify it + if (ctx.service.type === 'node' && !ctx.service.port) { + console.log(\`[plugin:\${this.name}] Setting default port for Node service\`); + ctx.service.port = 3001; + } + }, + + 'after:service:add': function(ctx) { + console.log(\`[plugin:\${this.name}] Service '\${ctx.service.name}' added successfully\`); + // Post-process the new service + }, + + // Development workflow hooks + 'before:dev:start': function(ctx) { + console.log(\`[plugin:\${this.name}] Starting development mode (docker: \${ctx.docker})\`); + // Pre-development setup + }, + + 'after:dev:start': function(ctx) { + console.log(\`[plugin:\${this.name}] Development mode started with \${ctx.processes} processes\`); + // Post-development setup, monitoring, etc. + }, + + 'before:dev:stop': function(ctx) { + console.log(\`[plugin:\${this.name}] Stopping development mode\`); + // Cleanup before stopping + }, + + 'after:dev:stop': function(ctx) { + console.log(\`[plugin:\${this.name}] Development mode stopped\`); + // Final cleanup + }, + + // Admin dashboard hooks + 'before:admin:start': function(ctx) { + console.log(\`[plugin:\${this.name}] Starting admin dashboard\`); + // Pre-admin setup + }, + + 'after:admin:start': function(ctx) { + console.log(\`[plugin:\${this.name}] Admin dashboard started at \${ctx.dashboardUrl}\`); + // Post-admin setup, register custom endpoints, etc. + }, + + // Dependency installation hooks + 'before:dependencies:install': function(ctx) { + console.log(\`[plugin:\${this.name}] Installing dependencies with \${ctx.packageManager}\`); + // Modify package.json or add custom dependencies + }, + + 'after:dependencies:install': function(ctx) { + if (ctx.success) { + console.log(\`[plugin:\${this.name}] Dependencies installed successfully\`); + } else { + console.log(\`[plugin:\${this.name}] Dependencies installation failed: \${ctx.error}\`); + } + // Post-installation tasks + } + }, + + // Plugin methods (optional) + // These can be called by other plugins or the system + methods: { + createCustomFiles: async function(projectDir) { + const fs = await import('fs-extra'); + const path = await import('path'); + + // Example: Create a custom configuration file + const configPath = path.join(projectDir, \`.config/\${this.name}.json\`); + await fs.mkdirp(path.dirname(configPath)); + await fs.writeJSON(configPath, { + plugin: this.name, + version: this.version, + createdAt: new Date().toISOString(), + settings: this.config + }, { spaces: 2 }); + + console.log(\`[plugin:\${this.name}] Created custom config at \${configPath}\`); + }, + + validateProject: function(projectDir) { + // Example validation logic + console.log(\`[plugin:\${this.name}] Validating project at \${projectDir}\`); + return true; + }, + + getPluginInfo: function() { + return { + name: this.name, + version: this.version, + description: this.description, + hooks: Object.keys(this.hooks), + methods: Object.keys(this.methods) + }; + } + }, + + // Plugin lifecycle (optional) + onLoad: function() { + console.log(\`[plugin:\${this.name}] Plugin loaded\`); + }, + + onUnload: function() { + console.log(\`[plugin:\${this.name}] Plugin unloaded\`); + } +}; +`; + + await fs.writeFile(path.join(pluginDir, 'index.js'), pluginCode); + + const readmeContent = `# Plugin ${pluginName} + +Generated plugin for create-polyglot projects. + +## Features + +This plugin provides hooks for various lifecycle events in the create-polyglot workflow: + +### Available Hooks + +- **Project Initialization** + - \`before:init\` - Called before project scaffolding begins + - \`after:init\` - Called after project scaffolding completes + +- **Template Management** + - \`before:template:copy\` - Called before copying service templates + - \`after:template:copy\` - Called after copying service templates + +- **Service Management** + - \`before:service:add\` - Called before adding a new service + - \`after:service:add\` - Called after adding a new service + +- **Development Workflow** + - \`before:dev:start\` - Called before starting dev server(s) + - \`after:dev:start\` - Called after dev server(s) have started + - \`before:dev:stop\` - Called before stopping dev server(s) + - \`after:dev:stop\` - Called after dev server(s) have stopped + +- **Admin Dashboard** + - \`before:admin:start\` - Called before starting admin dashboard + - \`after:admin:start\` - Called after admin dashboard is running + +- **Dependencies** + - \`before:dependencies:install\` - Called before installing dependencies + - \`after:dependencies:install\` - Called after installing dependencies + +## Configuration + +Plugin configuration is stored in \`polyglot.json\`: + +\`\`\`json +{ + "plugins": { + "${pluginName}": { + "enabled": true, + "priority": 0, + "config": { + "logLevel": "info" + } + } + } +} +\`\`\` + +## Usage + +### Enable/Disable Plugin + +\`\`\`bash +# Enable plugin +create-polyglot plugin enable ${pluginName} + +# Disable plugin +create-polyglot plugin disable ${pluginName} +\`\`\` + +### Plugin Configuration + +\`\`\`bash +# Configure plugin +create-polyglot plugin configure ${pluginName} --config '{"logLevel": "debug"}' +\`\`\` + +## Development + +1. Edit \`index.js\` to implement your hook handlers +2. Add any additional methods to the \`methods\` object +3. Update configuration options in the \`config\` object +4. Test your plugin by running create-polyglot commands + +## Hook Context + +Each hook receives a context object with relevant information: + +\`\`\`javascript +{ + projectName, // Name of the project + projectDir, // Absolute path to project directory + services, // Array of service configurations + config, // Project configuration from polyglot.json + timestamp, // Hook execution timestamp + hookName, // Name of the current hook + // ... additional context depending on the hook +} +\`\`\` + +## Best Practices + +1. **Error Handling**: Always wrap async operations in try-catch blocks +2. **Logging**: Use consistent logging with the plugin name prefix +3. **Configuration**: Make your plugin configurable through the config object +4. **Documentation**: Document any new hooks or methods you add +5. **Testing**: Test your plugin with different project configurations + +## Examples + +### Custom File Generation + +\`\`\`javascript +'after:init': async function(ctx) { + const fs = await import('fs-extra'); + const path = await import('path'); + + // Create a custom README section + const readmePath = path.join(ctx.projectDir, 'README.md'); + const customSection = \`\\n## Custom Plugin Features\\n\\nAdded by \${this.name} plugin.\\n\`; + + if (await fs.pathExists(readmePath)) { + const content = await fs.readFile(readmePath, 'utf-8'); + await fs.writeFile(readmePath, content + customSection); + } +} +\`\`\` + +### Service Validation + +\`\`\`javascript +'before:service:add': function(ctx) { + if (ctx.service.type === 'custom') { + // Validate custom service configuration + if (!ctx.service.customConfig) { + throw new Error('Custom service requires customConfig'); + } + } +} +\`\`\` + +### Development Environment Setup + +\`\`\`javascript +'before:dev:start': async function(ctx) { + // Set up environment variables + process.env.PLUGIN_MODE = 'development'; + process.env.PLUGIN_DEBUG = this.config.logLevel === 'debug'; +} +\`\`\` +`; + + await fs.writeFile(path.join(pluginDir, 'README.md'), readmeContent); + + // Create a package.json for the plugin + const packageJson = { + name: `create-polyglot-plugin-${pluginName}`, + version: "1.0.0", + description: `Generated plugin ${pluginName} for create-polyglot`, + main: "index.js", + type: "module", + keywords: ["create-polyglot", "plugin", pluginName], + author: "", + license: "MIT" + }; + + await fs.writeJSON(path.join(pluginDir, 'package.json'), packageJson, { spaces: 2 }); + + console.log(chalk.green(`āœ… Created plugin scaffold '${pluginName}' with comprehensive examples`)); } diff --git a/docs/plugin-system.md b/docs/plugin-system.md new file mode 100644 index 0000000..9593b69 --- /dev/null +++ b/docs/plugin-system.md @@ -0,0 +1,670 @@ +# Plugin System + +The create-polyglot plugin system provides a robust, extensible architecture for customizing and extending the CLI workflow through lifecycle hooks. + +## Overview + +The plugin system allows developers to: + +- **Hook into lifecycle events** during project scaffolding, development, and management +- **Customize project structure** and templates +- **Add custom functionality** to existing commands +- **Integrate with external tools** and services +- **Extend the CLI** with new capabilities + +## Architecture + +The plugin system is built on several key components: + +### 1. Plugin Registry (`PluginSystem` class) +- Manages plugin discovery, loading, and execution +- Handles plugin configuration and dependencies +- Provides error handling and graceful degradation + +### 2. Hook System (powered by `hookable`) +- Defines standardized lifecycle hook points +- Executes hooks with proper context and error handling +- Supports asynchronous hook execution + +### 3. Plugin Configuration +- Stored in `polyglot.json` under the `plugins` section +- Supports priority ordering, enable/disable, and custom config +- Automatically saved and loaded per project + +## Quick Start + +### 1. Create a Plugin + +```bash +# Create a new plugin scaffold +create-polyglot add plugin my-awesome-plugin + +# This creates plugins/my-awesome-plugin/ with: +# - index.js (main plugin file) +# - package.json (plugin metadata) +# - README.md (plugin documentation) +``` + +### 2. Implement Hook Handlers + +```javascript +// plugins/my-awesome-plugin/index.js +export default { + name: 'my-awesome-plugin', + version: '1.0.0', + description: 'Adds awesome features to create-polyglot', + + hooks: { + 'after:init': function(ctx) { + console.log(`[${this.name}] Project ${ctx.projectName} created!`); + // Add custom logic here + }, + + 'before:dev:start': function(ctx) { + console.log(`[${this.name}] Starting development mode`); + // Pre-development setup + } + } +}; +``` + +### 3. Manage Plugins + +```bash +# List all plugins +create-polyglot plugin list + +# Enable/disable plugins +create-polyglot plugin enable my-awesome-plugin +create-polyglot plugin disable my-awesome-plugin + +# Get plugin information +create-polyglot plugin info my-awesome-plugin + +# Configure plugin +create-polyglot plugin configure my-awesome-plugin --priority 10 +``` + +## Hook Lifecycle + +The plugin system provides hooks at key points in the create-polyglot workflow: + +### Project Initialization +- `before:init` - Before project scaffolding starts +- `after:init` - After project scaffolding completes +- `before:template:copy` - Before copying service templates +- `after:template:copy` - After copying service templates +- `before:dependencies:install` - Before installing dependencies +- `after:dependencies:install` - After installing dependencies + +### Service Management +- `before:service:add` - Before adding a new service +- `after:service:add` - After adding a new service +- `before:service:remove` - Before removing a service +- `after:service:remove` - After removing a service + +### Development Workflow +- `before:dev:start` - Before starting dev server(s) +- `after:dev:start` - After dev server(s) have started +- `before:dev:stop` - Before stopping dev server(s) +- `after:dev:stop` - After dev server(s) have stopped + +### Docker & Compose +- `before:docker:build` - Before building Docker images +- `after:docker:build` - After building Docker images +- `before:compose:up` - Before running docker compose up +- `after:compose:up` - After docker compose up completes + +### Hot Reload +- `before:hotreload:start` - Before starting hot reload +- `after:hotreload:start` - After hot reload is active +- `before:hotreload:restart` - Before restarting a service +- `after:hotreload:restart` - After a service restarts + +### Admin Dashboard +- `before:admin:start` - Before starting admin dashboard +- `after:admin:start` - After admin dashboard is running + +### Log Management +- `before:logs:view` - Before viewing logs +- `after:logs:view` - After log viewing session +- `before:logs:clear` - Before clearing logs +- `after:logs:clear` - After clearing logs + +### Plugin Lifecycle +- `before:plugin:load` - Before loading a plugin +- `after:plugin:load` - After loading a plugin +- `before:plugin:unload` - Before unloading a plugin +- `after:plugin:unload` - After unloading a plugin + +## Plugin Structure + +### Basic Plugin + +```javascript +export default { + name: 'plugin-name', + version: '1.0.0', + description: 'Plugin description', + + // Hook handlers + hooks: { + 'hookName': function(context) { + // Hook implementation + } + }, + + // Plugin configuration + config: { + enabled: true, + customOption: 'value' + }, + + // Plugin methods (optional) + methods: { + customMethod() { + // Custom functionality + } + }, + + // Lifecycle callbacks (optional) + onLoad() { + console.log('Plugin loaded'); + }, + + onUnload() { + console.log('Plugin unloaded'); + } +}; +``` + +### Hook Context + +Each hook receives a context object with relevant information: + +```javascript +{ + projectName, // Project name + projectDir, // Absolute path to project directory + services, // Array of service configurations + config, // Project configuration from polyglot.json + timestamp, // Hook execution timestamp + hookName, // Name of the current hook + // ... additional context specific to the hook +} +``` + +#### Context Examples + +**`after:init` context:** +```javascript +{ + projectName: 'my-app', + projectDir: '/path/to/my-app', + services: [ + { name: 'api', type: 'node', port: 3001 }, + { name: 'web', type: 'frontend', port: 3000 } + ], + config: { /* polyglot.json contents */ }, + options: { /* CLI options */ } +} +``` + +**`before:service:add` context:** +```javascript +{ + projectDir: '/path/to/my-app', + service: { type: 'python', name: 'ml-api', port: 3004 }, + options: { /* CLI options */ } +} +``` + +**`before:dev:start` context:** +```javascript +{ + projectDir: '/path/to/my-app', + docker: false, + mode: 'local' +} +``` + +## Plugin Configuration + +### Basic Configuration + +Plugins are configured in `polyglot.json`: + +```json +{ + "plugins": { + "my-plugin": { + "enabled": true, + "priority": 0, + "config": { + "customOption": "value" + } + } + } +} +``` + +### Configuration Options + +- `enabled` - Whether the plugin is active (default: true) +- `priority` - Loading order (higher loads first, default: 0) +- `external` - Path to external plugin (npm package or file path) +- `config` - Plugin-specific configuration object + +### External Plugins + +You can use plugins from npm packages or external files: + +```json +{ + "plugins": { + "external-plugin": { + "external": "create-polyglot-plugin-awesome", + "enabled": true + }, + "local-external": { + "external": "/path/to/plugin.js", + "enabled": true + } + } +} +``` + +## Advanced Features + +### Plugin Dependencies + +Plugins can specify dependencies through priority: + +```javascript +export default { + name: 'dependent-plugin', + + hooks: { + 'after:init': function(ctx) { + // This runs after higher-priority plugins + const corePlugin = this.context.plugins.get('core-plugin'); + if (corePlugin) { + // Use core plugin functionality + } + } + } +}; +``` + +### Conditional Hook Execution + +```javascript +export default { + name: 'conditional-plugin', + + hooks: { + 'before:service:add': function(ctx) { + // Only run for Node.js services + if (ctx.service.type !== 'node') { + return; + } + + // Node.js-specific logic + console.log('Adding Node.js service:', ctx.service.name); + } + } +}; +``` + +### Async Hook Handlers + +```javascript +export default { + name: 'async-plugin', + + hooks: { + 'after:init': async function(ctx) { + const fs = await import('fs-extra'); + const path = await import('path'); + + // Create custom files + const customDir = path.join(ctx.projectDir, 'custom'); + await fs.mkdirp(customDir); + + const config = { + plugin: this.name, + createdAt: new Date().toISOString(), + projectName: ctx.projectName + }; + + await fs.writeJson( + path.join(customDir, 'plugin-config.json'), + config, + { spaces: 2 } + ); + } + } +}; +``` + +### Error Handling + +```javascript +export default { + name: 'error-handling-plugin', + + hooks: { + 'after:init': function(ctx) { + try { + // Potentially risky operation + this.performCustomAction(ctx); + } catch (error) { + console.warn(`[${this.name}] Warning: ${error.message}`); + // Don't throw - allow other plugins and CLI to continue + } + } + }, + + methods: { + performCustomAction(ctx) { + // Custom logic that might fail + throw new Error('Something went wrong'); + } + } +}; +``` + +## Plugin CLI Commands + +### List Plugins + +```bash +# List all plugins with status +create-polyglot plugin list + +# List only enabled plugins +create-polyglot plugin list --enabled-only + +# Output as JSON +create-polyglot plugin list --json +``` + +### Plugin Information + +```bash +# Get detailed plugin information +create-polyglot plugin info my-plugin + +# Output as JSON +create-polyglot plugin info my-plugin --json +``` + +### Enable/Disable Plugins + +```bash +# Enable a plugin +create-polyglot plugin enable my-plugin + +# Disable a plugin +create-polyglot plugin disable my-plugin +``` + +### Configure Plugins + +```bash +# Set plugin priority +create-polyglot plugin configure my-plugin --priority 10 + +# Set custom configuration +create-polyglot plugin configure my-plugin --config '{"debug": true, "apiKey": "xxx"}' + +# Set external plugin +create-polyglot plugin configure my-plugin --external "npm-plugin-package" +``` + +### System Statistics + +```bash +# View plugin system statistics +create-polyglot plugin stats + +# Output as JSON +create-polyglot plugin stats --json +``` + +## Best Practices + +### 1. Error Handling +- Always wrap risky operations in try-catch blocks +- Don't throw errors from hooks unless critical +- Log warnings instead of failing completely + +### 2. Performance +- Keep hook handlers lightweight +- Use async operations when dealing with file system or network +- Cache expensive computations + +### 3. Configuration +- Make plugins configurable through the config object +- Provide sensible defaults +- Document configuration options + +### 4. Logging +- Use consistent logging with plugin name prefix +- Respect debug/quiet modes +- Use appropriate log levels + +### 5. Testing +- Test plugin loading and execution +- Test error scenarios +- Use the provided test utilities + +### 6. Documentation +- Document all hooks and methods +- Provide usage examples +- Keep README up to date + +### 7. Compatibility +- Don't assume specific project structure +- Check for required dependencies/tools +- Gracefully handle missing features + +## Example Plugins + +### Custom Template Plugin + +```javascript +export default { + name: 'custom-template', + + hooks: { + 'after:template:copy': async function(ctx) { + const fs = await import('fs-extra'); + const path = await import('path'); + + // Add custom templates for each service + for (const service of ctx.services) { + if (service.type === 'node') { + const servicePath = path.join(ctx.projectDir, 'services', service.name); + const customFile = path.join(servicePath, 'custom-setup.js'); + + const template = ` + // Custom setup for ${service.name} + console.log('Initializing ${service.name} service'); + + module.exports = { + init: () => { + console.log('Service ${service.name} initialized'); + } + }; + `; + + await fs.writeFile(customFile, template); + } + } + } + } +}; +``` + +### Environment Setup Plugin + +```javascript +export default { + name: 'env-setup', + + hooks: { + 'after:init': async function(ctx) { + const fs = await import('fs-extra'); + const path = await import('path'); + + // Create .env files for each service + for (const service of ctx.services) { + const servicePath = path.join(ctx.projectDir, 'services', service.name); + const envPath = path.join(servicePath, '.env.example'); + + const envContent = [ + `# Environment variables for ${service.name}`, + `NODE_ENV=development`, + `PORT=${service.port}`, + `SERVICE_NAME=${service.name}`, + '' + ].join('\\n'); + + await fs.writeFile(envPath, envContent); + } + + console.log(`[${this.name}] Created .env.example files for all services`); + } + } +}; +``` + +### Monitoring Plugin + +```javascript +export default { + name: 'monitoring', + config: { + healthCheckInterval: 30000, + alertsEnabled: true + }, + + hooks: { + 'after:dev:start': function(ctx) { + if (!this.config.healthCheckInterval) return; + + this.healthCheckTimer = setInterval(() => { + this.performHealthChecks(ctx.services); + }, this.config.healthCheckInterval); + + console.log(`[${this.name}] Health monitoring started`); + }, + + 'before:dev:stop': function(ctx) { + if (this.healthCheckTimer) { + clearInterval(this.healthCheckTimer); + console.log(`[${this.name}] Health monitoring stopped`); + } + } + }, + + methods: { + async performHealthChecks(services) { + for (const service of services) { + try { + const response = await fetch(`http://localhost:${service.port}/health`); + if (!response.ok) { + this.alertServiceDown(service); + } + } catch (error) { + this.alertServiceDown(service, error.message); + } + } + }, + + alertServiceDown(service, error = 'Service unavailable') { + if (this.config.alertsEnabled) { + console.warn(`[${this.name}] šŸ”“ Service ${service.name} is down: ${error}`); + } + } + } +}; +``` + +## Debugging + +### Enable Debug Mode + +Set the `DEBUG_PLUGINS` environment variable to see detailed plugin execution logs: + +```bash +DEBUG_PLUGINS=true create-polyglot init my-app +``` + +### Plugin Inspection + +Use the plugin info command to inspect loaded plugins: + +```bash +create-polyglot plugin info my-plugin +create-polyglot plugin stats +``` + +### Common Issues + +1. **Plugin not loading**: Check file path and syntax +2. **Hooks not executing**: Verify hook name spelling +3. **Configuration not working**: Check `polyglot.json` format +4. **Import errors**: Ensure proper ES module syntax + +## API Reference + +### Plugin System Methods + +- `pluginSystem.initialize(projectDir)` - Initialize for a project +- `pluginSystem.getPlugins()` - Get all loaded plugins +- `pluginSystem.getPlugin(name)` - Get specific plugin +- `pluginSystem.enablePlugin(name)` - Enable a plugin +- `pluginSystem.disablePlugin(name)` - Disable a plugin +- `pluginSystem.configurePlugin(name, config)` - Configure plugin +- `pluginSystem.getStats()` - Get system statistics + +### Hook Utilities + +- `callHook(hookName, context)` - Call a specific hook +- `initializePlugins(projectDir)` - Initialize plugin system +- `HOOK_POINTS` - Available hook points and descriptions + +## Contributing + +To contribute to the plugin system: + +1. Follow the established patterns +2. Add tests for new functionality +3. Update documentation +4. Consider backward compatibility +5. Submit pull requests with clear descriptions + +## Troubleshooting + +### Plugin Loading Issues + +1. Check plugin syntax and structure +2. Verify file permissions +3. Ensure proper export format (ES modules) +4. Check for conflicting plugin names + +### Hook Execution Problems + +1. Verify hook name spelling +2. Check context object usage +3. Handle errors properly +4. Test with minimal plugin first + +### Configuration Issues + +1. Validate JSON syntax in polyglot.json +2. Check plugin name matches directory/config +3. Verify external plugin paths +4. Restart after configuration changes \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index e083787..8a91aa5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "create-polyglot", - "version": "1.14.0", + "version": "1.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "create-polyglot", - "version": "1.14.0", + "version": "1.15.0", "license": "MIT", "dependencies": { "chalk": "^5.6.2", @@ -15,6 +15,7 @@ "degit": "^2.8.4", "execa": "^9.6.0", "fs-extra": "^11.3.2", + "hookable": "^5.5.3", "lodash": "^4.17.21", "prompts": "^2.4.2", "ws": "^8.16.0" @@ -1312,7 +1313,8 @@ }, "node_modules/hookable": { "version": "5.5.3", - "dev": true, + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", "license": "MIT" }, "node_modules/html-void-elements": { diff --git a/package.json b/package.json index e25900e..d21eadc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "create-polyglot", - "version": "1.14.0", + "version": "1.15.0", "description": "Scaffold polyglot microservice monorepos with built-in templates for Node, Python, Go, and more.", "main": "bin/index.js", "scripts": { @@ -54,6 +54,7 @@ "degit": "^2.8.4", "execa": "^9.6.0", "fs-extra": "^11.3.2", + "hookable": "^5.5.3", "lodash": "^4.17.21", "prompts": "^2.4.2", "ws": "^8.16.0" diff --git a/tests/plugin-system.test.js b/tests/plugin-system.test.js new file mode 100644 index 0000000..963add8 --- /dev/null +++ b/tests/plugin-system.test.js @@ -0,0 +1,491 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs-extra'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { pluginSystem, initializePlugins, callHook, HOOK_POINTS } from '../bin/lib/plugin-system.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +let testDirCounter = 0; + +describe('Plugin System', () => { + let testDir; + + beforeEach(async () => { + // Create unique test directory for each test + testDirCounter++; + testDir = path.join(__dirname, `temp-plugin-test-${testDirCounter}-${Date.now()}`); + + // Always reset plugin system state completely + pluginSystem.plugins.clear(); + pluginSystem.pluginOrder = []; + pluginSystem.isInitialized = false; + pluginSystem.projectDir = null; + pluginSystem.config = null; + + // Reset the hooks system by creating a new instance + const { createHooks } = await import('hookable'); + pluginSystem.hooks = createHooks(); + + // Create test directory + await fs.mkdirp(testDir); + + // Create basic polyglot.json + const config = { + name: 'test-project', + services: [ + { name: 'api', type: 'node', port: 3001 }, + { name: 'web', type: 'frontend', port: 3000 } + ], + plugins: {} + }; + await fs.writeJson(path.join(testDir, 'polyglot.json'), config); + }); + + afterEach(async () => { + // Clean up test directory + await fs.remove(testDir); + }); + + describe('Core Plugin System', () => { + it('should initialize with empty state', () => { + expect(pluginSystem.isInitialized).toBe(false); + expect(pluginSystem.plugins.size).toBe(0); + expect(pluginSystem.pluginOrder).toEqual([]); + }); + + it('should initialize for a project directory', async () => { + await initializePlugins(testDir); + + expect(pluginSystem.isInitialized).toBe(true); + expect(pluginSystem.projectDir).toBe(testDir); + expect(pluginSystem.config).toBeDefined(); + expect(pluginSystem.config.name).toBe('test-project'); + }); + + it('should handle missing polyglot.json gracefully', async () => { + await fs.remove(path.join(testDir, 'polyglot.json')); + + await initializePlugins(testDir); + + expect(pluginSystem.isInitialized).toBe(true); + expect(pluginSystem.config).toEqual({ plugins: {} }); + }); + }); + + describe('Plugin Discovery and Loading', () => { + it('should discover local plugins', async () => { + // Create a test plugin + const pluginDir = path.join(testDir, '.polyglot', 'plugins', 'test-plugin'); + await fs.mkdirp(pluginDir); + + const pluginCode = ` + export default { + name: 'test-plugin', + version: '1.0.0', + hooks: { + 'after:init': function(ctx) { + this.testHookCalled = true; + } + } + }; + `; + + await fs.writeFile(path.join(pluginDir, 'index.js'), pluginCode); + + await initializePlugins(testDir); + + expect(pluginSystem.pluginOrder).toHaveLength(1); + expect(pluginSystem.pluginOrder[0].name).toBe('test-plugin'); + expect(pluginSystem.pluginOrder[0].type).toBe('local'); + expect(pluginSystem.pluginOrder[0].enabled).toBe(true); + }); + + it('should respect plugin enabled/disabled state', async () => { + // Create disabled plugin + const pluginDir = path.join(testDir, '.polyglot', 'plugins', 'disabled-plugin'); + await fs.mkdirp(pluginDir); + + const pluginCode = ` + export default { + name: 'disabled-plugin', + hooks: {} + }; + `; + + await fs.writeFile(path.join(pluginDir, 'index.js'), pluginCode); + + // Update config to disable plugin + const config = await fs.readJson(path.join(testDir, 'polyglot.json')); + config.plugins = { + 'disabled-plugin': { enabled: false } + }; + await fs.writeJson(path.join(testDir, 'polyglot.json'), config); + + await initializePlugins(testDir); + + expect(pluginSystem.plugins.size).toBe(0); // Should not load disabled plugin + }); + + it('should load plugins with priority ordering', async () => { + // Create multiple plugins + const plugin1Dir = path.join(testDir, '.polyglot', 'plugins', 'plugin-1'); + const plugin2Dir = path.join(testDir, '.polyglot', 'plugins', 'plugin-2'); + await fs.mkdirp(plugin1Dir); + await fs.mkdirp(plugin2Dir); + + await fs.writeFile(path.join(plugin1Dir, 'index.js'), 'export default { name: "plugin-1" };'); + await fs.writeFile(path.join(plugin2Dir, 'index.js'), 'export default { name: "plugin-2" };'); + + // Set priorities + const config = await fs.readJson(path.join(testDir, 'polyglot.json')); + config.plugins = { + 'plugin-1': { priority: 1 }, + 'plugin-2': { priority: 10 } // Higher priority should load first + }; + await fs.writeJson(path.join(testDir, 'polyglot.json'), config); + + await initializePlugins(testDir); + + expect(pluginSystem.pluginOrder[0].name).toBe('plugin-2'); // Higher priority first + expect(pluginSystem.pluginOrder[1].name).toBe('plugin-1'); + }); + + it('should handle plugin loading errors gracefully', async () => { + const pluginDir = path.join(testDir, '.polyglot', 'plugins', 'broken-plugin'); + await fs.mkdirp(pluginDir); + + // Create invalid plugin code + await fs.writeFile(path.join(pluginDir, 'index.js'), 'this is not valid javascript'); + + await initializePlugins(testDir); + + expect(pluginSystem.plugins.size).toBe(0); + expect(pluginSystem.isInitialized).toBe(true); + }); + }); + + describe('Hook Execution', () => { + let testResults; + + beforeEach(() => { + testResults = []; + }); + + it('should execute hooks in loaded plugins', async () => { + const pluginDir = path.join(testDir, '.polyglot', 'plugins', 'hook-test'); + await fs.mkdirp(pluginDir); + + const pluginCode = ` + export default { + name: 'hook-test', + hooks: { + 'after:init': function(ctx) { + global.testResults = global.testResults || []; + global.testResults.push('hook-executed'); + } + } + }; + `; + + await fs.writeFile(path.join(pluginDir, 'index.js'), pluginCode); + await initializePlugins(testDir); + + global.testResults = []; + await callHook('after:init', { projectName: 'test' }); + + expect(global.testResults).toContain('hook-executed'); + }); + + it('should provide correct context to hooks', async () => { + const pluginDir = path.join(testDir, '.polyglot', 'plugins', 'context-test'); + await fs.mkdirp(pluginDir); + + const pluginCode = ` + export default { + name: 'context-test', + hooks: { + 'before:init': function(ctx) { + global.testContext = ctx; + } + } + }; + `; + + await fs.writeFile(path.join(pluginDir, 'index.js'), pluginCode); + await initializePlugins(testDir); + + const testContext = { + projectName: 'test-project', + customData: 'test-value' + }; + + await callHook('before:init', testContext); + + expect(global.testContext).toBeDefined(); + expect(global.testContext.projectName).toBe('test-project'); + expect(global.testContext.customData).toBe('test-value'); + expect(global.testContext.projectDir).toBe(testDir); + expect(global.testContext.timestamp).toBeDefined(); + expect(global.testContext.hookName).toBe('before:init'); + }); + + it('should handle hook execution errors gracefully', async () => { + const pluginDir = path.join(testDir, '.polyglot', 'plugins', 'error-test'); + await fs.mkdirp(pluginDir); + + const pluginCode = ` + export default { + name: 'error-test', + hooks: { + 'after:init': function(ctx) { + throw new Error('Test error'); + } + } + }; + `; + + await fs.writeFile(path.join(pluginDir, 'index.js'), pluginCode); + await initializePlugins(testDir); + + // Should not throw + await expect(callHook('after:init', { projectName: 'test' })).resolves.toBeUndefined(); + }); + + it('should execute multiple hooks in order', async () => { + const plugin1Dir = path.join(testDir, '.polyglot', 'plugins', 'plugin-1'); + const plugin2Dir = path.join(testDir, '.polyglot', 'plugins', 'plugin-2'); + await fs.mkdirp(plugin1Dir); + await fs.mkdirp(plugin2Dir); + + const plugin1Code = ` + export default { + name: 'plugin-1', + hooks: { + 'after:init': function(ctx) { + global.testOrder = global.testOrder || []; + global.testOrder.push('plugin-1'); + } + } + }; + `; + + const plugin2Code = ` + export default { + name: 'plugin-2', + hooks: { + 'after:init': function(ctx) { + global.testOrder = global.testOrder || []; + global.testOrder.push('plugin-2'); + } + } + }; + `; + + await fs.writeFile(path.join(plugin1Dir, 'index.js'), plugin1Code); + await fs.writeFile(path.join(plugin2Dir, 'index.js'), plugin2Code); + + await initializePlugins(testDir); + + // Verify plugins are loaded + expect(pluginSystem.plugins.size).toBe(2); + expect(pluginSystem.isPluginLoaded('plugin-1')).toBe(true); + expect(pluginSystem.isPluginLoaded('plugin-2')).toBe(true); + + global.testOrder = []; + await callHook('after:init', { projectName: 'test' }); + + expect(global.testOrder).toHaveLength(2); + expect(global.testOrder).toContain('plugin-1'); + expect(global.testOrder).toContain('plugin-2'); + }); + }); + + describe('Plugin Management', () => { + beforeEach(async () => { + // Create test plugin + const pluginDir = path.join(testDir, '.polyglot', 'plugins', 'management-test'); + await fs.mkdirp(pluginDir); + + const pluginCode = ` + export default { + name: 'management-test', + version: '1.0.0', + hooks: {} + }; + `; + + await fs.writeFile(path.join(pluginDir, 'index.js'), pluginCode); + }); + + it('should enable a plugin', async () => { + // Start with disabled plugin + const config = await fs.readJson(path.join(testDir, 'polyglot.json')); + config.plugins = { + 'management-test': { enabled: false } + }; + await fs.writeJson(path.join(testDir, 'polyglot.json'), config); + + await initializePlugins(testDir); + expect(pluginSystem.isPluginLoaded('management-test')).toBe(false); + + await pluginSystem.enablePlugin('management-test'); + expect(pluginSystem.isPluginLoaded('management-test')).toBe(true); + + // Check config was updated + const updatedConfig = await fs.readJson(path.join(testDir, 'polyglot.json')); + expect(updatedConfig.plugins['management-test'].enabled).toBe(true); + }); + + it('should disable a plugin', async () => { + await initializePlugins(testDir); + expect(pluginSystem.isPluginLoaded('management-test')).toBe(true); + + await pluginSystem.disablePlugin('management-test'); + expect(pluginSystem.isPluginLoaded('management-test')).toBe(false); + + // Check config was updated + const updatedConfig = await fs.readJson(path.join(testDir, 'polyglot.json')); + expect(updatedConfig.plugins['management-test'].enabled).toBe(false); + }); + + it('should configure a plugin', async () => { + await initializePlugins(testDir); + + const newConfig = { + priority: 5, + customSetting: 'test-value' + }; + + await pluginSystem.configurePlugin('management-test', newConfig); + + // Check config was updated + const updatedConfig = await fs.readJson(path.join(testDir, 'polyglot.json')); + expect(updatedConfig.plugins['management-test'].priority).toBe(5); + expect(updatedConfig.plugins['management-test'].customSetting).toBe('test-value'); + }); + + it('should get plugin information', async () => { + await initializePlugins(testDir); + + const plugin = pluginSystem.getPlugin('management-test'); + expect(plugin).toBeDefined(); + expect(plugin.name).toBe('management-test'); + expect(plugin.type).toBe('local'); + expect(plugin.enabled).toBe(true); + expect(plugin.plugin.version).toBe('1.0.0'); + }); + + it('should get all plugins', async () => { + await initializePlugins(testDir); + + const plugins = pluginSystem.getPlugins(); + expect(plugins).toHaveLength(1); + expect(plugins[0].name).toBe('management-test'); + }); + + it('should get system statistics', async () => { + await initializePlugins(testDir); + + const stats = pluginSystem.getStats(); + expect(stats.initialized).toBe(true); + expect(stats.projectDir).toBe(testDir); + expect(stats.totalPlugins).toBe(1); + expect(stats.enabledPlugins).toBe(1); + expect(stats.hookPoints).toBe(Object.keys(HOOK_POINTS).length); + }); + }); + + describe('Hook Points', () => { + it('should define all expected hook points', () => { + expect(HOOK_POINTS).toBeDefined(); + expect(typeof HOOK_POINTS).toBe('object'); + + const expectedHooks = [ + 'before:init', + 'after:init', + 'before:template:copy', + 'after:template:copy', + 'before:dependencies:install', + 'after:dependencies:install', + 'before:service:add', + 'after:service:add', + 'before:service:remove', + 'after:service:remove', + 'before:dev:start', + 'after:dev:start', + 'before:dev:stop', + 'after:dev:stop', + 'before:docker:build', + 'after:docker:build', + 'before:compose:up', + 'after:compose:up', + 'before:hotreload:start', + 'after:hotreload:start', + 'before:hotreload:restart', + 'after:hotreload:restart', + 'before:admin:start', + 'after:admin:start', + 'before:logs:view', + 'after:logs:view', + 'before:logs:clear', + 'after:logs:clear', + 'before:plugin:load', + 'after:plugin:load', + 'before:plugin:unload', + 'after:plugin:unload' + ]; + + for (const hook of expectedHooks) { + expect(HOOK_POINTS[hook]).toBeDefined(); + expect(typeof HOOK_POINTS[hook]).toBe('string'); + } + }); + }); + + describe('External Plugins', () => { + it('should handle external plugin configuration', async () => { + const config = await fs.readJson(path.join(testDir, 'polyglot.json')); + config.plugins = { + 'external-plugin': { + external: 'some-npm-package', + enabled: true, + priority: 10 + } + }; + await fs.writeJson(path.join(testDir, 'polyglot.json'), config); + + await initializePlugins(testDir); + + // Should discover but not load (since package doesn't exist) + expect(pluginSystem.pluginOrder).toHaveLength(1); + expect(pluginSystem.pluginOrder[0].type).toBe('external'); + expect(pluginSystem.pluginOrder[0].path).toBe('some-npm-package'); + }); + }); + + describe('Error Handling', () => { + it('should handle initialization without crashing', async () => { + // Test with invalid directory + await expect(initializePlugins('/nonexistent/directory')).resolves.toBeUndefined(); + }); + + it('should handle hook calls before initialization', async () => { + // Should not crash when plugin system not initialized + await expect(callHook('after:init', {})).resolves.toBeUndefined(); + }); + + it('should handle invalid plugin structure', async () => { + const pluginDir = path.join(testDir, '.polyglot', 'plugins', 'invalid-plugin'); + await fs.mkdirp(pluginDir); + + // Plugin that exports non-object + await fs.writeFile(path.join(pluginDir, 'index.js'), 'export default "not an object";'); + + await initializePlugins(testDir); + + // Should continue working despite invalid plugin + expect(pluginSystem.isInitialized).toBe(true); + expect(pluginSystem.plugins.size).toBe(0); + }); + }); +}); \ No newline at end of file diff --git a/tests/service-controls.test.js b/tests/service-controls.test.js index 0f002a5..69bf240 100644 --- a/tests/service-controls.test.js +++ b/tests/service-controls.test.js @@ -127,14 +127,28 @@ process.on('unhandledRejection', (reason, promise) => { // Test starting the service let startResponse = await makeServiceRequest('start', 'POST', { serviceName: 'test-api' }); + console.log('Start response status:', startResponse.status); + console.log('Start response ok:', startResponse.ok); + + if (!startResponse.ok) { + const errorText = await startResponse.text(); + console.log('Start error response:', errorText); + } + expect(startResponse.ok).toBe(true); let startResult = await startResponse.json(); + console.log('Start result:', startResult); expect(startResult.success).toBe(true); expect(startResult.message).toContain('starting'); // Wait for service to start await new Promise(resolve => setTimeout(resolve, 5000)); + // Check service status before stopping + let statusCheckResponse = await makeServiceRequest('status'); + const statusText = await statusCheckResponse.text(); + console.log('Status check response:', statusText); + // Verify service is running by checking health endpoint try { const healthResponse = await fetch('http://localhost:19999/health', { @@ -143,6 +157,7 @@ process.on('unhandledRejection', (reason, promise) => { if (healthResponse.ok) { const healthData = await healthResponse.json(); expect(healthData.status).toBe('ok'); + console.log('Health check successful:', healthData); } } catch (error) { // Service might not be fully started yet, that's okay for this test @@ -151,29 +166,31 @@ process.on('unhandledRejection', (reason, promise) => { // Test stopping the service (handle case where service might have exited) let stopResponse = await makeServiceRequest('stop', 'POST', { serviceName: 'test-api' }); + console.log('Stop response status:', stopResponse.status); + console.log('Stop response ok:', stopResponse.ok); if (!stopResponse.ok) { - // Check if it's the "not running" error which can happen in test isolation - const errorBody = await stopResponse.text(); - const errorData = JSON.parse(errorBody); + const errorText = await stopResponse.text(); + console.log('Stop error response:', errorText); - if (errorData.error?.includes('not running')) { - // Service was not running - this can happen in test isolation, skip stop test - console.log('Service exited before stop test - this is expected in some test environments'); + // If the stop fails because service isn't running, that's actually expected + // in a test environment where services might exit immediately + if (errorText.includes('Service test-api is not running')) { + console.log('Service already stopped (expected in test environment)'); + // This is okay - the service likely exited immediately which is normal in tests } else { - // It's a different error, fail the test + // Re-throw if it's a different error expect(stopResponse.ok).toBe(true); } } else { - // Stop succeeded, verify the response let stopResult = await stopResponse.json(); expect(stopResult.success).toBe(true); expect(stopResult.message).toContain('stopped'); - - // Wait for stop to complete - await new Promise(resolve => setTimeout(resolve, 1000)); } + // Wait for stop to complete + await new Promise(resolve => setTimeout(resolve, 1000)); + // Test restarting the service let restartResponse = await makeServiceRequest('restart', 'POST', { serviceName: 'test-api' }); expect(restartResponse.ok).toBe(true);