claude-buddy
Version:
Claude Buddy installation and setup script
602 lines (521 loc) • 17.5 kB
JavaScript
/**
* Uninstaller Module
*
* Safely removes Claude Buddy from a project while optionally preserving
* user customizations through selective removal and backup creation.
*
* @module setup/lib/uninstaller
*/
const fs = require('fs').promises;
const path = require('path');
const { glob } = require('glob');
const { createLogger } = require('./logger');
const logger = createLogger('uninstaller');
/**
* Uninstall Error Class
*/
class UninstallError extends Error {
constructor(message, code, details = {}) {
super(message);
this.name = 'UninstallError';
this.code = code;
this.details = details;
}
}
/**
* Preservation Error Class
*/
class PreservationError extends Error {
constructor(message, files, details = {}) {
super(message);
this.name = 'PreservationError';
this.files = files;
this.details = details;
}
}
/**
* Performs complete uninstallation process
*
* @param {Object} options - Uninstall options
* @param {string} options.targetDirectory - Target directory
* @param {Object} options.metadata - Installation metadata
* @param {boolean} options.preserveCustomizations - Preserve user files (default: true)
* @param {boolean} options.purge - Complete removal (default: false)
* @param {boolean} options.nonInteractive - No prompts (default: false)
* @param {boolean} options.dryRun - Dry-run mode (default: false)
* @param {boolean} options.verbose - Verbose logging (default: false)
* @returns {Promise<Object>} Uninstall result
*/
async function performUninstall(options) {
const {
targetDirectory,
metadata,
preserveCustomizations = true,
purge = false,
nonInteractive = false,
dryRun = false,
verbose = false
} = options;
const startTime = Date.now();
const result = {
success: false,
removedFiles: [],
preservedFiles: [],
removedDirectories: [],
preservedDirectories: [],
duration: 0,
warnings: []
};
try {
logger.section('Uninstallation Process', verbose);
logger.info(`Target: ${targetDirectory}`, verbose);
logger.info(`Mode: ${purge ? 'Purge' : 'Standard'}`, verbose);
logger.info(`Preserve customizations: ${preserveCustomizations && !purge ? 'Yes' : 'No'}`, verbose);
// Validate installation exists
const isValid = await validateUninstall(targetDirectory);
if (!isValid) {
throw new UninstallError(
'No Claude Buddy installation found',
'NOT_INSTALLED',
{ targetDirectory }
);
}
// Get removal plan
const plan = await getRemovalPlan(
targetDirectory,
metadata,
preserveCustomizations && !purge,
verbose
);
// Confirm uninstallation (unless non-interactive or dry-run)
if (!nonInteractive && !dryRun) {
const confirmed = await confirmUninstall(options, plan, verbose);
if (!confirmed) {
logger.info('Uninstallation cancelled by user', verbose);
result.success = false;
result.duration = Date.now() - startTime;
return result;
}
}
// Create preservation backup if preserving customizations
if (preserveCustomizations && !purge && plan.filesToPreserve.length > 0) {
if (!dryRun) {
logger.progress('Preserving user data', verbose);
const preservationPath = await preserveUserData(targetDirectory, metadata, verbose);
logger.success(`User data preserved at: ${preservationPath}`, verbose);
result.warnings.push(`User data backed up to: ${preservationPath}`);
} else {
logger.info('Would preserve user data', verbose);
}
}
// Remove framework files
logger.progress('Removing framework files', verbose);
const removalResult = await removeFiles(
targetDirectory,
plan.filesToRemove,
dryRun,
verbose
);
result.removedFiles = removalResult.removed;
result.preservedFiles = plan.filesToPreserve;
if (dryRun) {
logger.info(`Would remove ${removalResult.removed.length} files`, verbose);
} else {
logger.success(`Removed ${removalResult.removed.length} files`, verbose);
}
if (removalResult.errors.length > 0) {
removalResult.errors.forEach(err => {
result.warnings.push(`Failed to remove ${err.path}: ${err.error}`);
});
}
// Clean up empty directories (or all directories in purge mode)
logger.progress('Cleaning up directories', verbose);
const cleanupResult = await cleanupDirectories(
targetDirectory,
[
'.claude-buddy/personas',
'.claude-buddy/templates',
'.claude-buddy/context',
'.claude-buddy/logs',
'.claude-buddy',
'.claude/hooks',
'.claude/commands/buddy',
'.claude/commands',
'.claude/agents',
'.claude',
'directive'
],
dryRun,
purge,
verbose
);
result.removedDirectories = cleanupResult.removed;
result.preservedDirectories = cleanupResult.preserved;
if (dryRun) {
logger.info(`Would remove ${cleanupResult.removed.length} directories`, verbose);
} else {
logger.success(`Removed ${cleanupResult.removed.length} directories`, verbose);
}
result.success = true;
result.duration = Date.now() - startTime;
logger.section('Uninstallation Complete', verbose);
logger.success(`Removed ${result.removedFiles.length} files in ${result.duration}ms`, verbose);
if (result.preservedFiles.length > 0) {
logger.info(`Preserved ${result.preservedFiles.length} user customizations`, verbose);
}
return result;
} catch (error) {
logger.error(`Uninstallation failed: ${error.message}`, verbose);
result.success = false;
result.duration = Date.now() - startTime;
result.warnings.push(`Uninstallation failed: ${error.message}`);
return result;
}
}
/**
* Confirms uninstallation with user
*
* @param {Object} options - Uninstall options
* @param {Object} plan - Removal plan
* @param {boolean} verbose - Verbose logging
* @returns {Promise<boolean>} True if confirmed
*/
async function confirmUninstall(options, plan, verbose) {
const { purge } = options;
if (purge) {
logger.warn('WARNING: Complete Purge Mode', verbose);
logger.warn('This will PERMANENTLY remove ALL Claude Buddy files including:', verbose);
logger.warn(' - Framework files', verbose);
logger.warn(' - User customizations', verbose);
logger.warn(' - Custom personas', verbose);
logger.warn(' - All configurations', verbose);
logger.warn('', verbose);
logger.warn('This action CANNOT be undone (even with backup).', verbose);
// In a real implementation, we would use inquirer here
// For now, we'll assume confirmation in automated environments
return true;
} else {
logger.warn('Claude Buddy Uninstallation', verbose);
logger.info('', verbose);
logger.info('The following will be removed:', verbose);
logger.info(` - Framework files (${plan.filesToRemove.length} files)`, verbose);
logger.info(' - Slash commands and agents', verbose);
logger.info(' - Hook scripts', verbose);
logger.info('', verbose);
if (plan.filesToPreserve.length > 0) {
logger.info('The following will be preserved:', verbose);
logger.info(` - User customizations (${plan.filesToPreserve.length} files)`, verbose);
logger.info('', verbose);
}
// In a real implementation, we would use inquirer here
return true;
}
}
/**
* Removes specified files
*
* @param {string} targetDirectory - Base directory
* @param {Array} filesToRemove - Files to remove
* @param {boolean} dryRun - Dry-run mode
* @param {boolean} verbose - Verbose logging
* @returns {Promise<Object>} Removal result
*/
async function removeFiles(targetDirectory, filesToRemove, dryRun, verbose) {
const result = {
removed: [],
errors: []
};
for (const file of filesToRemove) {
const filePath = path.join(targetDirectory, file);
try {
if (dryRun) {
result.removed.push(file);
logger.debug(`Would remove: ${file}`, verbose);
continue;
}
// Check if file exists
try {
await fs.access(filePath);
} catch {
// File doesn't exist, skip
logger.debug(`File not found, skipping: ${file}`, verbose);
continue;
}
// Remove file
await fs.unlink(filePath);
result.removed.push(file);
logger.debug(`Removed: ${file}`, verbose);
} catch (error) {
result.errors.push({
path: file,
error: error.message,
recoverable: true
});
logger.error(`Failed to remove ${file}: ${error.message}`, verbose);
}
}
return result;
}
/**
* Removes empty directories (or all directories in purge mode)
*
* @param {string} targetDirectory - Base directory
* @param {Array} directories - Directories to check
* @param {boolean} dryRun - Dry-run mode
* @param {boolean} purge - Force remove non-empty directories
* @param {boolean} verbose - Verbose logging
* @returns {Promise<Object>} Cleanup result
*/
async function cleanupDirectories(targetDirectory, directories, dryRun, purge, verbose) {
const result = {
removed: [],
preserved: []
};
// Process directories in reverse order (deepest first)
const sortedDirs = [...directories].sort((a, b) => {
const depthA = a.split('/').length;
const depthB = b.split('/').length;
return depthB - depthA;
});
for (const dir of sortedDirs) {
const dirPath = path.join(targetDirectory, dir);
try {
// Check if directory exists
try {
await fs.access(dirPath);
} catch {
// Directory doesn't exist, skip
logger.debug(`Directory not found: ${dir}`, verbose);
continue;
}
// In purge mode, force-remove all directories
// Otherwise, only remove empty directories
if (purge) {
if (dryRun) {
result.removed.push(dir);
logger.debug(`Would force-remove directory: ${dir}`, verbose);
} else {
await fs.rm(dirPath, { recursive: true, force: true });
result.removed.push(dir);
logger.debug(`Force-removed directory: ${dir}`, verbose);
}
} else {
// Check if directory is empty
const entries = await fs.readdir(dirPath);
if (entries.length === 0) {
if (dryRun) {
result.removed.push(dir);
logger.debug(`Would remove empty directory: ${dir}`, verbose);
} else {
await fs.rmdir(dirPath);
result.removed.push(dir);
logger.debug(`Removed empty directory: ${dir}`, verbose);
}
} else {
result.preserved.push(dir);
logger.debug(`Preserved non-empty directory: ${dir} (${entries.length} items)`, verbose);
}
}
} catch (error) {
logger.warn(`Failed to remove directory ${dir}: ${error.message}`, verbose);
result.preserved.push(dir);
}
}
return result;
}
/**
* Preserves user data before uninstallation
*
* @param {string} targetDirectory - Base directory
* @param {Object} metadata - Installation metadata
* @param {boolean} verbose - Verbose logging
* @returns {Promise<string>} Preservation path
*/
async function preserveUserData(targetDirectory, metadata, verbose) {
const timestamp = new Date().toISOString().replace(/:/g, '-').replace(/\..+/, '');
const preservationDir = path.join(targetDirectory, `.claude-buddy-preserved-${timestamp}`);
try {
// Create preservation directory
await fs.mkdir(preservationDir, { recursive: true });
// Preserve user customizations from metadata
if (metadata && metadata.userCustomizations) {
for (const customization of metadata.userCustomizations) {
const sourceFile = path.join(targetDirectory, customization.file);
const destFile = path.join(preservationDir, customization.file);
try {
// Create parent directory
await fs.mkdir(path.dirname(destFile), { recursive: true });
// Copy file
await fs.copyFile(sourceFile, destFile);
logger.debug(`Preserved: ${customization.file}`, verbose);
} catch (error) {
logger.warn(`Failed to preserve ${customization.file}: ${error.message}`, verbose);
}
}
}
// Preserve specs directory if it exists
const specsDir = path.join(targetDirectory, 'specs');
try {
await fs.access(specsDir);
const destSpecsDir = path.join(preservationDir, 'specs');
await copyDirectory(specsDir, destSpecsDir);
logger.debug('Preserved specs directory', verbose);
} catch {
// Specs directory doesn't exist
}
// Create preservation info file
const infoFile = path.join(preservationDir, 'PRESERVATION_INFO.txt');
const infoContent = `
Claude Buddy User Data Preservation
Created: ${new Date().toISOString()}
Original Location: ${targetDirectory}
This directory contains your user customizations that were preserved
during Claude Buddy uninstallation.
To restore these files:
cp -r ${preservationDir}/* ${targetDirectory}/
To reinstall Claude Buddy:
claude-buddy install
To delete this backup:
rm -rf ${preservationDir}
`.trim();
await fs.writeFile(infoFile, infoContent, 'utf8');
return preservationDir;
} catch (error) {
throw new PreservationError(
`Failed to preserve user data: ${error.message}`,
[],
{ error: error.message }
);
}
}
/**
* Validates that directory has Claude Buddy installation
*
* @param {string} targetDirectory - Directory to validate
* @returns {Promise<boolean>} True if valid installation
*/
async function validateUninstall(targetDirectory) {
try {
// Check for .claude-buddy directory
const buddyDir = path.join(targetDirectory, '.claude-buddy');
await fs.access(buddyDir);
return true;
} catch {
return false;
}
}
/**
* Gets detailed removal plan
*
* @param {string} targetDirectory - Base directory
* @param {Object} metadata - Installation metadata
* @param {boolean} preserveCustomizations - Preserve user files
* @param {boolean} verbose - Verbose logging
* @returns {Promise<Object>} Removal plan
*/
async function getRemovalPlan(targetDirectory, metadata, preserveCustomizations, verbose) {
const plan = {
filesToRemove: [],
filesToPreserve: [],
directoriesToRemove: [],
directoriesToPreserve: [],
estimatedDuration: 2000,
backupRequired: preserveCustomizations
};
// Framework directories to process
const frameworkDirs = [
{ dir: '.claude-buddy', patterns: ['**/*'] },
{ dir: '.claude', patterns: ['**/*'] },
{ dir: 'directive', patterns: ['foundation.md'] }
];
// Collect all files
for (const { dir, patterns } of frameworkDirs) {
const baseDir = path.join(targetDirectory, dir);
try {
await fs.access(baseDir);
for (const pattern of patterns) {
try {
const files = await glob(pattern, {
cwd: baseDir,
nodir: true,
dot: true
});
for (const file of files) {
const relativeFile = path.join(dir, file);
// Check if file is a user customization
if (preserveCustomizations && isUserCustomization(relativeFile, metadata)) {
plan.filesToPreserve.push(relativeFile);
} else {
plan.filesToRemove.push(relativeFile);
}
}
} catch (error) {
logger.debug(`Failed to scan ${dir} with pattern ${pattern}: ${error.message}`, verbose);
}
}
} catch {
// Directory doesn't exist, skip
logger.debug(`Directory not found: ${dir}`, verbose);
}
}
return plan;
}
/**
* Checks if file is a user customization
*
* @param {string} filePath - File path to check
* @param {Object} metadata - Installation metadata
* @returns {boolean} True if user customization
*/
function isUserCustomization(filePath, metadata) {
// Check if file is in metadata customizations
if (metadata && metadata.userCustomizations) {
const isCustom = metadata.userCustomizations.some(custom =>
custom.file === filePath && custom.preserveOnUpdate
);
if (isCustom) {
return true;
}
}
// Check for custom personas
if (filePath.includes('personas/') && (filePath.includes('custom-') || filePath.includes('user-'))) {
return true;
}
// Check for specs directory
if (filePath.startsWith('specs/')) {
return true;
}
return false;
}
/**
* Copies directory recursively
*
* @param {string} source - Source directory
* @param {string} dest - Destination directory
* @returns {Promise<void>}
*/
async function copyDirectory(source, dest) {
await fs.mkdir(dest, { recursive: true });
const entries = await fs.readdir(source, { withFileTypes: true });
for (const entry of entries) {
const sourcePath = path.join(source, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
await copyDirectory(sourcePath, destPath);
} else {
await fs.copyFile(sourcePath, destPath);
}
}
}
module.exports = {
performUninstall,
confirmUninstall,
removeFiles,
cleanupDirectories,
preserveUserData,
validateUninstall,
getRemovalPlan,
isUserCustomization,
UninstallError,
PreservationError
};