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.
630 lines (555 loc) • 17.4 kB
text/typescript
/**
* 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';
/**
* Plugin types
*/
export type PluginType = 'framework' | 'add-on' | 'extension';
/**
* Plugin manifest structure
*/
export interface PluginManifest {
/** Plugin identifier */
id: string;
/** Plugin type */
type: PluginType;
/** Human-readable name */
name: string;
/** Semantic version */
version: string;
/** Description */
description: string;
/** Author */
author?: string;
/** License */
license?: string;
/** Repository URL */
repository?: string;
/** Required parent framework (for add-ons) */
parentFramework?: string;
/** Dependencies on other plugins */
dependencies?: Record<string, string>;
/** Entry points */
entry?: {
agents?: string;
commands?: string;
templates?: string;
};
/** Keywords for search */
keywords?: string[];
}
/**
* Installation options
*/
export interface InstallOptions {
/** Plugin type (required if installing from local path) */
type?: PluginType;
/** Parent framework ID (required for add-ons) */
parentFramework?: string;
/** Dry-run mode (preview without executing) */
dryRun?: boolean;
/** Force reinstallation even if already installed */
force?: boolean;
/** Custom target directory */
targetDir?: string;
/** Skip dependency check */
skipDependencyCheck?: boolean;
}
/**
* Installation result
*/
export interface InstallResult {
/** Whether installation succeeded */
success: boolean;
/** Plugin ID */
pluginId: string;
/** Plugin version */
version: string;
/** Installation path */
installPath: string;
/** Actions taken */
actions: InstallAction[];
/** Errors encountered */
errors: string[];
/** Warnings */
warnings: string[];
}
/**
* Single installation action
*/
export interface InstallAction {
/** Action type */
type: 'create-dir' | 'copy-file' | 'update-registry' | 'validate' | 'rollback';
/** Action description */
description: string;
/** Path affected */
path?: string;
/** Whether action was executed or just planned (dry-run) */
executed: boolean;
}
/**
* Registry entry structure (simplified)
*/
interface RegistryEntry {
id: string;
type: PluginType;
name: string;
version: string;
path: string;
installedAt: string;
parentFramework?: string;
health?: {
status: 'healthy' | 'warning' | 'error';
lastCheck: string;
};
}
/**
* Registry structure
*/
interface Registry {
version: string;
lastModified: string;
plugins: RegistryEntry[];
}
/**
* 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 {
private aiwgRoot: string;
private registryPath: string;
private rollbackActions: Array<() => Promise<void>> = [];
constructor(aiwgRoot: string) {
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: string, options: InstallOptions = {}): Promise<InstallResult> {
const result: InstallResult = {
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 as 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
*/
private async loadManifest(source: string): Promise<PluginManifest> {
const manifestPath = path.join(source, 'manifest.json');
try {
const content = await fs.readFile(manifestPath, 'utf-8');
const manifest = JSON.parse(content) as PluginManifest;
// Validate required fields
this.validateManifest(manifest);
return manifest;
} catch (error) {
if ((error as NodeJS.ErrnoException).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
*/
private validateManifest(manifest: PluginManifest): void {
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: PluginType[] = ['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
*/
private async isPluginInstalled(pluginId: string): Promise<boolean> {
try {
const registry = await this.loadRegistry();
return registry.plugins.some(p => p.id === pluginId);
} catch {
return false;
}
}
/**
* Validate plugin dependencies
*/
private async validateDependencies(manifest: PluginManifest): Promise<string[]> {
const errors: string[] = [];
// 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
*/
private getInstallPath(type: PluginType, pluginId: string, targetDir: string): string {
const typeDir = type === 'add-on' ? 'add-ons' : `${type}s`;
return path.join(targetDir, typeDir, pluginId);
}
/**
* Create directory structure for plugin
*/
private async createDirectoryStructure(
type: PluginType,
pluginId: string,
targetDir: string,
result: InstallResult
): Promise<void> {
const basePath = this.getInstallPath(type, pluginId, targetDir);
// Define directory structure based on type
const dirs: string[] = [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
*/
private async copyPluginFiles(source: string, dest: string, result: InstallResult): Promise<void> {
// 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
*/
private async copyDirectory(source: string, dest: string): Promise<void> {
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
*/
private async updateRegistry(
manifest: PluginManifest,
installPath: string,
result: InstallResult
): Promise<void> {
let registry: 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: RegistryEntry = {
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
*/
private async loadRegistry(): Promise<Registry> {
const content = await fs.readFile(this.registryPath, 'utf-8');
return JSON.parse(content);
}
/**
* Rollback changes on failure
*/
private async rollback(): Promise<void> {
// 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(): Promise<RegistryEntry[]> {
try {
const registry = await this.loadRegistry();
return registry.plugins;
} catch {
return [];
}
}
/**
* Get plugin info by ID
*/
async getPluginInfo(pluginId: string): Promise<RegistryEntry | null> {
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: string): Promise<{
valid: boolean;
manifest?: PluginManifest;
errors: string[];
}> {
const errors: string[] = [];
try {
const manifest = await this.loadManifest(source);
return { valid: true, manifest, errors };
} catch (error) {
errors.push((error as Error).message);
return { valid: false, errors };
}
}
}
/**
* Create a PluginInstaller with default AIWG root
*/
export function createInstaller(): PluginInstaller {
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
const aiwgRoot = path.join(homeDir, '.local', 'share', 'ai-writing-guide');
return new PluginInstaller(aiwgRoot);
}