Add core modules: config and platform detection
Some checks failed
CI / test (push) Has been cancelled

This commit is contained in:
2026-01-30 04:07:48 +00:00
parent e411817754
commit ae3b1d4a5d

418
dev_env_sync/cli/main.py Normal file
View File

@@ -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()