"""CLI interface for dev-env-sync using Click.""" import os from pathlib import Path from typing import Optional import click from dev_env_sync import __version__ from dev_env_sync.core.config import ConfigLoader, ConfigSchema from dev_env_sync.core.platform import PlatformDetector, PlatformNotSupported from dev_env_sync.managers.dotfile import DotfileManager from dev_env_sync.managers.editor import EditorManager from dev_env_sync.managers.package import PackageManagerFactory from dev_env_sync.managers.backup import BackupManager from dev_env_sync.utils.logging import setup_logging, get_logger def get_default_config_path() -> Optional[str]: """Get the default configuration file path.""" env_path = os.environ.get("DEV_ENV_SYNC_CONFIG") if env_path: return env_path for default_path in ConfigLoader.DEFAULT_CONFIG_PATHS: path = Path(default_path).expanduser() if path.exists(): return str(path) return None def load_config(config_path: Optional[str] = None) -> ConfigSchema: """Load configuration from file.""" path = config_path or get_default_config_path() if path is None: raise click.ClickException( "No configuration file found. Use --config or set DEV_ENV_SYNC_CONFIG." ) loader = ConfigLoader(path) try: return loader.load() except Exception as e: raise click.ClickException(f"Failed to load configuration: {e}") @click.group() @click.version_option(version=__version__) @click.option("--verbose", "-v", is_flag=True, help="Enable verbose output") @click.option("--dry-run", "-n", is_flag=True, help="Preview changes without applying") @click.option("--config", "-c", type=click.Path(exists=True), help="Path to configuration file") @click.pass_context def cli(ctx, verbose, dry_run, config): """Dev Environment Sync - Manage and sync your developer environment.""" ctx.ensure_object(dict) ctx.obj["verbose"] = verbose ctx.obj["dry_run"] = dry_run ctx.obj["config_path"] = config log_level = "DEBUG" if verbose else "INFO" logger = setup_logging(verbose=verbose, log_level=getattr(__import__("logging"), log_level)) logger.info(f"Dev Environment Sync v{__version__}") if dry_run: logger.info("DRY-RUN MODE: No changes will be made") try: platform_info = PlatformDetector.detect() logger.info(f"Platform: {platform_info.platform.value} ({platform_info.system} {platform_info.release})") except Exception as e: logger.warning(f"Could not detect platform: {e}") @cli.command() @click.pass_context def sync(ctx): """Synchronize the developer environment.""" config_path = ctx.obj.get("config_path") verbose = ctx.obj.get("verbose", False) dry_run = ctx.obj.get("dry_run", False) logger = get_logger(__name__) try: config = load_config(config_path) if config.backup.enabled and not dry_run: backup_manager = BackupManager( backup_dir=config.backup.directory, dry_run=dry_run, logger=logger, ) backup_manager.create_backup(config) dotfiles_results = [] if config.dotfiles: dotfile_manager = DotfileManager( config=config, dry_run=dry_run, backup_dir=config.backup.directory, logger=logger, ) dotfiles_results = dotfile_manager.sync_all() dotfile_manager.print_summary() editor_results = {} if config.editors: editor_manager = EditorManager( config=config, dry_run=dry_run, logger=logger, ) editor_results = editor_manager.sync_all() editor_manager.print_summary(editor_results) package_results = [] if config.packages: for pkg_config in config.packages: manager = PackageManagerFactory.get_manager( pkg_config.name, dry_run=dry_run, logger=logger, ) if manager and manager.is_available(): results = manager.install_multiple(pkg_config.packages) package_results.extend(results) else: for pkg in pkg_config.packages: logger.info(f"Package: {pkg} (manager: {pkg_config.name})") if dry_run: logger.info("\n=== DRY-RUN SUMMARY ===") logger.info(f"Sync would affect:") logger.info(f" Dotfiles: {len(dotfiles_results)}") logger.info(f" Editors: {len(editor_results)}") logger.info(f" Packages: {len(package_results)}") click.echo("Environment sync completed!") except Exception as e: raise click.ClickException(str(e)) @cli.command() @click.pass_context def diff(ctx): """Show pending changes without applying them.""" config_path = ctx.obj.get("config_path") verbose = ctx.obj.get("verbose", False) logger = get_logger(__name__) try: config = load_config(config_path) click.echo("=== PENDING CHANGES ===\n") dotfile_manager = DotfileManager( config=config, dry_run=True, logger=logger, ) for name, dotfile_config in config.dotfiles.items(): source = Path(dotfile_config.source) target = Path(dotfile_config.target) if not source.exists(): click.echo(f"[MISSING] {name}: Source not found - {source}") continue status = dotfile_manager.symlink_handler.check_status(source, target) if status == "ok": click.echo(f"[OK] {name}: Already synced") elif status == "missing": click.echo(f"[CREATE] {name}: Would create symlink {target} -> {source}") elif status == "broken": click.echo(f"[REPLACE] {name}: Would recreate symlink {target} -> {source}") elif status == "file_exists": click.echo(f"[REPLACE] {name}: Would replace file at {target}") if config.editors: click.echo("\n=== EDITOR CONFIGURATIONS ===\n") editor_manager = EditorManager(config=config, dry_run=True, logger=logger) for editor_name in config.editors: click.echo(f" {editor_name}") if config.packages: click.echo("\n=== PACKAGES TO INSTALL ===\n") for pkg_config in config.packages: click.echo(f" [{pkg_config.name}]") for pkg in pkg_config.packages: click.echo(f" - {pkg}") except Exception as e: raise click.ClickException(str(e)) @cli.command() @click.option("--output", "-o", type=click.Path(), help="Output file path for sample config") @click.pass_context def init(ctx, output): """Generate a sample configuration file.""" verbose = ctx.obj.get("verbose", False) if output is None: output = ".dev-env-sync.yml" config = ConfigLoader.create_default_config() try: import yaml with open(output, 'w', encoding='utf-8') as f: yaml.dump(config.to_dict(), f, default_flow_style=False, sort_keys=False) click.echo(f"Sample configuration written to: {output}") click.echo("\nEdit this file to match your environment, then run:") click.echo(f" dev-env-sync sync --config {output}") except IOError as e: raise click.ClickException(f"Failed to write config: {e}") except Exception as e: raise click.ClickException(f"Error: {e}") @cli.command() @click.option("--output", "-o", type=click.Path(), help="Output file path for backup") @click.pass_context def backup(ctx, output): """Create a manual backup of dotfiles.""" config_path = ctx.obj.get("config_path") dry_run = ctx.obj.get("dry_run", False) verbose = ctx.obj.get("verbose", False) logger = get_logger(__name__) try: config = load_config(config_path) backup_dir = output or config.backup.directory backup_manager = BackupManager( backup_dir=backup_dir, dry_run=dry_run, logger=logger, ) files_to_backup = [ Path(dotfile.target) for dotfile in config.dotfiles.values() if dotfile.backup ] backup_path = backup_manager.create_backup(config, files_to_backup) click.echo(f"Backup created: {backup_path}") except Exception as e: raise click.ClickException(str(e)) @cli.command() @click.argument("timestamp", required=False) @click.option("--list", "list_backups", is_flag=True, help="List available backups") @click.option("--verify", is_flag=True, help="Verify backup integrity") @click.option("--restore-all", is_flag=True, help="Restore all files from backup") @click.option("--file", multiple=True, help="Specific files to restore") @click.pass_context def restore(ctx, timestamp, list_backups, verify, restore_all, file): """Restore from a previous backup.""" config_path = ctx.obj.get("config_path") dry_run = ctx.obj.get("dry_run", False) verbose = ctx.obj.get("verbose", False) logger = get_logger(__name__) try: config = load_config(config_path) backup_manager = BackupManager( backup_dir=config.backup.directory, dry_run=dry_run, logger=logger, ) if list_backups: backups = backup_manager.list_backups() if not backups: click.echo("No backups found.") else: click.echo("Available backups:") for backup in backups: click.echo( f" {backup['timestamp']} - " f"{backup['file_count']} files - " f"{backup.get('config_name', 'Unknown')}" ) return if verify and timestamp: result = backup_manager.verify_backup(timestamp) if result["valid"]: click.echo(f"Backup {timestamp} is valid") else: click.echo(f"Backup {timestamp} has issues:") for failed in result["details"]["failed_files"]: click.echo(f" - {failed}") return if not timestamp: backups = backup_manager.list_backups() if not backups: raise click.ClickException("No backups found. Specify a timestamp or create a backup first.") timestamp = backups[0]["timestamp"] click.echo(f"Using most recent backup: {timestamp}") if restore_all or file: files_to_restore = list(file) if file else None result = backup_manager.restore_backup( timestamp, files=files_to_restore, dry_run=dry_run, ) click.echo(f"Restored {len(result['restored'])} files from {timestamp}") if result['failed']: click.echo(f"Failed to restore {len(result['failed'])} files:") for f in result['failed']: click.echo(f" - {f}") else: click.echo("Specify --restore-all or --file to restore files") except Exception as e: raise click.ClickException(str(e)) @cli.command() @click.pass_context def status(ctx): """Show current environment status.""" config_path = ctx.obj.get("config_path") verbose = ctx.obj.get("verbose", False) logger = get_logger(__name__) try: platform_info = PlatformDetector.detect() click.echo(f"Platform: {platform_info.platform.value}") click.echo(f"System: {platform_info.system} {platform_info.release}") click.echo(f"WSL: {'Yes' if platform_info.is_wsl else 'No'}") pkg_manager = PackageManagerFactory.get_platform_manager() if pkg_manager: click.echo(f"Package Manager: {pkg_manager.name}") else: click.echo("Package Manager: None detected") config_path = get_default_config_path() if config_path: click.echo(f"Config: {config_path}") config = load_config() click.echo(f"Config Name: {config.name or 'Unnamed'}") click.echo(f"Dotfiles: {len(config.dotfiles)}") click.echo(f"Editors: {len(config.editors)}") click.echo(f"Package Groups: {len(config.packages)}") else: click.echo("Config: None found") except Exception as e: raise click.ClickException(str(e)) @cli.command() @click.pass_context def platforms(ctx): """Show supported platforms and current detection.""" verbose = ctx.obj.get("verbose", False) try: platform_info = PlatformDetector.detect() click.echo("Current Platform:") click.echo(f" System: {platform_info.system}") click.echo(f" Release: {platform_info.release}") click.echo(f" Version: {platform_info.version}") click.echo(f" Machine: {platform_info.machine}") click.echo(f" Detected Platform: {platform_info.platform.value}") click.echo(f" Is WSL: {platform_info.is_wsl}") if platform_info.wsl_version: click.echo(f" WSL Version: {platform_info.wsl_version}") click.echo("\nSupported Platforms:") click.echo(" - Linux") click.echo(" - macOS") click.echo(" - WSL (Windows Subsystem for Linux)") except Exception as e: raise click.ClickException(str(e)) @cli.command() def version(): """Show version information.""" click.echo(f"Dev Environment Sync v{__version__}") def main(): """Main entry point for the CLI.""" cli(obj={}) if __name__ == "__main__": main()