TradingAgents/.claude/lib/install_orchestrator.py

690 lines
24 KiB
Python

#!/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())