UNPKG

aiwg

Version:

Cognitive architecture for AI-augmented software development with structured memory, ensemble validation, and closed-loop correction. FAIR-aligned artifacts, 84% cost reduction via human-in-the-loop, standards adopted by 100+ organizations.

1,586 lines (1,396 loc) 47.8 kB
/** * PluginRegistry - CRUD operations for plugin registry management * * Provides atomic, thread-safe operations for managing installed plugins catalog. * Supports frameworks, add-ons, and extensions with health monitoring. * Implements file-based locking, JSON schema validation, and graceful error handling. * * @module tools/workspace/registry-manager * @version 2.0.0 * @since 2025-10-19 * * @example * // Initialize registry * const registry = new PluginRegistry(); * await registry.initialize(); * * // Add framework * await registry.addPlugin({ * id: 'sdlc-complete', * type: 'framework', * name: 'SDLC Complete Framework', * version: '1.0.0', * 'install-date': new Date().toISOString(), * 'repo-path': 'frameworks/sdlc-complete/repo/', * projects: [], * health: 'healthy', * 'health-checked': new Date().toISOString() * }); * * // Query plugins * const isInstalled = await registry.isInstalled('sdlc-complete'); * const plugin = await registry.getPlugin('sdlc-complete'); * const allPlugins = await registry.listPlugins(); * const frameworks = await registry.getByType('framework'); * const healthy = await registry.getHealthy(); * * @errors * - PluginNotFoundError: Plugin ID not in registry * - InvalidSchemaError: Registry JSON schema validation failed * - RegistryLockError: Failed to acquire lock after retries * - DuplicatePluginError: Plugin ID already exists */ import fs from 'fs/promises'; import path from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // Custom error classes class PluginNotFoundError extends Error { constructor(pluginId) { super(`Plugin '${pluginId}' not found in registry. Install via: aiwg -deploy-framework ${pluginId}`); this.name = 'PluginNotFoundError'; this.pluginId = pluginId; } } // Backward compatibility alias class FrameworkNotFoundError extends PluginNotFoundError { constructor(frameworkId) { super(frameworkId); this.name = 'FrameworkNotFoundError'; this.frameworkId = frameworkId; } } class InvalidSchemaError extends Error { constructor(message, errors = []) { super(`Registry schema validation failed: ${message}`); this.name = 'InvalidSchemaError'; this.errors = errors; } } class RegistryLockError extends Error { constructor(message) { super(`Failed to acquire registry lock: ${message}`); this.name = 'RegistryLockError'; } } class DuplicatePluginError extends Error { constructor(pluginId) { super(`Plugin '${pluginId}' already exists in registry`); this.name = 'DuplicatePluginError'; this.pluginId = pluginId; } } // Backward compatibility alias class DuplicateFrameworkError extends DuplicatePluginError { constructor(frameworkId) { super(frameworkId); this.name = 'DuplicateFrameworkError'; this.frameworkId = frameworkId; } } /** * PluginRegistry - Manages installed plugins catalog * * Provides CRUD operations with atomic writes, file locking, and schema validation. * Supports frameworks, add-ons, and extensions with health monitoring. * Registry stored as JSON at .aiwg/frameworks/registry.json * * @class */ export class PluginRegistry { /** * Create a PluginRegistry instance * * @param {string} [registryPath='.aiwg/frameworks/registry.json'] - Absolute or relative path to registry file * * @example * const registry = new PluginRegistry(); * const customRegistry = new PluginRegistry('/custom/path/registry.json'); */ constructor(registryPath = '.aiwg/frameworks/registry.json') { this.registryPath = path.resolve(registryPath); this.lockPath = `${this.registryPath}.lock`; this.maxLockRetries = 3; this.lockRetryDelay = 100; // milliseconds this.schemaVersion = '1.0'; } // =========================== // CRUD Operations // =========================== /** * Initialize empty registry if not exists * * Creates .aiwg/frameworks/ directory and empty registry.json with valid schema. * Idempotent - safe to call multiple times. * * @returns {Promise<void>} * @throws {Error} If directory creation or file write fails * * @example * await registry.initialize(); * // Registry created at .aiwg/frameworks/registry.json */ async initialize() { const registryDir = path.dirname(this.registryPath); // Create directory if missing await fs.mkdir(registryDir, { recursive: true }); // Check if registry already exists try { await fs.access(this.registryPath); console.debug(`[PluginRegistry] Registry already exists at ${this.registryPath}`); return; } catch { // Registry doesn't exist, create it } // Create empty registry const emptyRegistry = { version: this.schemaVersion, plugins: [] }; await this._atomicWrite(emptyRegistry); console.debug(`[PluginRegistry] Initialized empty registry at ${this.registryPath}`); } /** * Add new plugin to registry * * @param {Object} pluginMetadata - Plugin metadata object * @param {string} pluginMetadata.id - Plugin ID (kebab-case, e.g., 'sdlc-complete') * @param {string} pluginMetadata.type - Plugin type ('framework' | 'add-on' | 'extension') * @param {string} pluginMetadata.name - Human-readable plugin name * @param {string} pluginMetadata.version - Semantic version (e.g., '1.0.0') * @param {string} pluginMetadata['install-date'] - ISO 8601 timestamp * @param {string} pluginMetadata['repo-path'] - Relative path to plugin repo directory * @param {string} [pluginMetadata['parent-framework']] - Parent framework ID (for add-ons) * @param {string} [pluginMetadata.extends] - Extended framework ID (for extensions) * @param {string[]} [pluginMetadata.projects=[]] - Array of project IDs (frameworks only) * @param {string[]} [pluginMetadata.campaigns=[]] - Array of campaign IDs (marketing) * @param {string[]} [pluginMetadata.stories=[]] - Array of story IDs (agile) * @param {string} [pluginMetadata.health='unknown'] - Health status ('healthy' | 'warning' | 'error' | 'unknown') * @param {string} [pluginMetadata['health-checked']] - ISO 8601 timestamp of last health check * * @returns {Promise<void>} * @throws {DuplicatePluginError} If plugin ID already exists * @throws {InvalidSchemaError} If metadata validation fails * * @example * await registry.addPlugin({ * id: 'sdlc-complete', * type: 'framework', * name: 'SDLC Complete Framework', * version: '1.0.0', * 'install-date': '2025-10-19T12:00:00Z', * 'repo-path': 'frameworks/sdlc-complete/repo/', * projects: [], * health: 'healthy', * 'health-checked': '2025-10-19T12:00:00Z' * }); */ async addPlugin(pluginMetadata) { // Validate plugin metadata this._validatePluginMetadata(pluginMetadata); await this._acquireLock(); try { const registry = await this._readRegistry(); // Check for duplicate if (registry.plugins.some(p => p.id === pluginMetadata.id)) { throw new DuplicatePluginError(pluginMetadata.id); } // Add default health status if missing if (!pluginMetadata.health) { pluginMetadata.health = 'unknown'; } // Add plugin registry.plugins.push(pluginMetadata); await this._atomicWrite(registry); console.debug(`[PluginRegistry] Added plugin: ${pluginMetadata.id} (${pluginMetadata.type})`); } finally { await this._releaseLock(); } } /** * Update existing plugin metadata * * @param {string} pluginId - Plugin ID to update * @param {Object} updates - Partial plugin metadata to merge * * @returns {Promise<void>} * @throws {PluginNotFoundError} If plugin ID not found * @throws {InvalidSchemaError} If updated metadata fails validation * * @example * await registry.updatePlugin('sdlc-complete', { * version: '1.1.0', * projects: ['plugin-system', 'auth-service'] * }); */ async updatePlugin(pluginId, updates) { this._validatePluginId(pluginId); await this._acquireLock(); try { const registry = await this._readRegistry(); const pluginIndex = registry.plugins.findIndex(p => p.id === pluginId); if (pluginIndex === -1) { throw new PluginNotFoundError(pluginId); } // Merge updates const updated = { ...registry.plugins[pluginIndex], ...updates }; // Validate merged metadata this._validatePluginMetadata(updated); registry.plugins[pluginIndex] = updated; await this._atomicWrite(registry); console.debug(`[PluginRegistry] Updated plugin: ${pluginId}`); } finally { await this._releaseLock(); } } /** * Remove plugin from registry * * @param {string} pluginId - Plugin ID to remove * * @returns {Promise<void>} * @throws {PluginNotFoundError} If plugin ID not found * * @example * await registry.removePlugin('old-plugin'); */ async removePlugin(pluginId) { this._validatePluginId(pluginId); await this._acquireLock(); try { const registry = await this._readRegistry(); const pluginIndex = registry.plugins.findIndex(p => p.id === pluginId); if (pluginIndex === -1) { throw new PluginNotFoundError(pluginId); } // Remove plugin registry.plugins.splice(pluginIndex, 1); await this._atomicWrite(registry); console.debug(`[PluginRegistry] Removed plugin: ${pluginId}`); } finally { await this._releaseLock(); } } /** * Get single plugin metadata * * @param {string} pluginId - Plugin ID to retrieve * * @returns {Promise<Object>} Plugin metadata object * @throws {PluginNotFoundError} If plugin ID not found * * @example * const plugin = await registry.getPlugin('sdlc-complete'); * console.log(plugin.version); // "1.0.0" */ async getPlugin(pluginId) { this._validatePluginId(pluginId); const registry = await this._readRegistry(); const plugin = registry.plugins.find(p => p.id === pluginId); if (!plugin) { throw new PluginNotFoundError(pluginId); } return plugin; } /** * List all installed plugins * * @returns {Promise<Object[]>} Array of plugin metadata objects * * @example * const plugins = await registry.listPlugins(); * plugins.forEach(p => console.log(p.id, p.type, p.name)); */ async listPlugins() { const registry = await this._readRegistry(); return registry.plugins; } // =========================== // Query Operations // =========================== /** * Check if plugin is installed * * @param {string} pluginId - Plugin ID to check * * @returns {Promise<boolean>} True if plugin exists in registry * * @example * if (await registry.isInstalled('sdlc-complete')) { * console.log('SDLC framework ready'); * } */ async isInstalled(pluginId) { this._validatePluginId(pluginId); try { const registry = await this._readRegistry(); return registry.plugins.some(p => p.id === pluginId); } catch (error) { // Registry doesn't exist = no plugins installed if (error.code === 'ENOENT') { return false; } throw error; } } /** * Get plugins by type * * @param {string} type - Plugin type ('framework' | 'add-on' | 'extension') * * @returns {Promise<Object[]>} Array of plugins matching type * * @example * const frameworks = await registry.getByType('framework'); * const addOns = await registry.getByType('add-on'); */ async getByType(type) { if (!['framework', 'add-on', 'extension'].includes(type)) { throw new Error(`Invalid plugin type: ${type}. Must be 'framework', 'add-on', or 'extension'`); } const registry = await this._readRegistry(); return registry.plugins.filter(p => p.type === type); } /** * Get all healthy plugins * * @returns {Promise<Object[]>} Array of plugins with health: 'healthy' * * @example * const healthy = await registry.getHealthy(); */ async getHealthy() { const registry = await this._readRegistry(); return registry.plugins.filter(p => p.health === 'healthy'); } /** * Get plugins with errors * * @returns {Promise<Object[]>} Array of plugins with health: 'error' * * @example * const errors = await registry.getErrors(); * if (errors.length > 0) { * console.warn('Plugins with errors:', errors.map(p => p.id)); * } */ async getErrors() { const registry = await this._readRegistry(); return registry.plugins.filter(p => p.health === 'error'); } /** * Get add-ons for specific framework * * @param {string} frameworkId - Framework ID * * @returns {Promise<Object[]>} Array of add-ons extending this framework * * @example * const addOns = await registry.getAddOnsFor('sdlc-complete'); * // [{ id: 'gdpr-compliance', type: 'add-on', parent-framework: 'sdlc-complete', ... }] */ async getAddOnsFor(frameworkId) { this._validatePluginId(frameworkId); const registry = await this._readRegistry(); return registry.plugins.filter( p => p.type === 'add-on' && p['parent-framework'] === frameworkId ); } /** * Get extensions for specific framework * * @param {string} frameworkId - Framework ID * * @returns {Promise<Object[]>} Array of extensions extending this framework * * @example * const extensions = await registry.getExtensionsFor('sdlc-complete'); */ async getExtensionsFor(frameworkId) { this._validatePluginId(frameworkId); const registry = await this._readRegistry(); return registry.plugins.filter( p => p.type === 'extension' && p.extends === frameworkId ); } /** * Get projects associated with framework * * @param {string} frameworkId - Framework ID * * @returns {Promise<string[]>} Array of project IDs * @throws {PluginNotFoundError} If framework ID not found * * @example * const projects = await registry.getProjects('sdlc-complete'); * // ['plugin-system', 'auth-service'] */ async getProjects(frameworkId) { const plugin = await this.getPlugin(frameworkId); if (plugin.type !== 'framework') { throw new Error(`Plugin '${frameworkId}' is not a framework (type: ${plugin.type})`); } return plugin.projects || []; } /** * Add project to framework * * @param {string} frameworkId - Framework ID * @param {string} projectId - Project ID to add * * @returns {Promise<void>} * @throws {PluginNotFoundError} If framework ID not found * * @example * await registry.addProject('sdlc-complete', 'new-project'); */ async addProject(frameworkId, projectId) { this._validatePluginId(frameworkId); this._validateProjectId(projectId); await this._acquireLock(); try { const registry = await this._readRegistry(); const plugin = registry.plugins.find(p => p.id === frameworkId); if (!plugin) { throw new PluginNotFoundError(frameworkId); } if (plugin.type !== 'framework') { throw new Error(`Plugin '${frameworkId}' is not a framework (type: ${plugin.type})`); } // Initialize projects array if missing if (!plugin.projects) { plugin.projects = []; } // Add project if not already present if (!plugin.projects.includes(projectId)) { plugin.projects.push(projectId); await this._atomicWrite(registry); console.debug(`[PluginRegistry] Added project '${projectId}' to framework '${frameworkId}'`); } } finally { await this._releaseLock(); } } /** * Remove project from framework * * @param {string} frameworkId - Framework ID * @param {string} projectId - Project ID to remove * * @returns {Promise<void>} * @throws {PluginNotFoundError} If framework ID not found * * @example * await registry.removeProject('sdlc-complete', 'completed-project'); */ async removeProject(frameworkId, projectId) { this._validatePluginId(frameworkId); this._validateProjectId(projectId); await this._acquireLock(); try { const registry = await this._readRegistry(); const plugin = registry.plugins.find(p => p.id === frameworkId); if (!plugin) { throw new PluginNotFoundError(frameworkId); } if (plugin.type !== 'framework') { throw new Error(`Plugin '${frameworkId}' is not a framework (type: ${plugin.type})`); } if (plugin.projects) { const projectIndex = plugin.projects.indexOf(projectId); if (projectIndex !== -1) { plugin.projects.splice(projectIndex, 1); await this._atomicWrite(registry); console.debug(`[PluginRegistry] Removed project '${projectId}' from framework '${frameworkId}'`); } } } finally { await this._releaseLock(); } } // =========================== // Backward Compatibility (Framework-specific methods) // =========================== /** * Add new framework to registry (backward compatibility) * * @deprecated Use addPlugin() instead * @param {Object} frameworkMetadata - Framework metadata object * @returns {Promise<void>} */ async addFramework(frameworkMetadata) { // Auto-add type field if missing if (!frameworkMetadata.type) { frameworkMetadata.type = 'framework'; } return this.addPlugin(frameworkMetadata); } /** * Update framework metadata (backward compatibility) * * @deprecated Use updatePlugin() instead * @param {string} frameworkId - Framework ID * @param {Object} updates - Updates to apply * @returns {Promise<void>} */ async updateFramework(frameworkId, updates) { return this.updatePlugin(frameworkId, updates); } /** * Remove framework (backward compatibility) * * @deprecated Use removePlugin() instead * @param {string} frameworkId - Framework ID * @returns {Promise<void>} */ async removeFramework(frameworkId) { return this.removePlugin(frameworkId); } /** * Get framework metadata (backward compatibility) * * @deprecated Use getPlugin() instead * @param {string} frameworkId - Framework ID * @returns {Promise<Object>} */ async getFramework(frameworkId) { return this.getPlugin(frameworkId); } /** * List all frameworks (backward compatibility) * * @deprecated Use getByType('framework') instead * @returns {Promise<Object[]>} */ async listFrameworks() { return this.getByType('framework'); } // =========================== // Validation // =========================== /** * Validate registry against JSON schema * * @returns {Promise<boolean>} True if registry is valid * @throws {InvalidSchemaError} If validation fails * * @example * try { * await registry.validateRegistry(); * console.log('Registry valid'); * } catch (error) { * console.error('Validation failed:', error.errors); * } */ async validateRegistry() { const registry = await this._readRegistry(); return this._validateRegistrySchema(registry); } /** * Validate registry schema (internal) * * @private * @param {Object} registry - Registry object to validate * @returns {boolean} True if valid * @throws {InvalidSchemaError} If validation fails */ _validateRegistrySchema(registry) { const errors = []; // Check version if (!registry.version || registry.version !== this.schemaVersion) { errors.push(`Invalid version: expected '${this.schemaVersion}', got '${registry.version}'`); } // Check plugins array exists if (!Array.isArray(registry.plugins)) { errors.push('Missing or invalid plugins array'); } else { // Validate each plugin registry.plugins.forEach((plugin, index) => { try { this._validatePluginMetadata(plugin); } catch (error) { errors.push(`Plugin ${index} (${plugin?.id || 'unknown'}): ${error.message}`); } }); } if (errors.length > 0) { throw new InvalidSchemaError('Registry validation failed', errors); } return true; } /** * Validate plugin metadata (internal) * * @private * @param {Object} plugin - Plugin metadata to validate * @throws {InvalidSchemaError} If validation fails */ _validatePluginMetadata(plugin) { const errors = []; // Required fields (all plugins) const requiredFields = ['id', 'type', 'name', 'version', 'install-date', 'repo-path']; requiredFields.forEach(field => { if (!plugin[field]) { errors.push(`Missing required field: ${field}`); } }); // Validate plugin type if (plugin.type && !['framework', 'add-on', 'extension'].includes(plugin.type)) { errors.push(`Invalid plugin type '${plugin.type}': must be 'framework', 'add-on', or 'extension'`); } // Type-specific validation if (plugin.type === 'add-on' && !plugin['parent-framework']) { errors.push(`Add-on '${plugin.id}' missing required field: parent-framework`); } if (plugin.type === 'extension' && !plugin.extends) { errors.push(`Extension '${plugin.id}' missing required field: extends`); } // Validate plugin ID format (kebab-case) if (plugin.id && !/^[a-z0-9-]+$/.test(plugin.id)) { errors.push(`Invalid plugin ID '${plugin.id}': must be kebab-case (lowercase letters, numbers, hyphens only)`); } // Validate version format (semver) if (plugin.version && !/^\d+\.\d+\.\d+$/.test(plugin.version)) { errors.push(`Invalid version '${plugin.version}': must be semantic version (e.g., '1.0.0')`); } // Validate install-date format (ISO 8601) if (plugin['install-date']) { try { new Date(plugin['install-date']); } catch { errors.push(`Invalid install-date '${plugin['install-date']}': must be ISO 8601 format`); } } // Validate health status if (plugin.health && !['healthy', 'warning', 'error', 'unknown'].includes(plugin.health)) { errors.push(`Invalid health status '${plugin.health}': must be 'healthy', 'warning', 'error', or 'unknown'`); } if (errors.length > 0) { throw new InvalidSchemaError(`Plugin metadata validation failed for '${plugin.id || 'unknown'}'`, errors); } } /** * Validate plugin ID format (internal) * * @private * @param {string} pluginId - Plugin ID to validate * @throws {Error} If ID is invalid */ _validatePluginId(pluginId) { if (!pluginId || typeof pluginId !== 'string') { throw new Error('Plugin ID must be a non-empty string'); } if (!/^[a-z0-9-]+$/.test(pluginId)) { throw new Error(`Invalid plugin ID '${pluginId}': must be kebab-case (lowercase letters, numbers, hyphens only)`); } } /** * Validate project ID format (internal) * * @private * @param {string} projectId - Project ID to validate * @throws {Error} If ID is invalid */ _validateProjectId(projectId) { if (!projectId || typeof projectId !== 'string') { throw new Error('Project ID must be a non-empty string'); } if (!/^[a-z0-9-]+$/.test(projectId)) { throw new Error(`Invalid project ID '${projectId}': must be kebab-case (lowercase letters, numbers, hyphens only)`); } } // =========================== // Migration Logic // =========================== /** * Migrate old registry format to new schema (internal) * * Converts `frameworks` array to `plugins` array with type: 'framework' * Adds default health: 'unknown' for migrated plugins * * @private * @param {Object} registry - Registry object to migrate * @returns {Object} Migrated registry */ _migrateRegistry(registry) { // Check if migration needed if (registry.plugins) { // Already using new schema return registry; } if (!registry.frameworks || !Array.isArray(registry.frameworks)) { // Invalid registry, return empty return { version: this.schemaVersion, plugins: [] }; } console.debug(`[PluginRegistry] Migrating registry from 'frameworks' to 'plugins' schema`); // Migrate frameworks to plugins const plugins = registry.frameworks.map(framework => ({ ...framework, type: 'framework', health: framework.health || 'unknown', 'health-checked': framework['health-checked'] || null })); return { version: this.schemaVersion, plugins }; } // =========================== // Atomic Operations // =========================== /** * Acquire file lock with retry * * @private * @returns {Promise<void>} * @throws {RegistryLockError} If lock acquisition fails after retries */ async _acquireLock() { let retries = 0; while (retries < this.maxLockRetries) { try { // Try to create lock file (exclusive) await fs.writeFile(this.lockPath, process.pid.toString(), { flag: 'wx' }); console.debug(`[PluginRegistry] Lock acquired: ${this.lockPath}`); return; } catch (error) { if (error.code === 'EEXIST') { // Lock file exists, check if stale try { const lockContent = await fs.readFile(this.lockPath, 'utf-8'); const lockPid = parseInt(lockContent, 10); // Check if process still running (simplified check) if (lockPid !== process.pid) { console.debug(`[PluginRegistry] Lock held by PID ${lockPid}, retrying...`); } } catch { // Ignore lock file read errors } // Wait before retry retries++; if (retries < this.maxLockRetries) { await new Promise(resolve => setTimeout(resolve, this.lockRetryDelay * retries)); } } else { throw error; } } } throw new RegistryLockError(`Failed to acquire lock after ${this.maxLockRetries} retries`); } /** * Release file lock * * @private * @returns {Promise<void>} */ async _releaseLock() { try { await fs.unlink(this.lockPath); console.debug(`[PluginRegistry] Lock released: ${this.lockPath}`); } catch (error) { if (error.code !== 'ENOENT') { console.error(`[PluginRegistry] Failed to release lock: ${error.message}`); } } } /** * Atomic write with lock and validation * * Writes registry to temporary file, validates, then renames atomically. * * @private * @param {Object} registry - Registry data to write * @returns {Promise<void>} */ async _atomicWrite(registry) { // Validate before writing this._validateRegistrySchema(registry); const tempPath = `${this.registryPath}.tmp`; const registryJson = JSON.stringify(registry, null, 2); try { // Write to temp file await fs.writeFile(tempPath, registryJson, 'utf-8'); // Atomic rename (overwrites existing) await fs.rename(tempPath, this.registryPath); console.debug(`[PluginRegistry] Atomic write complete: ${this.registryPath}`); } catch (error) { // Cleanup temp file on failure try { await fs.unlink(tempPath); } catch { // Ignore cleanup errors } throw new Error(`Failed to write registry: ${error.message}`); } } /** * Read registry from disk * * @private * @returns {Promise<Object>} Registry object * @throws {Error} If registry file doesn't exist or is invalid JSON */ async _readRegistry() { try { const registryJson = await fs.readFile(this.registryPath, 'utf-8'); let registry = JSON.parse(registryJson); // Auto-migrate if needed registry = this._migrateRegistry(registry); // If migration occurred, write back to disk if (!registryJson.includes('"plugins"')) { console.debug(`[PluginRegistry] Writing migrated registry to disk`); await this._atomicWrite(registry); } // Validate schema on read this._validateRegistrySchema(registry); return registry; } catch (error) { if (error.code === 'ENOENT') { // Registry doesn't exist, auto-initialize console.debug(`[PluginRegistry] Registry not found, initializing...`); await this.initialize(); return { version: this.schemaVersion, plugins: [] }; } if (error instanceof SyntaxError) { throw new InvalidSchemaError(`Registry JSON is malformed: ${error.message}`); } throw error; } } // =========================== // Health Monitoring // =========================== /** * Run health check on all installed plugins * * Verifies each plugin's: * - Directory exists at repo-path * - Required files present (manifest.json or plugin.yaml) * - Parent framework exists (for add-ons) * - Extended framework exists (for extensions) * * @returns {Promise<Object>} Health check results * @property {number} total - Total plugins checked * @property {number} healthy - Plugins passing all checks * @property {number} warning - Plugins with minor issues * @property {number} error - Plugins with critical issues * @property {Object[]} results - Per-plugin health status * * @example * const health = await registry.healthCheck(); * console.log(`${health.healthy}/${health.total} plugins healthy`); * health.results.filter(r => r.status === 'error').forEach(r => { * console.error(`${r.pluginId}: ${r.issues.join(', ')}`); * }); */ async healthCheck() { const registry = await this._readRegistry(); const results = []; let healthy = 0; let warning = 0; let error = 0; for (const plugin of registry.plugins) { const issues = []; let status = 'healthy'; // Check 1: Directory exists const repoPath = path.resolve(path.dirname(this.registryPath), '..', plugin['repo-path']); try { const stat = await fs.stat(repoPath); if (!stat.isDirectory()) { issues.push(`repo-path '${plugin['repo-path']}' is not a directory`); status = 'error'; } } catch (err) { if (err.code === 'ENOENT') { issues.push(`repo-path '${plugin['repo-path']}' does not exist`); status = 'error'; } else { issues.push(`Cannot access repo-path: ${err.message}`); status = 'warning'; } } // Check 2: Required files exist (if directory exists and no error yet) if (status !== 'error') { const manifestPath = path.join(repoPath, 'manifest.json'); const pluginYamlPath = path.join(repoPath, 'plugin.yaml'); const pluginJsonPath = path.join(repoPath, 'plugin.json'); let hasManifest = false; for (const manifestFile of [manifestPath, pluginYamlPath, pluginJsonPath]) { try { await fs.access(manifestFile); hasManifest = true; break; } catch { // File doesn't exist, try next } } if (!hasManifest) { issues.push('Missing manifest file (manifest.json, plugin.yaml, or plugin.json)'); if (status === 'healthy') status = 'warning'; } } // Check 3: Parent framework exists (for add-ons) if (plugin.type === 'add-on' && plugin['parent-framework']) { const parentExists = registry.plugins.some(p => p.id === plugin['parent-framework']); if (!parentExists) { issues.push(`Parent framework '${plugin['parent-framework']}' not installed`); status = 'error'; } } // Check 4: Extended framework exists (for extensions) if (plugin.type === 'extension' && plugin.extends) { const extendedExists = registry.plugins.some(p => p.id === plugin.extends); if (!extendedExists) { issues.push(`Extended framework '${plugin.extends}' not installed`); status = 'error'; } } // Update plugin health status in registry const healthChanged = plugin.health !== status; plugin.health = status; plugin['health-checked'] = new Date().toISOString(); // Count by status if (status === 'healthy') healthy++; else if (status === 'warning') warning++; else error++; results.push({ pluginId: plugin.id, type: plugin.type, status, issues, checkedAt: plugin['health-checked'] }); } // Save updated health statuses await this._acquireLock(); try { await this._atomicWrite(registry); } finally { await this._releaseLock(); } return { total: registry.plugins.length, healthy, warning, error, results }; } /** * Get plugins that need attention (warning or error status) * * @returns {Promise<Object[]>} Array of plugins with issues * * @example * const issues = await registry.getPluginsWithIssues(); * if (issues.length > 0) { * console.warn('Plugins need attention:', issues.map(p => p.id)); * } */ async getPluginsWithIssues() { const registry = await this._readRegistry(); return registry.plugins.filter(p => p.health === 'warning' || p.health === 'error'); } /** * Update health status for specific plugin * * @param {string} pluginId - Plugin ID * @param {string} status - New health status ('healthy' | 'warning' | 'error' | 'unknown') * @param {string[]} [issues=[]] - List of issues (for warning/error status) * * @returns {Promise<void>} * * @example * await registry.setHealthStatus('my-plugin', 'error', ['Config file missing']); */ async setHealthStatus(pluginId, status, issues = []) { if (!['healthy', 'warning', 'error', 'unknown'].includes(status)) { throw new Error(`Invalid health status: ${status}`); } await this.updatePlugin(pluginId, { health: status, 'health-checked': new Date().toISOString(), 'health-issues': issues.length > 0 ? issues : undefined }); } // =========================== // Backup and Restore // =========================== /** * Create backup of current registry * * Saves registry to timestamped backup file in .aiwg/frameworks/backups/ * * @param {string} [reason] - Optional reason for backup (stored in backup metadata) * * @returns {Promise<string>} Path to backup file * * @example * const backupPath = await registry.createBackup('Before plugin uninstall'); * console.log(`Backup saved to: ${backupPath}`); */ async createBackup(reason = 'manual') { const registry = await this._readRegistry(); const backupDir = path.join(path.dirname(this.registryPath), 'backups'); await fs.mkdir(backupDir, { recursive: true }); const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const backupPath = path.join(backupDir, `registry-${timestamp}.json`); const backup = { ...registry, _backup: { createdAt: new Date().toISOString(), reason, originalPath: this.registryPath, schemaVersion: this.schemaVersion } }; await fs.writeFile(backupPath, JSON.stringify(backup, null, 2), 'utf-8'); console.debug(`[PluginRegistry] Backup created: ${backupPath}`); return backupPath; } /** * List available registry backups * * @returns {Promise<Object[]>} Array of backup info objects * @property {string} path - Full path to backup file * @property {string} filename - Backup filename * @property {Date} createdAt - When backup was created * @property {number} size - File size in bytes * * @example * const backups = await registry.listBackups(); * backups.forEach(b => console.log(`${b.filename} (${b.size} bytes)`)); */ async listBackups() { const backupDir = path.join(path.dirname(this.registryPath), 'backups'); try { const files = await fs.readdir(backupDir); const backups = []; for (const file of files) { if (file.startsWith('registry-') && file.endsWith('.json')) { const filePath = path.join(backupDir, file); const stat = await fs.stat(filePath); // Extract timestamp from filename const timestampMatch = file.match(/registry-(.+)\.json/); let createdAt = stat.mtime; if (timestampMatch) { const timestampStr = timestampMatch[1].replace(/-/g, (match, offset) => { // Convert back to ISO format: 2025-12-02T14-30-00-000Z -> 2025-12-02T14:30:00.000Z if (offset === 4 || offset === 7) return '-'; if (offset === 10) return 'T'; if (offset === 13 || offset === 16) return ':'; if (offset === 19) return '.'; return match; }); createdAt = new Date(stat.mtime); } backups.push({ path: filePath, filename: file, createdAt, size: stat.size }); } } // Sort by date, newest first return backups.sort((a, b) => b.createdAt - a.createdAt); } catch (error) { if (error.code === 'ENOENT') { return []; // No backups directory } throw error; } } /** * Restore registry from backup * * @param {string} backupPath - Path to backup file to restore * @param {boolean} [createBackupFirst=true] - Create backup of current state before restoring * * @returns {Promise<void>} * @throws {Error} If backup file doesn't exist or is invalid * * @example * const backups = await registry.listBackups(); * if (backups.length > 0) { * await registry.restoreFromBackup(backups[0].path); * console.log('Registry restored'); * } */ async restoreFromBackup(backupPath, createBackupFirst = true) { // Verify backup exists try { await fs.access(backupPath); } catch { throw new Error(`Backup file not found: ${backupPath}`); } // Read and validate backup const backupJson = await fs.readFile(backupPath, 'utf-8'); let backup; try { backup = JSON.parse(backupJson); } catch (error) { throw new InvalidSchemaError(`Backup file is not valid JSON: ${error.message}`); } // Remove backup metadata before restoration const { _backup, ...registry } = backup; // Validate backup schema (without _backup field) this._validateRegistrySchema(registry); // Create backup of current state before restoring if (createBackupFirst) { await this.createBackup('pre-restore'); } // Restore registry await this._acquireLock(); try { await this._atomicWrite(registry); console.debug(`[PluginRegistry] Registry restored from: ${backupPath}`); } finally { await this._releaseLock(); } } /** * Clean up old backups, keeping only recent ones * * @param {number} [keepCount=5] - Number of recent backups to keep * * @returns {Promise<number>} Number of backups deleted * * @example * const deleted = await registry.cleanBackups(3); * console.log(`Deleted ${deleted} old backups`); */ async cleanBackups(keepCount = 5) { const backups = await this.listBackups(); if (backups.length <= keepCount) { return 0; } // Delete oldest backups beyond keepCount const toDelete = backups.slice(keepCount); let deleted = 0; for (const backup of toDelete) { try { await fs.unlink(backup.path); deleted++; console.debug(`[PluginRegistry] Deleted old backup: ${backup.filename}`); } catch (error) { console.error(`[PluginRegistry] Failed to delete backup ${backup.filename}: ${error.message}`); } } return deleted; } // =========================== // Error Recovery // =========================== /** * Attempt to recover corrupted registry * * Tries multiple recovery strategies: * 1. Re-read and validate current registry * 2. Restore from most recent backup * 3. Scan filesystem and rebuild registry * * @returns {Promise<Object>} Recovery result * @property {boolean} success - Whether recovery succeeded * @property {string} method - Recovery method used ('validate' | 'backup' | 'rebuild' | 'none') * @property {string} [message] - Additional information * * @example * const result = await registry.recover(); * if (result.success) { * console.log(`Recovery successful via ${result.method}`); * } else { * console.error('Recovery failed:', result.message); * } */ async recover() { // Strategy 1: Try to read and validate current registry try { await this._readRegistry(); return { success: true, method: 'validate', message: 'Registry is valid' }; } catch (validationError) { console.debug(`[PluginRegistry] Registry validation failed: ${validationError.message}`); } // Strategy 2: Try to restore from most recent backup const backups = await this.listBackups(); for (const backup of backups) { try { await this.restoreFromBackup(backup.path, false); return { success: true, method: 'backup', message: `Restored from backup: ${backup.filename}` }; } catch (restoreError) { console.debug(`[PluginRegistry] Backup restore failed for ${backup.filename}: ${restoreError.message}`); continue; // Try next backup } } // Strategy 3: Rebuild from filesystem try { const rebuilt = await this._rebuildFromFilesystem(); return { success: true, method: 'rebuild', message: `Rebuilt registry with ${rebuilt.plugins.length} plugins from filesystem scan` }; } catch (rebuildError) { console.error(`[PluginRegistry] Filesystem rebuild failed: ${rebuildError.message}`); } return { success: false, method: 'none', message: 'All recovery strategies failed. Manual intervention required.' }; } /** * Rebuild registry by scanning filesystem for installed plugins * * Scans .aiwg/frameworks/ for directories containing plugin manifests * * @private * @returns {Promise<Object>} Rebuilt registry object */ async _rebuildFromFilesystem() { const frameworksDir = path.dirname(this.registryPath); const plugins = []; try { const entries = await fs.readdir(frameworksDir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory() || entry.name === 'backups') { continue; } const pluginDir = path.join(frameworksDir, entry.name); // Look for manifest files const manifestFiles = ['manifest.json', 'plugin.yaml', 'plugin.json']; let manifest = null; for (const manifestFile of manifestFiles) { const manifestPath = path.join(pluginDir, manifestFile); try { const content = await fs.readFile(manifestPath, 'utf-8'); manifest = manifestFile.endsWith('.json') ? JSON.parse(content) : content; break; } catch { continue; } } // Create plugin entry from directory name if no manifest const pluginId = entry.name; plugins.push({ id: pluginId, type: 'framework', // Default to framework name: manifest?.name || pluginId, version: manifest?.version || '0.0.0', 'install-date': new Date().toISOString(), 'repo-path': `${entry.name}/`, projects: [], health: 'unknown', 'health-checked': new Date().toISOString(), _recovered: true }); } } catch (error) { if (error.code === 'ENOENT') { // No frameworks directory, create empty registry } else { throw error; } } const registry = { version: this.schemaVersion, plugins, _rebuilt: { at: new Date().toISOString(), reason: 'recovery' } }; // Write rebuilt registry await this._acquireLock(); try { // Create directory if needed await fs.mkdir(path.dirname(this.registryPath), { recursive: true }); await this._atomicWrite(registry); } finally { await this._releaseLock(); } console.debug(`[PluginRegistry] Registry rebuilt with ${plugins.length} plugins`); return registry; } /** * Verify registry integrity and report issues * * @returns {Promise<Object>} Integrity check results * @property {boolean} valid - Whether registry is valid * @property {string[]} errors - List of errors found * @property {string[]} warnings - List of warnings * * @example * const integrity = await registry.checkIntegrity(); * if (!integrity.valid) { * console.error('Registry issues:', integrity.errors); * } */ async checkIntegrity() { const errors = []; const warnings = []; try { const registry = await this._readRegistry(); // Check for duplicate IDs const ids = registry.plugins.map(p => p.id); const duplicates = ids.filter((id, index) => ids.indexOf(id) !== index); if (duplicates.length > 0) { errors.push(`Duplicate plugin IDs: ${[...new Set(duplicates)].join(', ')}`); } // Check for orphaned add-ons/extensions for (const plugin of registry.plugins) { if (plugin.type === 'add-on' && plugin['parent-framework']) { const parentExists = registry.plugins.some(p => p.id === plugin['parent-framework']); if (!parentExists) { errors.push(`Add-on '${plugin.id}' references non-existent parent framework '${plugin['parent-framework']}'`); } } if (plugin.type === 'extension' && plugin.extends) { const extendedExists = registry.plugins.some(p => p.id === plugin.extends); if (!extendedExists) { errors.push(`Extension '${plugin.id}' references non-existent framework '${plugin.extends}'`); } } // Check for missing required fields if (!plugin.id) errors.push('Found plugin with missing ID'); if (!plugin.type) warnings.push(`Plugin '${plugin.id}' missing type field`); if (!plugin.version) warnings.push(`Plugin '${plugin.id}' missing version field`); // Check for stale health checks (> 7 days) if (plugin['health-checked']) { const lastCheck = new Date(plugin['health-checked']); const daysSinceCheck = (Date.now() - lastCheck.getTime()) / (1000 * 60 * 60 * 24); if (daysSinceCheck > 7) { warnings.push(`Plugin '${plugin.id}' health check is ${Math.floor(daysSinceCheck)} days old`); } } } } catch (error) { errors.push(`Failed to read registry: ${error.message}`); } return { valid: errors.length === 0, errors, warnings }; } } // Backward compatibility alias export const FrameworkRegistry = PluginRegistry; // Export error classes for external use export { PluginNotFoundError, FrameworkNotFoundError, InvalidSchemaError, RegistryLockError, DuplicatePluginError, DuplicateFrameworkError };