#!/usr/bin/env python3 """ Install Orchestrator - Coordinates complete installation workflow This module orchestrates the entire installation process, including: - Fresh installations - Upgrades with backup and rollback - Marketplace directory detection - Validation and reporting Key Features: - Comprehensive file discovery and copying - Automatic backup before upgrades - Rollback on failure - Marketplace directory auto-detection - Installation marker file tracking - Validation and coverage reporting Usage: from install_orchestrator import InstallOrchestrator # Fresh install orchestrator = InstallOrchestrator(plugin_dir, project_dir) result = orchestrator.fresh_install() # Upgrade install result = orchestrator.upgrade_install() # Rollback orchestrator.rollback(backup_dir) # Auto-detect marketplace orchestrator = InstallOrchestrator.auto_detect(project_dir) Date: 2025-11-17 Issue: GitHub #80 (Bootstrap overhaul - Phase 4) Agent: implementer Design Patterns: See library-design-patterns skill for standardized design patterns. See error-handling-patterns skill for exception handling. """ import json import shutil from datetime import datetime from pathlib import Path from typing import List, Dict, Any, Optional from dataclasses import dataclass, asdict # Import dependencies - handle both package import and direct script execution try: # Try relative imports first (when used as package) from .file_discovery import FileDiscovery from .copy_system import CopySystem from .installation_validator import InstallationValidator, ValidationResult from .security_utils import validate_path, audit_log except ImportError: # Fall back to same-directory imports (when run as script) import sys from pathlib import Path # Add lib directory to path for direct execution lib_dir = Path(__file__).parent if str(lib_dir) not in sys.path: sys.path.insert(0, str(lib_dir)) from file_discovery import FileDiscovery from copy_system import CopySystem from installation_validator import InstallationValidator from security_utils import validate_path, audit_log class InstallError(Exception): """Raised when installation encounters a critical error.""" pass @dataclass class InstallResult: """Result of installation operation. Attributes: status: "success" or "failure" files_copied: Number of files copied coverage: Coverage percentage (0-100) errors: List of error messages backup_dir: Optional backup directory path customizations_detected: Optional list of user customizations found files_added: Optional number of new files added during upgrade files_restored: Optional number of files restored during rollback """ status: str files_copied: int coverage: float errors: List[str] backup_dir: Optional[Path] = None customizations_detected: Optional[int] = None customized_files: Optional[List[str]] = None files_added: Optional[int] = None files_restored: Optional[int] = None def to_dict(self) -> Dict[str, Any]: """Convert to dictionary.""" result = asdict(self) if result["backup_dir"]: result["backup_dir"] = str(result["backup_dir"]) return result class InstallOrchestrator: """Orchestrates plugin installation workflow. Coordinates file discovery, copying, validation, backup, and rollback. Attributes: plugin_dir: Path to plugin source directory project_dir: Path to project directory claude_dir: Path to .claude directory in project discovery: FileDiscovery instance copy_system: CopySystem instance Examples: >>> orchestrator = InstallOrchestrator(plugin_dir, project_dir) >>> result = orchestrator.fresh_install() >>> print(f"Installed {result.files_copied} files") """ def __init__(self, plugin_dir: Path, project_dir: Path): """Initialize orchestrator with security validation. Args: plugin_dir: Plugin source directory project_dir: Project directory Raises: InstallError: If plugin directory doesn't exist ValueError: If path validation fails (path traversal, symlink) """ # Validate paths (prevents CWE-22, CWE-59) self.plugin_dir = validate_path( Path(plugin_dir).resolve(), purpose="plugin directory", allow_missing=False ) self.project_dir = validate_path( Path(project_dir).resolve(), purpose="project directory", allow_missing=False ) self.claude_dir = self.project_dir / ".claude" # Audit log initialization audit_log("install_orchestrator", "initialized", { "plugin_dir": str(self.plugin_dir), "project_dir": str(self.project_dir) }) self.discovery = FileDiscovery(self.plugin_dir) # CopySystem will be created per operation with specific source/dest self._copy_system_class = CopySystem @classmethod def from_marketplace(cls, marketplace_dir: Path, project_dir: Path) -> "InstallOrchestrator": """Create orchestrator from marketplace directory. Marketplace structure: ~/.claude/plugins/marketplaces/autonomous-dev/plugins/autonomous-dev/ Args: marketplace_dir: Marketplace root directory project_dir: Project directory Returns: InstallOrchestrator instance Raises: InstallError: If marketplace directory structure is invalid """ marketplace_dir = Path(marketplace_dir) plugin_dir = marketplace_dir / "plugins" / "autonomous-dev" if not plugin_dir.exists(): raise InstallError( f"Invalid marketplace structure. Expected: {plugin_dir}" ) return cls(plugin_dir, project_dir) @classmethod def auto_detect(cls, project_dir: Path) -> "InstallOrchestrator": """Auto-detect marketplace directory and create orchestrator. Checks common locations: - ~/.claude/plugins/marketplaces/autonomous-dev/ - /usr/local/share/claude/plugins/marketplaces/autonomous-dev/ Args: project_dir: Project directory Returns: InstallOrchestrator instance Raises: InstallError: If marketplace directory not found """ home = Path.home() search_paths = [ home / ".claude" / "plugins" / "marketplaces" / "autonomous-dev", Path("/usr/local/share/claude/plugins/marketplaces/autonomous-dev"), ] for marketplace_dir in search_paths: if marketplace_dir.exists(): return cls.from_marketplace(marketplace_dir, project_dir) raise InstallError( "Could not auto-detect marketplace directory. " f"Searched: {', '.join(str(p) for p in search_paths)}" ) def fresh_install(self, progress_callback: Optional[callable] = None, show_progress: bool = False) -> InstallResult: """Perform fresh installation. Workflow: 1. Pre-install cleanup (remove .claude/lib/ duplicates) 2. Discover all files in plugin directory 3. Copy all files to .claude directory 4. Set executable permissions on scripts 5. Create installation marker file 6. Validate coverage Args: progress_callback: Optional callback(current, total, message) for progress updates show_progress: Whether to print progress to stdout (default: False) Returns: InstallResult with status and metrics Raises: InstallError: If installation fails """ from plugins.autonomous_dev.lib.orphan_file_cleaner import OrphanFileCleaner errors = [] backup_dir = None # Create backup BEFORE try block if .claude exists (for rollback on failure) if self.claude_dir.exists(): backup_dir = self._create_backup() try: # Step 1: Pre-install cleanup (remove duplicate libraries) cleaner = OrphanFileCleaner(project_root=self.project_dir) cleanup_result = cleaner.pre_install_cleanup() if not cleanup_result.success: # Log warning but continue installation errors.append(f"Pre-install cleanup warning: {cleanup_result.error_message}") # Step 2: Discover all files if progress_callback: progress_callback(0, 100, "Discovering plugin files...") files = self.discovery.discover_all_files() total_files = len(files) if total_files == 0: raise InstallError("No files discovered in plugin directory") if progress_callback: progress_callback(10, 100, f"Discovered {total_files} files") # Step 3: Ensure .claude directory exists self.claude_dir.mkdir(parents=True, exist_ok=True) # Step 4: Copy all files using CopySystem if progress_callback: progress_callback(20, 100, "Installing files...") copy_system = CopySystem(self.plugin_dir, self.claude_dir) # Create wrapper callback that adds progress display def combined_callback(current: int, total: int, message: str): if show_progress: percentage = int((current / total) * 100) if total > 0 else 0 print(f"[{current}/{total}] {message} ({percentage}%)") if progress_callback: progress_callback(current, total, message) copy_result = copy_system.copy_all( files=files, overwrite=True, preserve_timestamps=True, continue_on_error=False, # Don't continue on error - we'll rollback progress_callback=combined_callback if (show_progress or progress_callback) else None ) files_copied = copy_result["files_copied"] if copy_result["errors"] > 0: errors.extend(copy_result["error_list"]) # If there were errors, raise to trigger rollback raise InstallError(f"Copy errors occurred: {copy_result['errors']} errors") # Step 5: Set executable permissions on scripts self._set_executable_permissions() # Step 6: Validate coverage validator = InstallationValidator(self.plugin_dir, self.claude_dir) validation = validator.validate() status = "success" if validation.status == "complete" else "failure" if validation.status != "complete": errors.append(f"Incomplete installation: {validation.coverage}% coverage") raise InstallError(f"Incomplete installation: {validation.coverage}% coverage") # Step 7: Create installation marker with coverage self._create_marker_file(files_copied, validation.coverage) return InstallResult( status=status, files_copied=files_copied, coverage=validation.coverage, errors=errors, ) except Exception as e: # Rollback on failure if backup exists if backup_dir and backup_dir.exists(): if show_progress: print(f"Installation failed, rolling back...") try: self.rollback(backup_dir) except Exception as rollback_error: raise InstallError( f"Installation failed and rollback failed: {e}, {rollback_error}" ) raise InstallError(f"Fresh installation failed: {e}") def upgrade(self, progress_callback: Optional[callable] = None, show_progress: bool = False) -> InstallResult: """Alias for upgrade_install() for backward compatibility.""" return self.upgrade_install(progress_callback=progress_callback, show_progress=show_progress) def upgrade_install(self, progress_callback: Optional[callable] = None, show_progress: bool = False) -> InstallResult: """Perform upgrade installation with backup. Workflow: 1. Pre-install cleanup (remove .claude/lib/ duplicates) 2. Create backup of existing installation 3. Discover files 4. Copy files (preserving user customizations if possible) 5. Set permissions 6. Update marker file 7. Validate 8. On failure: rollback Returns: InstallResult with backup directory Raises: InstallError: If upgrade fails and rollback fails """ from plugins.autonomous_dev.lib.orphan_file_cleaner import OrphanFileCleaner errors = [] backup_dir = None try: # Step 1: Pre-install cleanup (remove duplicate libraries) cleaner = OrphanFileCleaner(project_root=self.project_dir) cleanup_result = cleaner.pre_install_cleanup() if not cleanup_result.success: # Log warning but continue installation errors.append(f"Pre-install cleanup warning: {cleanup_result.error_message}") # Step 2: Create backup backup_dir = self._create_backup() # Step 3: Discover files files = self.discovery.discover_all_files() total_files = len(files) # Step 4: Detect customizations and prepare file list files_to_copy = [] customized_files = [] new_files = [] for source_file in files: rel_path = source_file.relative_to(self.plugin_dir) dest_file = self.claude_dir / rel_path # Track if this is a new file (doesn't exist in destination) if not dest_file.exists(): new_files.append(str(rel_path)) # Check if file was customized by user if dest_file.exists(): # Compare file contents to detect customization source_content = source_file.read_bytes() dest_content = dest_file.read_bytes() if source_content != dest_content: customized_files.append(str(rel_path)) # Only copy if not preserved or doesn't exist if not self._should_preserve(dest_file) or not dest_file.exists(): files_to_copy.append(source_file) # Use CopySystem for batch copy copy_system = CopySystem(self.plugin_dir, self.claude_dir) copy_result = copy_system.copy_all( files=files_to_copy, overwrite=True, preserve_timestamps=True, continue_on_error=True ) files_copied = copy_result["files_copied"] if copy_result["errors"] > 0: errors.extend(copy_result["error_list"]) # Step 5: Set permissions self._set_executable_permissions() # Step 6: Validate validator = InstallationValidator(self.plugin_dir, self.claude_dir) validation = validator.validate() if validation.status != "complete": # Rollback on incomplete installation errors.append(f"Validation failed: {validation.coverage}% coverage") self.rollback(backup_dir) return InstallResult( status="failure", files_copied=0, coverage=0.0, errors=errors, backup_dir=backup_dir, ) # Step 7: Update marker with coverage self._create_marker_file(files_copied, validation.coverage) return InstallResult( status="success", files_copied=files_copied, coverage=validation.coverage, errors=errors, backup_dir=backup_dir, customizations_detected=len(customized_files), customized_files=customized_files, files_added=len(new_files), ) except Exception as e: if backup_dir: try: self.rollback(backup_dir) except Exception as rollback_error: raise InstallError( f"Upgrade failed and rollback failed: {e}, {rollback_error}" ) raise InstallError(f"Upgrade installation failed: {e}") def rollback(self, backup_dir: Path) -> InstallResult: """Rollback installation from backup. Args: backup_dir: Path to backup directory Returns: InstallResult with success or failure status Raises: InstallError: If rollback fails critically """ backup_dir = Path(backup_dir).resolve() if not backup_dir.exists(): # Gracefully handle missing backup return InstallResult( status="failure", files_copied=0, coverage=0.0, errors=[f"Backup directory not found: {backup_dir}"], backup_dir=backup_dir, files_restored=0 ) try: # CRITICAL: If backup is inside .claude, move it outside first # Otherwise rmtree will delete the backup we're trying to restore from temp_backup = None if backup_dir.is_relative_to(self.claude_dir): temp_backup = self.project_dir / backup_dir.name shutil.move(str(backup_dir), str(temp_backup)) backup_dir = temp_backup # Remove current installation if self.claude_dir.exists(): shutil.rmtree(self.claude_dir) # Restore from backup shutil.copytree(backup_dir, self.claude_dir) # Count restored files (all files including nested) discovery = FileDiscovery(self.claude_dir) all_restored_files = discovery.discover_all_files() files_restored = len(all_restored_files) # Audit log for restoration audit_log("install_orchestrator", "rollback_complete", { "backup_dir": str(backup_dir), "files_restored": files_restored, "claude_dir": str(self.claude_dir) }) # Clean up temporary backup if we moved it if temp_backup and temp_backup.exists(): shutil.rmtree(temp_backup) return InstallResult( status="success", files_copied=0, coverage=100.0, errors=[], backup_dir=backup_dir, files_restored=files_restored ) except Exception as e: return InstallResult( status="failure", files_copied=0, coverage=0.0, errors=[f"Rollback failed: {e}"], backup_dir=backup_dir, files_restored=0 ) def _create_backup(self) -> Path: """Create backup of existing installation. Returns: Path to backup directory """ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") backup_dir = self.claude_dir / f".backup-{timestamp}" if self.claude_dir.exists(): # Copy existing installation to backup shutil.copytree( self.claude_dir, backup_dir, ignore=shutil.ignore_patterns(".backup-*") ) return backup_dir def _set_executable_permissions(self): """Set executable permissions on scripts and hooks.""" executable_patterns = [ "scripts/*.py", "hooks/*.py", ] for pattern in executable_patterns: for file_path in self.claude_dir.glob(pattern): if file_path.is_file(): # Security: Set explicit permissions (fixes CWE-732) # Use 0o755 (rwxr-xr-x) instead of bitwise OR to prevent # world-writable files file_path.chmod(0o755) def _create_marker_file(self, files_installed: int, coverage: float = 100.0): """Create installation marker file. Args: files_installed: Number of files installed coverage: Installation coverage percentage """ marker_file = self.claude_dir / ".autonomous-dev-installed" metadata = { "version": "3.8.0", # Should match plugin version "timestamp": datetime.now().isoformat(), "files_installed": files_installed, "coverage": coverage, "plugin_dir": str(self.plugin_dir), } with open(marker_file, "w") as f: json.dump(metadata, f, indent=2) def _should_preserve(self, file_path: Path) -> bool: """Check if file should be preserved during upgrade. Preserves user customizations in: - .env files - settings.local.json - Custom hooks Args: file_path: File path to check Returns: True if file should be preserved """ preserve_patterns = [ ".env", "settings.local.json", "custom_hooks/", ] for pattern in preserve_patterns: if pattern in str(file_path): return True return False def main(): """CLI entry point for installation orchestrator.""" import sys import argparse parser = argparse.ArgumentParser(description="Install autonomous-dev plugin") parser.add_argument("--plugin-dir", type=Path, help="Plugin source directory") parser.add_argument("--project-dir", type=Path, help="Project directory") parser.add_argument("--source", type=Path, help="Source plugin directory (alias for --plugin-dir)") parser.add_argument("--dest", type=Path, help="Destination project directory (alias for --project-dir)") parser.add_argument("--fresh-install", action="store_true", help="Perform fresh installation") parser.add_argument("--upgrade", action="store_true", help="Perform upgrade installation") parser.add_argument("--mode", choices=["fresh", "upgrade"], help="Installation mode (legacy)") parser.add_argument("--show-progress", action="store_true", help="Show progress indicators") parser.add_argument("--auto-detect", action="store_true", help="Auto-detect marketplace directory") args = parser.parse_args() # Handle argument aliases plugin_dir = args.plugin_dir or args.source project_dir = args.project_dir or args.dest if not project_dir: print("āŒ Error: --project-dir (or --dest) is required", file=sys.stderr) return 1 try: # Create orchestrator if args.auto_detect: orchestrator = InstallOrchestrator.auto_detect(project_dir) elif plugin_dir: orchestrator = InstallOrchestrator(plugin_dir, project_dir) else: print("āŒ Error: --plugin-dir (or --source) required unless --auto-detect is used", file=sys.stderr) return 1 # Determine mode if args.fresh_install or args.mode == "fresh": result = orchestrator.fresh_install(show_progress=args.show_progress) elif args.upgrade or args.mode == "upgrade": result = orchestrator.upgrade_install(show_progress=args.show_progress) else: # Default to fresh install result = orchestrator.fresh_install(show_progress=args.show_progress) print(f"{'āœ…' if result.status == 'success' else 'āŒ'} Installation {result.status}") print(f"šŸ“Š Files copied: {result.files_copied}") print(f"šŸ“ˆ Coverage: {result.coverage}%") if result.errors: print("\nāš ļø Errors:") for error in result.errors: print(f" - {error}") return 0 if result.status == "success" else 1 except InstallError as e: print(f"āŒ Installation Error: {e}", file=sys.stderr) return 1 except Exception as e: print(f"āŒ Unexpected Error: {e}", file=sys.stderr) return 1 # CLI interface for standalone usage if __name__ == "__main__": import sys sys.exit(main())