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
JavaScript
/**
* 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