"""CLI parsing + entry point dispatch. Flag style mirrors packwiz-installer-bootstrap so operators wiring Prism's PreLaunch/PostExit hooks don't relearn the surface. Supports both ``--url value`` and ``--url=value`` forms. """ from __future__ import annotations import argparse import sys from dataclasses import dataclass from pathlib import Path @dataclass(frozen=True) class Args: """Parsed CLI args shared by both pull + push subcommands.""" url: str pack_folder: Path token_file: Path restic_binary: Path | None # None → auto-discover allow_download: bool headless: bool def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( prog="cloud-sync", description="Per-user Minecraft state sync via restic.", ) p.add_argument("--version", action="version", version="cloud-sync 0.1.0") sub = p.add_subparsers(dest="cmd", required=True) for name in ("pull", "push"): sp = sub.add_parser(name, help=f"{name} player state") sp.add_argument( "--url", required=True, help="cloud-svc data plane URL (e.g. https://cloud.tm.center)", ) sp.add_argument( "--pack-folder", default=".", type=Path, help="Minecraft instance directory (default: cwd)", ) sp.add_argument( "--token-file", default=None, type=Path, help="Token file path (default: /.cloud-sync/token)", ) sp.add_argument( "--restic-binary", default=None, type=Path, help="Path to a restic binary; overrides auto-discovery", ) sp.add_argument( "--no-download", action="store_true", help="Don't auto-fetch restic from upstream; fail if not found locally", ) sp.add_argument( "-g", "--no-gui", action="store_true", help="Headless mode (no Swing/Qt windows, restic stdout only)", ) return p def parse(argv: list[str]) -> tuple[str, Args]: """Parse argv → (subcommand, Args). Raises SystemExit on error/help.""" ns = build_parser().parse_args(argv) pack = Path(ns.pack_folder).absolute().resolve() token = ( Path(ns.token_file).absolute() if ns.token_file is not None else pack / ".cloud-sync" / "token" ) return ns.cmd, Args( url=ns.url, pack_folder=pack, token_file=token, restic_binary=Path(ns.restic_binary).absolute() if ns.restic_binary else None, allow_download=not ns.no_download, headless=ns.no_gui, ) def main(argv: list[str] | None = None) -> int: """CLI entrypoint. Returns exit code (0=ok, 1=user cancel, 2=error).""" # Imports kept here so tests of parse() don't drag UI in. from . import sync, ui try: cmd, args = parse(sys.argv[1:] if argv is None else argv) except SystemExit as e: return int(e.code) if isinstance(e.code, int) else 2 action = {"pull": sync.pull, "push": sync.push}[cmd] progress = ui.make_progress(headless=args.headless) title = "Cloud sync — pulling" if cmd == "pull" else "Cloud sync — pushing" try: return progress.run_with(lambda: action(args, progress), title) except KeyboardInterrupt: print("cloud-sync: cancelled", file=sys.stderr) return 1 except Exception as e: # noqa: BLE001 print(f"cloud-sync {cmd}: {e}", file=sys.stderr) return 2