claude-buddy
Version:
Claude Buddy installation and setup script
773 lines (683 loc) • 24.7 kB
JavaScript
/**
* Installer Module
*
* Performs fresh installation of Claude Buddy components with transactional
* execution and rollback capability.
*
* @module setup/lib/installer
*/
const fs = require('fs').promises;
const path = require('path');
const { glob } = require('glob');
const { createLogger } = require('./logger');
const { createTransaction, executeAction, commitTransaction } = require('./transaction');
const { checkPermissions } = require('./environment');
const { promptConfiguration, getDefaultConfig } = require('./config-prompts');
const logger = createLogger('installer');
/**
* Installation Error Class
*/
class InstallationError extends Error {
constructor(message, code, details = {}) {
super(message);
this.name = 'InstallationError';
this.code = code;
this.details = details;
}
}
/**
* Performs complete fresh installation process
*
* @param {Object} options - Installation options
* @param {string} options.targetDirectory - Target directory for installation
* @param {Object} options.manifest - Installation manifest
* @param {Array} options.components - Filtered components to install
* @param {Object} options.environment - Environment detection result
* @param {boolean} options.dryRun - Dry-run mode (default: false)
* @param {boolean} options.verbose - Verbose logging (default: false)
* @param {boolean} options.nonInteractive - Non-interactive mode (default: false)
* @param {boolean} options.skipConfigPrompts - Skip config prompts (default: false)
* @returns {Promise<Object>} Installation result
*/
async function performInstallation(options) {
const {
targetDirectory,
manifest,
components,
environment,
dryRun = false,
verbose = false,
nonInteractive = false,
skipConfigPrompts = false
} = options;
const startTime = Date.now();
const result = {
success: false,
version: manifest.version,
installedComponents: [],
skippedComponents: [],
duration: 0,
metadata: null,
warnings: [],
errors: []
};
let transaction = null;
try {
logger.section('Installation Process', verbose);
logger.info(`Target: ${targetDirectory}`, verbose);
logger.info(`Version: ${manifest.version}`, verbose);
logger.info(`Components: ${components.length}`, verbose);
logger.info(`Dry-run: ${dryRun ? 'Yes' : 'No'}`, verbose);
// Phase 1: Validation
logger.progress('Validating target directory', verbose);
const isValid = await validateTargetDirectory(targetDirectory);
if (!isValid) {
throw new InstallationError(
'Target directory validation failed',
'DIRECTORY_VALIDATION_FAILED',
{ targetDirectory }
);
}
// Phase 2: Create transaction
if (!dryRun) {
logger.progress('Creating transaction', verbose);
transaction = await createTransaction({
operation: 'install',
version: manifest.version,
targetDirectory
});
} else {
logger.info('Dry-run mode: No transaction created', verbose);
}
// Phase 3: Create directory structure
logger.progress('Creating directory structure', verbose);
const dirResult = await createDirectoryStructure(
targetDirectory,
manifest.directories,
transaction,
dryRun,
verbose
);
if (dryRun) {
logger.info(`Would create ${dirResult.created.length} directories`, verbose);
logger.info(`Would skip ${dirResult.existing.length} existing directories`, verbose);
} else {
logger.success(`Created ${dirResult.created.length} directories`, verbose);
}
if (dirResult.failed.length > 0) {
throw new InstallationError(
`Failed to create ${dirResult.failed.length} directories`,
'DIRECTORY_CREATION_FAILED',
{ failed: dirResult.failed }
);
}
// Phase 4: Install components
logger.progress('Installing components', verbose);
for (const component of components) {
try {
const componentResult = await installComponent(
component,
targetDirectory,
transaction,
dryRun,
verbose
);
result.installedComponents.push(component.name);
if (dryRun) {
logger.info(`Would install ${component.name}: ${componentResult.filesInstalled} files`, verbose);
} else {
logger.success(`Installed ${component.name}: ${componentResult.filesInstalled} files`, verbose);
}
if (componentResult.errors.length > 0) {
componentResult.errors.forEach(err => {
result.warnings.push({
type: 'file_error',
message: err,
component: component.name
});
});
}
} catch (error) {
if (component.type === 'required') {
throw error;
} else {
// Optional component failed - log warning and continue
result.skippedComponents.push(component.name);
result.warnings.push({
type: 'optional_dependency',
message: `Failed to install optional component ${component.name}: ${error.message}`,
component: component.name
});
logger.warn(`Skipped optional component ${component.name}: ${error.message}`, verbose);
}
}
}
// Phase 4.5: Configure hooks.json (interactive mode only)
if (!dryRun && !nonInteractive && !skipConfigPrompts && result.installedComponents.includes('config')) {
logger.progress('Configuring Claude Buddy', verbose);
try {
const hooksJsonPath = path.join(targetDirectory, '.claude', 'hooks.json');
// Prompt user for configuration
const userConfig = await promptConfiguration('install', null);
// Read existing hooks.json (distributed with config component)
const existingHooks = await fs.readJson(hooksJsonPath);
// Merge user config into hooks.json config section
existingHooks.config = userConfig;
// Write updated hooks.json
await fs.writeJson(hooksJsonPath, existingHooks, { spaces: 2 });
// Log the configuration action in transaction
if (transaction) {
await executeAction(transaction, {
type: 'update',
path: '.claude/hooks.json',
reason: 'Apply user configuration'
});
}
logger.success('Configuration applied', verbose);
} catch (error) {
result.warnings.push({
type: 'configuration_failed',
message: `Failed to configure hooks.json: ${error.message}`
});
logger.warn(`Configuration skipped: ${error.message}`, verbose);
}
} else if (!dryRun && (nonInteractive || skipConfigPrompts)) {
logger.info('Configuration prompts skipped (using defaults)', verbose);
} else if (dryRun) {
logger.info('Would prompt for configuration', verbose);
}
// Phase 5: Create default configuration
if (!dryRun) {
logger.progress('Creating default configuration', verbose);
await createDefaultConfiguration(
targetDirectory,
manifest,
result.installedComponents,
environment,
transaction,
verbose
);
logger.success('Configuration created', verbose);
} else {
logger.info('Would create default configuration', verbose);
}
// Phase 6: Create installation metadata
if (!dryRun) {
logger.progress('Creating installation metadata', verbose);
const metadata = await createInstallationMetadata(
targetDirectory,
manifest,
result.installedComponents,
environment,
transaction,
verbose
);
result.metadata = metadata;
logger.success('Metadata created', verbose);
} else {
logger.info('Would create installation metadata', verbose);
}
// Phase 7: Verify installation
logger.progress('Verifying installation', verbose);
const verification = await verifyInstallation(
targetDirectory,
manifest,
result.installedComponents,
dryRun,
verbose
);
if (!verification.valid) {
verification.issues.forEach(issue => {
result.errors.push({
type: issue.type,
message: issue.message,
path: issue.path,
recoverable: false
});
});
if (verification.issues.some(i => i.severity === 'error')) {
throw new InstallationError(
'Installation verification failed',
'VERIFICATION_FAILED',
{ issues: verification.issues }
);
}
}
if (dryRun) {
logger.info(verification.summary, verbose);
} else {
logger.success('Installation verified', verbose);
}
// Phase 8: Commit transaction
if (!dryRun && transaction) {
logger.progress('Committing transaction', verbose);
await commitTransaction(transaction);
logger.success('Transaction committed', verbose);
}
result.success = true;
result.duration = Date.now() - startTime;
logger.section('Installation Complete', verbose);
logger.success(`Installed ${result.installedComponents.length} components in ${result.duration}ms`, verbose);
return result;
} catch (error) {
logger.error(`Installation failed: ${error.message}`, verbose);
// Provide manual recovery instructions
if (!dryRun && transaction) {
try {
const permissions = await checkPermissions(targetDirectory);
logger.section('Manual Recovery Options', verbose);
if (permissions.isGitRepo) {
logger.info('Git repository detected:', verbose);
logger.info(' - Use "git status" to see changes', verbose);
logger.info(' - Use "git diff" to review changes', verbose);
logger.info(' - Use "git reset HEAD" to unstage changes', verbose);
logger.info(' - Use "git checkout -- <file>" to discard changes', verbose);
} else {
logger.info('Manual recovery:', verbose);
logger.info(' - Restore files manually if needed', verbose);
logger.info(' - Installation state has been partially modified', verbose);
}
logger.info(`Transaction log: .claude/logs/${transaction.transactionId}.json`, verbose);
} catch (recoveryError) {
logger.warn(`Could not determine recovery options: ${recoveryError.message}`, verbose);
}
}
result.errors.push({
type: error.code || 'unknown',
message: error.message,
recoverable: false
});
result.duration = Date.now() - startTime;
return result;
}
}
/**
* Creates directory structure with proper permissions
*
* @param {string} targetDirectory - Base directory
* @param {Array} directories - Directory specifications
* @param {Object} transaction - Active transaction
* @param {boolean} dryRun - Dry-run mode
* @param {boolean} verbose - Verbose logging
* @returns {Promise<Object>} Creation result
*/
async function createDirectoryStructure(targetDirectory, directories, transaction, dryRun, verbose) {
const result = {
created: [],
existing: [],
failed: []
};
for (const dirSpec of directories) {
const dirPath = path.join(targetDirectory, dirSpec.path);
try {
// Check if directory exists
try {
const stats = await fs.stat(dirPath);
if (stats.isDirectory()) {
result.existing.push(dirSpec.path);
logger.debug(`Directory exists: ${dirSpec.path}`, verbose);
continue;
}
} catch (err) {
// Directory doesn't exist - will create it
}
if (dryRun) {
result.created.push(dirSpec.path);
logger.debug(`Would create directory: ${dirSpec.path}`, verbose);
continue;
}
// Create directory through transaction system (or directly if no transaction)
if (transaction) {
await executeAction(transaction, {
type: 'create_directory',
path: dirSpec.path,
reason: 'Directory creation',
isDirectory: true,
targetPermissions: dirSpec.permissions
});
} else {
// No transaction - create directly
await fs.mkdir(dirPath, { recursive: true });
// Set permissions (Unix only)
if (dirSpec.permissions && process.platform !== 'win32') {
await fs.chmod(dirPath, parseInt(dirSpec.permissions, 8));
}
}
result.created.push(dirSpec.path);
logger.debug(`Created directory: ${dirSpec.path}`, verbose);
} catch (error) {
result.failed.push(dirSpec.path);
logger.error(`Failed to create directory ${dirSpec.path}: ${error.message}`, verbose);
}
}
return result;
}
/**
* Installs a single component by copying files
*
* @param {Object} component - Component to install
* @param {string} targetDirectory - Base directory
* @param {Object} transaction - Active transaction
* @param {boolean} dryRun - Dry-run mode
* @param {boolean} verbose - Verbose logging
* @returns {Promise<Object>} Component install result
*/
async function installComponent(component, targetDirectory, transaction, dryRun, verbose) {
const startTime = Date.now();
const result = {
component: component.name,
filesInstalled: 0,
filesCopied: [],
errors: [],
duration: 0
};
try {
// Calculate paths
const { sourcePath, targetPath } = calculateInstallationPaths(component, targetDirectory);
logger.debug(`Installing ${component.name} from ${sourcePath} to ${targetPath}`, verbose);
// Find files matching patterns
const files = [];
for (const pattern of component.filePatterns) {
const matchedFiles = await glob(pattern, {
cwd: sourcePath,
nodir: true,
dot: true
});
files.push(...matchedFiles);
}
logger.debug(`Found ${files.length} files for ${component.name}`, verbose);
// Copy each file
for (const file of files) {
const sourceFile = path.join(sourcePath, file);
const targetFile = path.join(targetPath, file);
try {
if (dryRun) {
result.filesCopied.push(file);
result.filesInstalled++;
continue;
}
// Read source file content
const sourceContent = await fs.readFile(sourceFile, 'utf-8');
const permissions = (process.platform !== 'win32' && file.endsWith('.py')) ? '0755' : null;
// Create file through transaction system (or directly if no transaction)
if (transaction) {
await executeAction(transaction, {
type: 'create',
path: path.join(component.target, file),
sourceContent: sourceContent,
targetPermissions: permissions,
component: component.name,
reason: `Install ${component.name} file`
});
} else {
// No transaction - copy directly
const targetDir = path.dirname(targetFile);
await fs.mkdir(targetDir, { recursive: true });
await fs.copyFile(sourceFile, targetFile);
if (permissions) {
await fs.chmod(targetFile, parseInt(permissions, 8));
}
}
result.filesCopied.push(file);
result.filesInstalled++;
logger.debug(`Copied file: ${file}`, verbose);
} catch (error) {
const errorMsg = `Failed to copy ${file}: ${error.message}`;
result.errors.push(errorMsg);
logger.error(errorMsg, verbose);
}
}
} catch (error) {
throw new InstallationError(
`Component installation failed: ${error.message}`,
'FILE_COPY_FAILED',
{ component: component.name, error: error.message }
);
}
result.duration = Date.now() - startTime;
return result;
}
/**
* Creates default configuration files
*
* @param {string} targetDirectory - Base directory
* @param {Object} manifest - Installation manifest
* @param {Array} installedComponents - List of installed component names
* @param {Object} environment - Environment detection result
* @param {Object} transaction - Active transaction
* @param {boolean} verbose - Verbose logging
* @returns {Promise<void>}
*/
async function createDefaultConfiguration(targetDirectory, manifest, installedComponents, environment, transaction, verbose) {
// Configuration is now managed in .claude/hooks.json (distributed with hooks component)
// This function is kept for future use if needed
// Note: buddy-config.json has been deprecated in v2.2.0
// All configuration now lives in .claude/hooks.json config section
// The hooks.json file is distributed from setup/dist/.claude/hooks.json
logger.debug('Configuration files distributed with components (hooks.json)', verbose);
}
/**
* Creates installation metadata
*
* @param {string} targetDirectory - Base directory
* @param {Object} manifest - Installation manifest
* @param {Array} installedComponents - List of installed component names
* @param {Object} environment - Environment detection result
* @param {Object} transaction - Active transaction
* @param {boolean} verbose - Verbose logging
* @returns {Promise<Object>} Created metadata
*/
async function createInstallationMetadata(targetDirectory, manifest, installedComponents, environment, transaction, verbose) {
const metadata = {
version: manifest.version,
installDate: new Date().toISOString(),
lastUpdateDate: null,
installMode: 'project',
installedComponents: {},
userCustomizations: [],
dependencies: {},
transactionHistory: [
{
transactionId: transaction ? transaction.transactionId : 'dry-run',
operation: 'install',
version: manifest.version,
timestamp: new Date().toISOString(),
status: 'completed'
}
]
};
// Populate installed components
for (const component of manifest.components) {
const isInstalled = installedComponents.includes(component.name);
metadata.installedComponents[component.name] = {
version: manifest.version,
enabled: isInstalled,
reason: isInstalled ? undefined : 'Dependency unavailable'
};
}
// Populate dependencies
if (environment.detectedDependencies) {
for (const dep of environment.detectedDependencies) {
metadata.dependencies[dep.name] = {
version: dep.version,
required: dep.name === 'node',
available: dep.available,
location: dep.location
};
}
}
// Write metadata file (v3.0.0: moved to .claude directory)
const metadataPath = path.join(targetDirectory, '.claude', 'install-metadata.json');
const metadataContent = JSON.stringify(metadata, null, 2);
if (transaction) {
await executeAction(transaction, {
type: 'create',
path: '.claude/install-metadata.json',
sourceContent: metadataContent,
reason: 'Create installation metadata'
});
} else {
await fs.writeFile(metadataPath, metadataContent, 'utf8');
}
logger.debug('Created install-metadata.json', verbose);
return metadata;
}
/**
* Verifies installation integrity
*
* @param {string} targetDirectory - Base directory
* @param {Object} manifest - Installation manifest
* @param {Array} installedComponents - List of installed component names
* @param {boolean} dryRun - Dry-run mode
* @param {boolean} _verbose - Verbose logging (unused)
* @returns {Promise<Object>} Verification result
*/
async function verifyInstallation(targetDirectory, manifest, installedComponents, dryRun, _verbose) {
const result = {
valid: true,
issues: [],
summary: ''
};
if (dryRun) {
result.summary = 'Installation would complete successfully (dry-run)';
return result;
}
// Check directories
for (const dirSpec of manifest.directories) {
const dirPath = path.join(targetDirectory, dirSpec.path);
try {
const stats = await fs.stat(dirPath);
if (!stats.isDirectory()) {
result.issues.push({
severity: 'error',
type: 'missing_directory',
message: `Expected directory but found file: ${dirSpec.path}`,
path: dirSpec.path
});
result.valid = false;
}
} catch (error) {
result.issues.push({
severity: 'error',
type: 'missing_directory',
message: `Directory not found: ${dirSpec.path}`,
path: dirSpec.path
});
result.valid = false;
}
}
// Check metadata file (v3.0.0: moved to .claude directory)
// Skip this check in dry-run or if transaction hasn't been committed yet
if (!dryRun) {
// Note: This check may not work if called before transaction commit
// In that case, we rely on the successful creation during Phase 6
const metadataPath = path.join(targetDirectory, '.claude', 'install-metadata.json');
try {
const metadataContent = await fs.readFile(metadataPath, 'utf8');
const metadata = JSON.parse(metadataContent);
if (metadata.version !== manifest.version) {
result.issues.push({
severity: 'warning',
type: 'invalid_config',
message: `Version mismatch: expected ${manifest.version}, found ${metadata.version}`,
path: 'install-metadata.json'
});
}
} catch (error) {
// If file doesn't exist yet, it might be because transaction hasn't committed
// This is not necessarily an error
result.issues.push({
severity: 'warning',
type: 'pending_file',
message: 'Installation metadata pending transaction commit',
path: metadataPath
});
}
}
// Check hooks configuration file (replaces buddy-config.json in v2.2.0+)
// Only check if config component was installed (v3.0.0: hooks.json is part of config component)
if (!dryRun && installedComponents.includes('config')) {
const hooksConfigPath = path.join(targetDirectory, '.claude', 'hooks.json');
try {
const configContent = await fs.readFile(hooksConfigPath, 'utf8');
const hooksConfig = JSON.parse(configContent); // Validate JSON
// Verify config section exists
if (!hooksConfig.config) {
result.issues.push({
severity: 'warning',
type: 'missing_config_section',
message: 'hooks.json exists but missing config section',
path: hooksConfigPath
});
}
} catch (error) {
// If file doesn't exist yet, it might be because transaction hasn't committed
result.issues.push({
severity: 'warning',
type: 'pending_file',
message: 'hooks.json pending transaction commit',
path: hooksConfigPath
});
}
}
if (result.valid) {
result.summary = `Installation verified: ${installedComponents.length} components installed successfully`;
} else {
result.summary = `Verification failed with ${result.issues.filter(i => i.severity === 'error').length} errors`;
}
return result;
}
/**
* Validates target directory
*
* @param {string} targetDirectory - Directory to validate
* @returns {Promise<boolean>} True if valid
*/
async function validateTargetDirectory(targetDirectory) {
try {
const stats = await fs.stat(targetDirectory);
if (!stats.isDirectory()) {
return false;
}
// Check write permissions
await fs.access(targetDirectory, fs.constants.W_OK);
return true;
} catch (error) {
return false;
}
}
/**
* Calculates installation paths for a component
*
* @param {Object} component - Component specification
* @param {string} targetDirectory - Base target directory
* @returns {Object} Source and target paths
*/
function calculateInstallationPaths(component, targetDirectory) {
const { resolveSourcePath, resolveTargetPath } = require('./manifest');
// Use manifest's path resolution (handles both npm package and dev scenarios)
const sourcePath = resolveSourcePath(component.source);
const targetPath = resolveTargetPath(component.target, targetDirectory);
return { sourcePath, targetPath };
}
/**
* Estimates installation duration
*
* @param {Array} components - Components to install
* @returns {number} Estimated duration in milliseconds
*/
function estimateInstallDuration(components) {
// Estimate 100ms per component + 500ms overhead
return (components.length * 100) + 500;
}
module.exports = {
performInstallation,
createDirectoryStructure,
installComponent,
createDefaultConfiguration,
verifyInstallation,
InstallationError,
validateTargetDirectory,
calculateInstallationPaths,
estimateInstallDuration
};