diff --git a/dev_env_sync/cli/main.py b/dev_env_sync/cli/main.py new file mode 100644 index 0000000..cee8590 --- /dev/null +++ b/dev_env_sync/cli/main.py @@ -0,0 +1,418 @@ +"""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()