UNPKG

claudekit

Version:

CLI tools for Claude Code development workflow

1,038 lines (908 loc) 31.6 kB
import { promises as fs } from 'node:fs'; import * as path from 'node:path'; // import os from 'node:os'; // Unused import { confirm } from '@inquirer/prompts'; import { Logger } from '../utils/logger.js'; import { Colors } from '../utils/colors.js'; import { copyFileWithBackup, ensureDirectoryExists, checkWritePermission, pathExists, safeRemove, normalizePath, expandHomePath, } from './filesystem.js'; import { discoverComponents, // getComponent, // resolveDependencyOrder, resolveAllDependencies, getMissingDependencies, registryToComponents, // getComponentsByType } from './components.js'; import { findComponentsDirectory } from './paths.js'; import { detectProjectContext as detectProjectInfo } from './project-detection.js'; import type { Installation, Component, // ComponentType, InstallTarget, TemplateType, } from '../types/config.js'; /** * Installation Orchestrator Module * * Manages the complete installation lifecycle including: * - Planning and validation * - Transaction-based installation with rollback * - Progress tracking and reporting * - Dry-run simulation * - Error handling and recovery */ // ============================================================================ // Types and Interfaces // ============================================================================ export interface InstallStep { id: string; type: 'create-dir' | 'copy-file' | 'install-dependency' | 'configure'; description: string; source?: string; target: string; component?: Component; metadata?: Record<string, unknown>; } export interface InstallPlan { steps: InstallStep[]; components: Component[]; target: InstallTarget; directories: string[]; backupPaths: string[]; estimatedDuration: number; warnings: string[]; } export interface InstallProgress { totalSteps: number; completedSteps: number; currentStep?: InstallStep; phase: 'planning' | 'validating' | 'installing' | 'configuring' | 'complete' | 'failed'; message: string; warnings: string[]; errors: string[]; } export interface InstallResult { success: boolean; installedComponents: Component[]; modifiedFiles: string[]; createdDirectories: string[]; backupFiles: string[]; warnings: string[]; errors: string[]; duration: number; } export interface InstallOptions { dryRun?: boolean; force?: boolean; backup?: boolean; interactive?: boolean; installDependencies?: boolean; template?: TemplateType; customPath?: string; onProgress?: (progress: InstallProgress) => void; onPromptStart?: () => void; onPromptEnd?: () => void; } // ============================================================================ // Installation Transaction Management // ============================================================================ class InstallTransaction { private completedSteps: InstallStep[] = []; private createdFiles: string[] = []; private createdDirs: string[] = []; private backupFiles: string[] = []; private logger: Logger; constructor(logger: Logger) { this.logger = logger; } recordFileCreated(filePath: string): void { this.createdFiles.push(filePath); } recordDirCreated(dirPath: string): void { this.createdDirs.push(dirPath); } recordBackupCreated(backupPath: string): void { this.backupFiles.push(backupPath); } recordStepCompleted(step: InstallStep): void { this.completedSteps.push(step); } async rollback(): Promise<void> { this.logger.warn('Rolling back installation...'); // Remove created files for (const file of this.createdFiles.reverse()) { try { await safeRemove(file); this.logger.debug(`Removed file: ${file}`); } catch (error) { this.logger.error(`Failed to remove file ${file}: ${error}`); } } // Remove created directories (in reverse order) for (const dir of this.createdDirs.reverse()) { try { const entries = await fs.readdir(dir); if (entries.length === 0) { await fs.rmdir(dir); this.logger.debug(`Removed directory: ${dir}`); } } catch (error) { this.logger.debug(`Failed to remove directory ${dir}: ${error}`); } } // Restore backups for (const backupPath of this.backupFiles) { try { const originalPath = backupPath.replace(/\.backup-[\d-T]+$/, ''); await fs.rename(backupPath, originalPath); this.logger.debug(`Restored backup: ${backupPath} -> ${originalPath}`); } catch (error) { this.logger.error(`Failed to restore backup ${backupPath}: ${error}`); } } this.logger.info('Rollback completed'); } getCompletedSteps(): InstallStep[] { return [...this.completedSteps]; } getCreatedFiles(): string[] { return [...this.createdFiles]; } getCreatedDirs(): string[] { return [...this.createdDirs]; } getBackupFiles(): string[] { return [...this.backupFiles]; } async cleanupBackups(): Promise<void> { // Remove backup files after successful installation for (const backupPath of this.backupFiles) { try { await safeRemove(backupPath); this.logger.debug(`Removed backup: ${backupPath}`); } catch (error) { this.logger.warn(`Failed to remove backup ${backupPath}: ${error}`); } } } } // ============================================================================ // Installation Planning // ============================================================================ /** * Create an installation plan based on the configuration */ export async function createInstallPlan( installation: Installation, options: InstallOptions = {} ): Promise<InstallPlan> { const logger = Logger.create('installer'); const steps: InstallStep[] = []; const directories = new Set<string>(); const backupPaths: string[] = []; const warnings: string[] = []; // Determine base paths const userDir = expandHomePath('~/.claude'); const projectDir = path.join(process.cwd(), '.claude'); // Get components in dependency order with auto-included dependencies let sourceDir: string; try { sourceDir = await findComponentsDirectory(); } catch (error) { throw new Error( `Could not find claudekit components: ${error instanceof Error ? error.message : 'Unknown error'}` ); } const registry = await discoverComponents(sourceDir); const componentIds = installation.components.map((c) => c.id); // Resolve all dependencies (including transitive) const resolvedIds = resolveAllDependencies(componentIds, registry, { includeOptional: false, maxDepth: 10, }); // Get missing dependencies that were auto-included const missingDeps = getMissingDependencies(componentIds, registry); if (missingDeps.length > 0) { logger.info(`Auto-including dependencies: ${missingDeps.join(', ')}`); } // Map resolved IDs to components const orderedComponents: Component[] = []; for (const id of resolvedIds) { // First check if it's in the original selection let component = installation.components.find((c) => c.id === id); // If not, try to find it in the registry (auto-included dependency) if (!component) { const registryComponent = registry.components.get(id); if (registryComponent) { // Convert registry component to Component type component = { id: registryComponent.metadata.id, type: registryComponent.type, name: registryComponent.metadata.name, description: registryComponent.metadata.description, path: registryComponent.path, dependencies: registryComponent.metadata.dependencies, category: registryComponent.metadata.category, version: registryComponent.metadata.version, author: registryComponent.metadata.author, config: { allowedTools: registryComponent.metadata.allowedTools, argumentHint: registryComponent.metadata.argumentHint, shellOptions: registryComponent.metadata.shellOptions, timeout: registryComponent.metadata.timeout, retries: registryComponent.metadata.retries, }, createdAt: registryComponent.lastModified, updatedAt: registryComponent.lastModified, }; } } if (component) { orderedComponents.push(component); } } // Plan directory creation if (installation.target === 'user' || installation.target === 'both') { directories.add(userDir); directories.add(path.join(userDir, 'commands')); directories.add(path.join(userDir, 'agents')); // Hook directories removed - hooks are now embedded } if (installation.target === 'project' || installation.target === 'both') { directories.add(projectDir); directories.add(path.join(projectDir, 'commands')); directories.add(path.join(projectDir, 'agents')); // Hook directories removed - hooks are now embedded } // Add custom path if specified if (options.customPath !== undefined) { const customPath = normalizePath(options.customPath); directories.add(customPath); directories.add(path.join(customPath, 'commands')); directories.add(path.join(customPath, 'agents')); // Hook directories removed - hooks are now embedded } // First pass: collect all directories needed by analyzing components for (const component of orderedComponents) { // Skip hook components - they are now embedded if (component.type === 'hook') { continue; } // Calculate relative path from source base to preserve directory structure const componentTypeDir = component.type === 'agent' ? 'agents' : 'commands'; // Use basename as fallback if sourceDir is not available (e.g., in tests) let relativePath: string; if (sourceDir) { const baseSourceDir = path.join(sourceDir, componentTypeDir); relativePath = path.relative(baseSourceDir, component.path); } else { // Fallback for tests or when sourceDir is not available relativePath = path.basename(component.path); } // Collect directories for each target if (installation.target === 'user' || installation.target === 'both') { const targetPath = path.join(userDir, componentTypeDir, relativePath); // Ensure all parent directories are added to the directories set let parentDir = path.dirname(targetPath); const baseDir = path.join(userDir, componentTypeDir); while (parentDir !== baseDir && parentDir.startsWith(baseDir)) { directories.add(parentDir); parentDir = path.dirname(parentDir); } } if (installation.target === 'project' || installation.target === 'both') { const targetPath = path.join(projectDir, componentTypeDir, relativePath); // Ensure all parent directories are added to the directories set let parentDir = path.dirname(targetPath); const baseDir = path.join(projectDir, componentTypeDir); while (parentDir !== baseDir && parentDir.startsWith(baseDir)) { directories.add(parentDir); parentDir = path.dirname(parentDir); } } if (options.customPath !== undefined) { const customPath = normalizePath(options.customPath); const targetPath = path.join(customPath, componentTypeDir, relativePath); // Ensure all parent directories are added to the directories set let parentDir = path.dirname(targetPath); const baseDir = path.join(customPath, componentTypeDir); while (parentDir !== baseDir && parentDir.startsWith(baseDir)) { directories.add(parentDir); parentDir = path.dirname(parentDir); } } } // Create directory steps FIRST, before any file operations for (const dir of directories) { steps.push({ id: `create-dir-${dir}`, type: 'create-dir', description: `Create directory: ${dir}`, target: dir, }); } // Second pass: create file copy and permission steps for (const component of orderedComponents) { // Skip hook components - they are now embedded if (component.type === 'hook') { continue; } const targets: string[] = []; // Calculate relative path from source base to preserve directory structure const componentTypeDir = component.type === 'agent' ? 'agents' : 'commands'; // Use basename as fallback if sourceDir is not available (e.g., in tests) let relativePath: string; if (sourceDir) { const baseSourceDir = path.join(sourceDir, componentTypeDir); relativePath = path.relative(baseSourceDir, component.path); } else { // Fallback for tests or when sourceDir is not available relativePath = path.basename(component.path); } // Determine target paths if (installation.target === 'user' || installation.target === 'both') { targets.push(path.join(userDir, componentTypeDir, relativePath)); } if (installation.target === 'project' || installation.target === 'both') { targets.push(path.join(projectDir, componentTypeDir, relativePath)); } if (options.customPath !== undefined) { const customPath = normalizePath(options.customPath); targets.push(path.join(customPath, componentTypeDir, relativePath)); } // Create copy steps for each target for (const target of targets) { // Check if file exists for backup planning if ((await pathExists(target)) && options.backup !== false) { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const backupPath = `${target}.backup-${timestamp}`; backupPaths.push(backupPath); } steps.push({ id: `copy-${component.id}-to-${target}`, type: 'copy-file', description: `Install ${component.name} to ${path.dirname(target)}`, source: component.path, target, component, }); } // Plan dependency installation if enabled if (options.installDependencies === true && component.dependencies.length > 0) { for (const dep of component.dependencies) { if (!['git', 'npm', 'yarn', 'pnpm', 'node'].includes(dep)) { continue; // Skip system dependencies } steps.push({ id: `install-dep-${dep}-for-${component.id}`, type: 'install-dependency', description: `Check/install dependency: ${dep}`, target: dep, component, metadata: { dependency: dep }, }); } } } // Check for warnings if ( installation.projectInfo?.hasTypeScript === true && !orderedComponents.some((c) => c.id === 'typecheck-changed') ) { warnings.push('TypeScript detected but typecheck-changed hook not selected'); } if ( installation.projectInfo?.hasESLint === true && !orderedComponents.some((c) => c.id === 'lint-changed') ) { warnings.push('ESLint detected but lint-changed hook not selected'); } // Warn about circular dependencies if any were detected if (registry.dependencyGraph?.cycles && registry.dependencyGraph.cycles.length > 0) { for (const cycle of registry.dependencyGraph.cycles) { warnings.push(`Circular dependency detected: ${cycle.join(' -> ')}`); } } // Estimate duration (rough approximation) const estimatedDuration = steps.length * 100; // 100ms per step average return { steps, components: orderedComponents, target: installation.target, directories: Array.from(directories), backupPaths, estimatedDuration, warnings, }; } // ============================================================================ // Installation Validation // ============================================================================ /** * Validate installation plan before execution */ export async function validateInstallPlan(plan: InstallPlan): Promise<string[]> { const errors: string[] = []; const checkedPaths = new Set<string>(); // Validate write permissions for directories for (const dir of plan.directories) { const parentDir = path.dirname(dir); if (!checkedPaths.has(parentDir)) { if (!(await checkWritePermission(parentDir))) { errors.push(`No write permission for directory: ${parentDir}`); } checkedPaths.add(parentDir); } } // Validate source files exist for (const step of plan.steps) { if (step.type === 'copy-file' && step.source !== undefined) { if (!(await pathExists(step.source))) { errors.push(`Source file not found: ${step.source}`); } } } // Note: Circular dependency validation is now handled by the dependency resolver // It will warn but still allow installation in a safe order return errors; } // ============================================================================ // Dry Run Simulation // ============================================================================ /** * Simulate installation without making changes */ export async function simulateInstallation( plan: InstallPlan, options: InstallOptions = {} ): Promise<InstallResult> { const logger = Logger.create('installer:dry-run'); const startTime = Date.now(); const warnings: string[] = [...plan.warnings]; const errors: string[] = []; logger.info('=== DRY RUN MODE ==='); logger.info(`Would install ${plan.components.length} components to ${plan.target}`); // Report progress through planning phase if (options.onProgress) { options.onProgress({ totalSteps: plan.steps.length, completedSteps: 0, phase: 'planning', message: 'Simulating installation plan...', warnings: [], errors: [], }); } // Simulate each step for (let i = 0; i < plan.steps.length; i++) { const step = plan.steps[i]; if (options.onProgress && step) { options.onProgress({ totalSteps: plan.steps.length, completedSteps: i, currentStep: step, phase: 'installing', message: step.description, warnings, errors, }); } if (step) { logger.info(`[${i + 1}/${plan.steps.length}] ${step.description}`); } // Simulate validation for each step type if (step) { switch (step.type) { case 'create-dir': if (await pathExists(step.target)) { logger.debug(` Directory already exists: ${step.target}`); } else { logger.debug(` Would create directory: ${step.target}`); } break; case 'copy-file': if (await pathExists(step.target)) { if (options.backup !== false) { logger.debug(` Would backup existing file: ${step.target}`); } logger.debug(` Would overwrite file: ${step.target}`); } else { logger.debug(` Would create file: ${step.target}`); } break; case 'install-dependency': logger.debug(` Would check/install dependency: ${step.target}`); break; } } } // Final progress update if (options.onProgress) { options.onProgress({ totalSteps: plan.steps.length, completedSteps: plan.steps.length, phase: 'complete', message: 'Dry run completed successfully', warnings, errors, }); } const duration = Date.now() - startTime; logger.info(`\nDry run completed in ${duration}ms`); logger.info(`Would have:`); logger.info(` - Created ${plan.directories.length} directories`); logger.info(` - Installed ${plan.components.length} components`); logger.info(` - Created ${plan.backupPaths.length} backups`); if (warnings.length > 0) { logger.warn('\nWarnings:'); warnings.forEach((w) => logger.warn(` - ${w}`)); } return { success: true, installedComponents: plan.components, modifiedFiles: plan.steps.filter((s) => s.type === 'copy-file').map((s) => s.target), createdDirectories: plan.directories, backupFiles: plan.backupPaths, warnings, errors, duration, }; } // ============================================================================ // Installation Execution // ============================================================================ /** * Execute the installation plan */ export async function executeInstallation( plan: InstallPlan, options: InstallOptions = {} ): Promise<InstallResult> { const logger = Logger.create('installer'); const transaction = new InstallTransaction(logger); const startTime = Date.now(); const warnings: string[] = [...plan.warnings]; const errors: string[] = []; let completedSteps = 0; // Initialize progress if (options.onProgress) { options.onProgress({ totalSteps: plan.steps.length, completedSteps: 0, phase: 'installing', message: 'Starting installation...', warnings: [], errors: [], }); } try { // Execute each step for (const step of plan.steps) { if (options.onProgress) { options.onProgress({ totalSteps: plan.steps.length, completedSteps, currentStep: step, phase: 'installing', message: step.description, warnings, errors, }); } logger.debug(`Executing: ${step.description}`); try { await executeStep(step, transaction, options); transaction.recordStepCompleted(step); completedSteps++; } catch (error) { const errorMsg = `Failed to ${step.description}: ${error}`; errors.push(errorMsg); logger.error(errorMsg); throw error; } } // Report completion if (options.onProgress) { options.onProgress({ totalSteps: plan.steps.length, completedSteps: plan.steps.length, phase: 'complete', message: 'Installation completed successfully', warnings, errors, }); } const duration = Date.now() - startTime; logger.success(`Installation completed in ${duration}ms`); // Clean up backup files on success if (options.dryRun !== true) { await transaction.cleanupBackups(); } return { success: true, installedComponents: plan.components, modifiedFiles: transaction.getCreatedFiles(), createdDirectories: transaction.getCreatedDirs(), backupFiles: transaction.getBackupFiles(), warnings, errors, duration, }; } catch (error) { // Report failure if (options.onProgress) { options.onProgress({ totalSteps: plan.steps.length, completedSteps, phase: 'failed', message: `Installation failed: ${error}`, warnings, errors, }); } logger.error(`Installation failed: ${error}`); // Rollback transaction if (options.dryRun !== true) { await transaction.rollback(); } const duration = Date.now() - startTime; return { success: false, installedComponents: [], modifiedFiles: [], createdDirectories: [], backupFiles: [], warnings, errors, duration, }; } } /** * Execute a single installation step */ async function executeStep( step: InstallStep, transaction: InstallTransaction, options: InstallOptions ): Promise<void> { const logger = Logger.create('installer:step'); switch (step.type) { case 'create-dir': logger.debug(`Creating directory: ${step.target}`); await ensureDirectoryExists(step.target); transaction.recordDirCreated(step.target); break; case 'copy-file': if (step.source === undefined) { throw new Error('Source file required for copy operation'); } logger.debug(`Copying file: ${step.source} -> ${step.target}`); // Record backup if created if (options.backup !== false && (await pathExists(step.target))) { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const backupPath = `${step.target}.backup-${timestamp}`; transaction.recordBackupCreated(backupPath); logger.debug(`Creating backup: ${backupPath}`); } await copyFileWithBackup( step.source, step.target, options.backup !== false, options.force === true ? undefined : async (_source: string, target: string): Promise<boolean> => { // Check if we're in non-interactive mode if (options.interactive === false) { throw new Error( `\nFile conflict detected: ${target} already exists with different content.\n` + `To overwrite existing files, run with --force flag.` ); } // Interactive conflict resolution (skip if force is true) // Notify that we're starting a prompt (to pause progress) if (options.onPromptStart) { options.onPromptStart(); } // Clear the spinner and show conflict info process.stdout.write('\x1B[2K\r'); console.log(`\n${Colors.warn('━━━ File Conflict Detected ━━━')}`); console.log(`Target file: ${Colors.accent(target)}`); console.log(`This file already exists with different content.`); console.log(''); const shouldOverwrite = await confirm({ message: 'Do you want to overwrite the existing file?', default: false, }); console.log(''); // Add spacing after prompt // Notify that prompt is done (to resume progress) if (options.onPromptEnd) { options.onPromptEnd(); } return shouldOverwrite; } ); transaction.recordFileCreated(step.target); break; case 'install-dependency': { // For now, just check if dependency exists // Future: implement actual dependency installation const depName = (step.metadata?.['dependency'] as string) ?? step.target; logger.debug(`Checking dependency: ${depName}`); break; } default: // This should never happen as step.type is a union type throw new Error(`Unknown step type: ${step.type}`); } } // ============================================================================ // Main Installer API // ============================================================================ /** * Main installation orchestrator */ export class Installer { private logger: Logger; private options: InstallOptions; constructor(options: InstallOptions = {}) { this.logger = Logger.create('installer'); this.options = options; } /** * Run the complete installation process */ async install(installation: Installation): Promise<InstallResult> { try { // Set log level based on options if (this.options.interactive === false && process.env['DEBUG'] === undefined) { this.logger.setLevel('warn'); } // Phase 1: Create installation plan this.reportProgress({ totalSteps: 0, completedSteps: 0, phase: 'planning', message: 'Creating installation plan...', warnings: [], errors: [], }); const plan = await createInstallPlan(installation, this.options); // Phase 2: Validate plan this.reportProgress({ totalSteps: plan.steps.length, completedSteps: 0, phase: 'validating', message: 'Validating installation plan...', warnings: plan.warnings, errors: [], }); const validationErrors = await validateInstallPlan(plan); if (validationErrors.length > 0 && this.options.force !== true) { return { success: false, installedComponents: [], modifiedFiles: [], createdDirectories: [], backupFiles: [], warnings: plan.warnings, errors: validationErrors, duration: 0, }; } // Phase 3: Execute installation (or dry run) if (this.options.dryRun === true) { return await simulateInstallation(plan, this.options); } else { return await executeInstallation(plan, this.options); } } catch (error) { this.logger.error(`Installation failed: ${error}`); return { success: false, installedComponents: [], modifiedFiles: [], createdDirectories: [], backupFiles: [], warnings: [], errors: [`Installation failed: ${error}`], duration: 0, }; } } /** * Create a default installation configuration */ async createDefaultInstallation(target: InstallTarget = 'project'): Promise<Installation> { // Detect project information const projectInfo = await detectProjectInfo(process.cwd()); // Discover available components let sourceDir: string; try { sourceDir = await findComponentsDirectory(); } catch (error) { throw new Error( `Could not find claudekit components: ${error instanceof Error ? error.message : 'Unknown error'}` ); } const registry = await discoverComponents(sourceDir); const allComponents = registryToComponents(registry); // Select recommended components based on project const selectedComponents: Component[] = []; // Always include base hooks const baseHooks = ['create-checkpoint', 'check-todos']; for (const hookId of baseHooks) { const hook = allComponents.find((c) => c.id === hookId && c.type === 'hook'); if (hook) { selectedComponents.push(hook); } } // Add TypeScript hook if TypeScript detected if (projectInfo?.hasTypeScript) { const tsHook = allComponents.find((c) => c.id === 'typecheck-changed' && c.type === 'hook'); if (tsHook) { selectedComponents.push(tsHook); } } // Add ESLint hook if ESLint detected if (projectInfo?.hasESLint) { const eslintHook = allComponents.find((c) => c.id === 'lint-changed' && c.type === 'hook'); if (eslintHook) { selectedComponents.push(eslintHook); } } // Add some useful commands const recommendedCommands = ['checkpoint-create', 'checkpoint-list', 'git-status']; for (const cmdId of recommendedCommands) { const cmd = allComponents.find((c) => c.id === cmdId && c.type === 'command'); if (cmd !== undefined) { selectedComponents.push(cmd); } } return { components: selectedComponents, target, backup: true, dryRun: false, projectInfo, template: 'default', installDependencies: true, }; } /** * Report progress to callback if provided */ private reportProgress(progress: InstallProgress): void { if (this.options.onProgress) { this.options.onProgress(progress); } } } /** * Convenience function to run installation with default settings */ export async function installComponents( components: Component[], target: InstallTarget = 'project', options: InstallOptions = {} ): Promise<InstallResult> { const installer = new Installer(options); const installation: Installation = { components, target, backup: options.backup ?? true, dryRun: options.dryRun ?? false, installDependencies: options.installDependencies ?? true, }; return installer.install(installation); }