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
473 lines • 16.3 kB
JavaScript
/**
* Plugin Uninstallation System
*
* Comprehensive uninstaller supporting frameworks, add-ons, and extensions
* with dependency validation, artifact cleanup, and safe removal.
*
* Features:
* - Dependency validation (prevent orphaned add-ons)
* - Active project detection
* - Artifact cleanup (directories, registry)
* - Dry-run mode
* - Force mode (skip dependency checks)
* - Project preservation (--keep-projects)
* - Rollback on failure
*
* @module src/plugin/plugin-uninstaller
*/
import * as fs from 'fs/promises';
import * as path from 'path';
/**
* PluginUninstaller - Uninstall and cleanup plugins
*
* @example
* ```typescript
* const uninstaller = new PluginUninstaller('~/.local/share/ai-writing-guide');
*
* // Check dependencies before uninstall
* const deps = await uninstaller.getDependentPlugins('sdlc-complete');
* if (deps.length > 0) {
* console.log('Cannot uninstall - dependent plugins exist');
* }
*
* // Uninstall framework
* const result = await uninstaller.uninstall('my-framework');
*
* // Force uninstall (ignore dependencies)
* const forceResult = await uninstaller.uninstall('parent-framework', { force: true });
*
* // Dry-run preview
* const preview = await uninstaller.uninstall('plugin', { dryRun: true });
* ```
*/
export class PluginUninstaller {
aiwgRoot;
registryPath;
backupDir;
rollbackActions = [];
constructor(aiwgRoot) {
this.aiwgRoot = aiwgRoot;
this.registryPath = path.join(aiwgRoot, 'registry.json');
this.backupDir = path.join(aiwgRoot, 'backups');
}
/**
* Uninstall a plugin
*
* @param pluginId - Plugin identifier
* @param options - Uninstall options
* @returns Uninstall result
*/
async uninstall(pluginId, options = {}) {
const result = {
success: false,
pluginId,
actions: [],
errors: [],
warnings: [],
stats: {
filesRemoved: 0,
dirsRemoved: 0,
bytesFreed: 0,
projectsArchived: 0
}
};
this.rollbackActions = [];
try {
// Step 1: Validate plugin exists
const plugin = await this.getPlugin(pluginId);
if (!plugin) {
result.errors.push(`Plugin '${pluginId}' is not installed`);
return result;
}
result.actions.push({
type: 'validate',
description: `Found plugin: ${plugin.name} v${plugin.version} (${plugin.type})`,
executed: true
});
// Step 2: Check for dependent plugins
if (!options.force) {
const dependents = await this.getDependentPlugins(pluginId);
if (dependents.length > 0) {
const depNames = dependents.map(d => `${d.id} (${d.type})`).join(', ');
result.errors.push(`Cannot uninstall '${pluginId}' - the following plugins depend on it: ${depNames}. ` +
`Uninstall them first or use --force to skip this check.`);
result.actions.push({
type: 'check-deps',
description: `Found ${dependents.length} dependent plugins`,
executed: true
});
return result;
}
result.actions.push({
type: 'check-deps',
description: 'No dependent plugins found',
executed: true
});
}
else {
result.warnings.push('Dependency check skipped (--force)');
}
// Step 3: Check for active projects (warning only)
if (plugin.type === 'framework') {
const projects = await this.getActiveProjects(pluginId, plugin.path);
if (projects.length > 0) {
result.warnings.push(`Plugin has ${projects.length} active project(s): ${projects.join(', ')}`);
}
// Handle project preservation
if (projects.length > 0 && options.keepProjects && !options.dryRun) {
await this.archiveProjects(pluginId, plugin.path, projects, result);
}
}
// Step 4: Backup registry (for rollback)
if (!options.dryRun) {
await this.backupRegistry(result);
}
// Step 5: Remove plugin directory
const pluginPath = path.join(this.aiwgRoot, plugin.path);
if (!options.dryRun) {
await this.removeDirectory(pluginPath, result);
}
else {
result.actions.push({
type: 'remove-dir',
description: `Would remove directory: ${plugin.path}`,
path: pluginPath,
executed: false
});
}
// Step 6: Update registry
if (!options.dryRun) {
await this.removeFromRegistry(pluginId, result);
}
else {
result.actions.push({
type: 'update-registry',
description: `Would remove ${pluginId} from registry`,
path: this.registryPath,
executed: false
});
}
result.success = true;
}
catch (error) {
result.errors.push(`Uninstall 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;
}
/**
* Get plugins that depend on the given plugin
*
* @param pluginId - Plugin identifier
* @returns Array of dependent plugins
*/
async getDependentPlugins(pluginId) {
const dependents = [];
try {
const registry = await this.loadRegistry();
for (const plugin of registry.plugins) {
if (plugin.parentFramework === pluginId) {
dependents.push({
id: plugin.id,
type: plugin.type,
relationship: 'parentFramework'
});
}
}
}
catch {
// Registry doesn't exist
}
return dependents;
}
/**
* Get suggested uninstall order for a plugin and its dependents
*
* @param pluginId - Plugin identifier
* @returns Ordered list of plugins to uninstall
*/
async getUninstallOrder(pluginId) {
const order = [];
const visited = new Set();
const visit = async (id) => {
if (visited.has(id))
return;
visited.add(id);
const dependents = await this.getDependentPlugins(id);
for (const dep of dependents) {
await visit(dep.id);
}
order.push(id);
};
await visit(pluginId);
return order;
}
/**
* Get plugin info from registry
*/
async getPlugin(pluginId) {
try {
const registry = await this.loadRegistry();
return registry.plugins.find(p => p.id === pluginId) || null;
}
catch {
return null;
}
}
/**
* Get active projects for a framework
*/
async getActiveProjects(_pluginId, pluginPath) {
const projectsDir = path.join(this.aiwgRoot, pluginPath, 'projects');
try {
const entries = await fs.readdir(projectsDir, { withFileTypes: true });
return entries
.filter(e => e.isDirectory())
.map(e => e.name);
}
catch {
return [];
}
}
/**
* Archive projects before uninstall
*/
async archiveProjects(pluginId, pluginPath, projects, result) {
const now = new Date();
const yearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
const archiveBase = path.join(this.aiwgRoot, 'archive', 'uninstalled', pluginId, yearMonth);
await fs.mkdir(archiveBase, { recursive: true });
const projectsDir = path.join(this.aiwgRoot, pluginPath, 'projects');
for (const project of projects) {
const srcPath = path.join(projectsDir, project);
const destPath = path.join(archiveBase, project);
await this.copyDirectory(srcPath, destPath);
result.stats.projectsArchived++;
result.actions.push({
type: 'archive',
description: `Archived project: ${project} → ${path.relative(this.aiwgRoot, destPath)}`,
path: destPath,
executed: true
});
}
}
/**
* Backup registry for rollback
*/
async backupRegistry(result) {
try {
await fs.mkdir(this.backupDir, { recursive: true });
const timestamp = Date.now();
const backupPath = path.join(this.backupDir, `registry-${timestamp}.json`);
const content = await fs.readFile(this.registryPath, 'utf-8');
await fs.writeFile(backupPath, content, 'utf-8');
result.actions.push({
type: 'backup',
description: `Backed up registry to ${path.basename(backupPath)}`,
path: backupPath,
executed: true
});
// Add rollback action
this.rollbackActions.push(async () => {
await fs.copyFile(backupPath, this.registryPath);
});
}
catch {
// Registry doesn't exist, nothing to backup
}
}
/**
* Recursively remove directory and track stats
*/
async removeDirectory(dirPath, result) {
try {
const stats = await this.calculateDirectoryStats(dirPath);
result.stats.filesRemoved = stats.files;
result.stats.dirsRemoved = stats.dirs;
result.stats.bytesFreed = stats.bytes;
await fs.rm(dirPath, { recursive: true, force: true });
result.actions.push({
type: 'remove-dir',
description: `Removed directory: ${path.relative(this.aiwgRoot, dirPath)} (${stats.files} files, ${this.formatBytes(stats.bytes)})`,
path: dirPath,
executed: true
});
}
catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
}
}
/**
* Calculate directory statistics
*/
async calculateDirectoryStats(dirPath) {
let files = 0;
let dirs = 0;
let bytes = 0;
const scan = async (currentPath) => {
try {
const entries = await fs.readdir(currentPath, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(currentPath, entry.name);
if (entry.isDirectory()) {
dirs++;
await scan(entryPath);
}
else if (entry.isFile()) {
files++;
try {
const stat = await fs.stat(entryPath);
bytes += stat.size;
}
catch {
// Ignore stat errors
}
}
}
}
catch {
// Ignore read errors
}
};
await scan(dirPath);
return { files, dirs, bytes };
}
/**
* Remove plugin from registry
*/
async removeFromRegistry(pluginId, result) {
const registry = await this.loadRegistry();
const originalLength = registry.plugins.length;
registry.plugins = registry.plugins.filter(p => p.id !== pluginId);
if (registry.plugins.length === originalLength) {
// Plugin wasn't in registry
return;
}
registry.lastModified = new Date().toISOString();
await fs.writeFile(this.registryPath, JSON.stringify(registry, null, 2), 'utf-8');
result.actions.push({
type: 'update-registry',
description: `Removed ${pluginId} from registry`,
path: this.registryPath,
executed: true
});
}
/**
* Load registry file
*/
async loadRegistry() {
const content = await fs.readFile(this.registryPath, 'utf-8');
return JSON.parse(content);
}
/**
* 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);
}
}
}
/**
* Rollback changes on failure
*/
async rollback() {
for (let i = this.rollbackActions.length - 1; i >= 0; i--) {
try {
await this.rollbackActions[i]();
}
catch {
// Ignore rollback errors
}
}
this.rollbackActions = [];
}
/**
* Format bytes as human-readable string
*/
formatBytes(bytes) {
if (bytes === 0)
return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
/**
* List all installed plugins
*/
async listInstalled() {
try {
const registry = await this.loadRegistry();
return registry.plugins;
}
catch {
return [];
}
}
/**
* Check if a plugin can be safely uninstalled
*
* @param pluginId - Plugin identifier
* @returns Object with canUninstall flag and reason/warnings
*/
async canUninstall(pluginId) {
const warnings = [];
// Check if plugin exists
const plugin = await this.getPlugin(pluginId);
if (!plugin) {
return {
canUninstall: false,
reason: `Plugin '${pluginId}' is not installed`,
warnings
};
}
// Check for dependents
const dependents = await this.getDependentPlugins(pluginId);
if (dependents.length > 0) {
return {
canUninstall: false,
reason: `Plugin has ${dependents.length} dependent plugin(s): ${dependents.map(d => d.id).join(', ')}`,
warnings
};
}
// Check for active projects (warning)
if (plugin.type === 'framework') {
const projects = await this.getActiveProjects(pluginId, plugin.path);
if (projects.length > 0) {
warnings.push(`Plugin has ${projects.length} active project(s)`);
}
}
return {
canUninstall: true,
warnings
};
}
}
/**
* Create a PluginUninstaller with default AIWG root
*/
export function createUninstaller() {
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
const aiwgRoot = path.join(homeDir, '.local', 'share', 'ai-writing-guide');
return new PluginUninstaller(aiwgRoot);
}
//# sourceMappingURL=plugin-uninstaller.js.map