UNPKG

aiwg

Version:

Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo

436 lines 15.8 kB
/** * Plugin Installation System * * Comprehensive installer supporting frameworks, add-ons, and extensions * with dependency resolution, registry management, and atomic installation. * * Features: * - Install from local path or plugin ID * - Dependency resolution (add-ons require parent framework) * - Automatic registry updates * - Directory structure creation * - Manifest validation * - Atomic installation (rollback on failure) * - Dry-run mode * * @module src/plugin/plugin-installer * @implements @.aiwg/requirements/use-cases/UC-010-rollback-plugin-installation.md * @architecture @.aiwg/architecture/software-architecture-doc.md - Section 5.1 PluginManager * @adr @.aiwg/architecture/decisions/ADR-006-plugin-rollback-strategy.md * @nfr @.aiwg/requirements/nfr-modules/reliability.md - NFR-REL-002 (zero data loss) * @tests @test/unit/plugin/plugin-installer.test.ts * @depends @src/plugin/metadata-validator.ts * @depends @src/plugin/framework-config-loader.ts */ import * as fs from 'fs/promises'; import * as path from 'path'; /** * PluginInstaller - Install and manage plugins * * @example * ```typescript * const installer = new PluginInstaller('~/.local/share/ai-writing-guide'); * * // Install framework * const result = await installer.install('/path/to/sdlc-complete'); * * // Install add-on with parent * const addonResult = await installer.install('/path/to/gdpr-compliance', { * type: 'add-on', * parentFramework: 'sdlc-complete' * }); * * // Dry-run preview * const preview = await installer.install('/path/to/plugin', { dryRun: true }); * ``` */ export class PluginInstaller { aiwgRoot; registryPath; rollbackActions = []; constructor(aiwgRoot) { this.aiwgRoot = aiwgRoot; this.registryPath = path.join(aiwgRoot, 'registry.json'); } /** * Install a plugin from a source path * * @param source - Path to plugin directory or plugin ID * @param options - Installation options * @returns Installation result */ async install(source, options = {}) { const result = { success: false, pluginId: '', version: '', installPath: '', actions: [], errors: [], warnings: [] }; this.rollbackActions = []; try { // Step 1: Load and validate manifest const manifest = await this.loadManifest(source); result.pluginId = manifest.id; result.version = manifest.version; result.actions.push({ type: 'validate', description: `Validated manifest for ${manifest.name} v${manifest.version}`, executed: true }); // Override type if specified if (options.type) { manifest.type = options.type; } // Override parent framework if specified if (options.parentFramework) { manifest.parentFramework = options.parentFramework; } // Step 2: Check if already installed const isInstalled = await this.isPluginInstalled(manifest.id); if (isInstalled && !options.force) { result.errors.push(`Plugin '${manifest.id}' is already installed. Use --force to reinstall.`); return result; } if (isInstalled && options.force) { result.warnings.push(`Reinstalling existing plugin '${manifest.id}'`); } // Step 3: Validate dependencies if (!options.skipDependencyCheck) { const depErrors = await this.validateDependencies(manifest); if (depErrors.length > 0) { result.errors.push(...depErrors); return result; } } // Step 4: Calculate installation path const targetDir = options.targetDir || this.aiwgRoot; const installPath = this.getInstallPath(manifest.type, manifest.id, targetDir); result.installPath = installPath; // Step 5: Create directory structure if (!options.dryRun) { await this.createDirectoryStructure(manifest.type, manifest.id, targetDir, result); } else { result.actions.push({ type: 'create-dir', description: `Would create directory structure at ${installPath}`, path: installPath, executed: false }); } // Step 6: Copy plugin files if (!options.dryRun) { await this.copyPluginFiles(source, installPath, result); } else { result.actions.push({ type: 'copy-file', description: `Would copy plugin files from ${source} to ${installPath}`, path: installPath, executed: false }); } // Step 7: Update registry if (!options.dryRun) { await this.updateRegistry(manifest, installPath, result); } else { result.actions.push({ type: 'update-registry', description: `Would update registry with ${manifest.id}`, path: this.registryPath, executed: false }); } result.success = true; } catch (error) { result.errors.push(`Installation failed: ${error.message}`); // Rollback on failure if (!options.dryRun && this.rollbackActions.length > 0) { result.actions.push({ type: 'rollback', description: 'Rolling back changes due to error', executed: true }); await this.rollback(); } } return result; } /** * Load and validate plugin manifest */ async loadManifest(source) { const manifestPath = path.join(source, 'manifest.json'); try { const content = await fs.readFile(manifestPath, 'utf-8'); const manifest = JSON.parse(content); // Validate required fields this.validateManifest(manifest); return manifest; } catch (error) { if (error.code === 'ENOENT') { throw new Error(`Manifest not found at ${manifestPath}. Ensure the plugin has a valid manifest.json`); } throw error; } } /** * Validate manifest has required fields */ validateManifest(manifest) { const required = ['id', 'type', 'name', 'version']; const missing = required.filter(field => !(field in manifest)); if (missing.length > 0) { throw new Error(`Manifest missing required fields: ${missing.join(', ')}`); } // Validate plugin ID format if (!/^[a-z0-9-]+$/.test(manifest.id)) { throw new Error(`Invalid plugin ID '${manifest.id}'. Use lowercase letters, numbers, and hyphens only.`); } // Validate version format (semver-like) if (!/^\d+\.\d+\.\d+/.test(manifest.version)) { throw new Error(`Invalid version '${manifest.version}'. Use semver format (e.g., 1.0.0).`); } // Validate type const validTypes = ['framework', 'add-on', 'extension']; if (!validTypes.includes(manifest.type)) { throw new Error(`Invalid plugin type '${manifest.type}'. Must be one of: ${validTypes.join(', ')}`); } // Add-ons require parentFramework if (manifest.type === 'add-on' && !manifest.parentFramework) { throw new Error(`Add-on plugins require a 'parentFramework' field in manifest.json`); } } /** * Check if plugin is already installed */ async isPluginInstalled(pluginId) { try { const registry = await this.loadRegistry(); return registry.plugins.some(p => p.id === pluginId); } catch { return false; } } /** * Validate plugin dependencies */ async validateDependencies(manifest) { const errors = []; // Check parent framework for add-ons if (manifest.type === 'add-on' && manifest.parentFramework) { const parentInstalled = await this.isPluginInstalled(manifest.parentFramework); if (!parentInstalled) { errors.push(`Parent framework '${manifest.parentFramework}' is not installed. ` + `Install it first: aiwg -install-plugin ${manifest.parentFramework}`); } } // Check other dependencies if (manifest.dependencies) { for (const [depId, depVersion] of Object.entries(manifest.dependencies)) { const depInstalled = await this.isPluginInstalled(depId); if (!depInstalled) { errors.push(`Required dependency '${depId}' (${depVersion}) is not installed. ` + `Install it first: aiwg -install-plugin ${depId}`); } } } return errors; } /** * Get installation path for plugin type */ getInstallPath(type, pluginId, targetDir) { const typeDir = type === 'add-on' ? 'add-ons' : `${type}s`; return path.join(targetDir, typeDir, pluginId); } /** * Create directory structure for plugin */ async createDirectoryStructure(type, pluginId, targetDir, result) { const basePath = this.getInstallPath(type, pluginId, targetDir); // Define directory structure based on type const dirs = [basePath]; if (type === 'framework') { dirs.push(path.join(basePath, 'repo'), path.join(basePath, 'projects'), path.join(basePath, 'working'), path.join(basePath, 'archive')); } // Create directories for (const dir of dirs) { await fs.mkdir(dir, { recursive: true }); result.actions.push({ type: 'create-dir', description: `Created directory: ${path.relative(this.aiwgRoot, dir)}`, path: dir, executed: true }); // Add rollback action this.rollbackActions.push(async () => { await fs.rm(dir, { recursive: true, force: true }); }); } } /** * Copy plugin files to installation directory */ async copyPluginFiles(source, dest, result) { // For frameworks, copy to repo subdirectory const targetPath = dest.includes('/frameworks/') ? path.join(dest, 'repo') : dest; await this.copyDirectory(source, targetPath); result.actions.push({ type: 'copy-file', description: `Copied plugin files to ${path.relative(this.aiwgRoot, targetPath)}`, path: targetPath, executed: true }); // Add rollback action this.rollbackActions.push(async () => { await fs.rm(targetPath, { recursive: true, force: true }); }); } /** * Recursively copy directory */ async copyDirectory(source, dest) { await fs.mkdir(dest, { recursive: true }); const entries = await fs.readdir(source, { withFileTypes: true }); for (const entry of entries) { const srcPath = path.join(source, entry.name); const destPath = path.join(dest, entry.name); if (entry.isDirectory()) { await this.copyDirectory(srcPath, destPath); } else { await fs.copyFile(srcPath, destPath); } } } /** * Update registry with new plugin */ async updateRegistry(manifest, installPath, result) { let registry; try { registry = await this.loadRegistry(); } catch { // Create new registry if it doesn't exist registry = { version: '1.0.0', lastModified: new Date().toISOString(), plugins: [] }; } // Save old state for rollback const oldContent = JSON.stringify(registry, null, 2); this.rollbackActions.push(async () => { await fs.writeFile(this.registryPath, oldContent, 'utf-8'); }); // Remove existing entry if force reinstall registry.plugins = registry.plugins.filter(p => p.id !== manifest.id); // Add new entry const relativePath = path.relative(this.aiwgRoot, installPath); const entry = { id: manifest.id, type: manifest.type, name: manifest.name, version: manifest.version, path: relativePath, installedAt: new Date().toISOString(), health: { status: 'healthy', lastCheck: new Date().toISOString() } }; if (manifest.parentFramework) { entry.parentFramework = manifest.parentFramework; } registry.plugins.push(entry); registry.lastModified = new Date().toISOString(); // Ensure registry directory exists await fs.mkdir(path.dirname(this.registryPath), { recursive: true }); // Write registry await fs.writeFile(this.registryPath, JSON.stringify(registry, null, 2), 'utf-8'); result.actions.push({ type: 'update-registry', description: `Updated registry: added ${manifest.id} v${manifest.version}`, path: this.registryPath, executed: true }); } /** * Load registry file */ async loadRegistry() { const content = await fs.readFile(this.registryPath, 'utf-8'); return JSON.parse(content); } /** * Rollback changes on failure */ async rollback() { // Execute rollback actions in reverse order for (let i = this.rollbackActions.length - 1; i >= 0; i--) { try { await this.rollbackActions[i](); } catch { // Ignore rollback errors } } this.rollbackActions = []; } /** * List installed plugins */ async listInstalled() { try { const registry = await this.loadRegistry(); return registry.plugins; } catch { return []; } } /** * Get plugin info by ID */ async getPluginInfo(pluginId) { try { const registry = await this.loadRegistry(); return registry.plugins.find(p => p.id === pluginId) || null; } catch { return null; } } /** * Validate manifest without installing */ async validatePlugin(source) { const errors = []; try { const manifest = await this.loadManifest(source); return { valid: true, manifest, errors }; } catch (error) { errors.push(error.message); return { valid: false, errors }; } } } /** * Create a PluginInstaller with default AIWG root */ export function createInstaller() { const homeDir = process.env.HOME || process.env.USERPROFILE || ''; const aiwgRoot = path.join(homeDir, '.local', 'share', 'ai-writing-guide'); return new PluginInstaller(aiwgRoot); } //# sourceMappingURL=plugin-installer.js.map