Add core modules: config and platform detection
Some checks failed
CI / test (push) Has been cancelled
Some checks failed
CI / test (push) Has been cancelled
This commit is contained in:
418
dev_env_sync/cli/main.py
Normal file
418
dev_env_sync/cli/main.py
Normal 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()
|
||||||
Reference in New Issue
Block a user