#!/usr/bin/env python3 from __future__ import annotations import argparse import filecmp import os import shutil from pathlib import Path LIVE_TEMPLATES_DIR = Path.home() / ".nanobot" / "cards" / "templates" EXAMPLE_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "examples" / "cards" / "templates" def iter_template_dirs(root: Path) -> dict[str, Path]: templates: dict[str, Path] = {} if not root.exists(): return templates for child in sorted(root.iterdir()): if not child.is_dir(): continue if not (child / "manifest.json").exists(): continue templates[child.name] = child return templates def sync_directory(source: Path, dest: Path) -> None: dest.mkdir(parents=True, exist_ok=True) source_entries = {path.name: path for path in source.iterdir()} dest_entries = {path.name: path for path in dest.iterdir()} if dest.exists() else {} for name, source_path in source_entries.items(): dest_path = dest / name if source_path.is_dir(): sync_directory(source_path, dest_path) continue dest_path.parent.mkdir(parents=True, exist_ok=True) if not dest_path.exists() or not filecmp.cmp(source_path, dest_path, shallow=False): shutil.copy2(source_path, dest_path) for name, dest_path in dest_entries.items(): if name in source_entries: continue if dest_path.is_dir(): shutil.rmtree(dest_path) else: dest_path.unlink(missing_ok=True) def compare_directory(source: Path, dest: Path, drifts: list[str], *, prefix: str) -> None: source_entries = {path.name: path for path in source.iterdir()} if source.exists() else {} dest_entries = {path.name: path for path in dest.iterdir()} if dest.exists() else {} for name, source_path in source_entries.items(): rel = f"{prefix}/{name}" if prefix else name dest_path = dest / name if name not in dest_entries: drifts.append(f"missing mirror: {rel}") continue if source_path.is_dir(): if not dest_path.is_dir(): drifts.append(f"type mismatch: {rel}") continue compare_directory(source_path, dest_path, drifts, prefix=rel) continue if dest_path.is_dir(): drifts.append(f"type mismatch: {rel}") continue if not filecmp.cmp(source_path, dest_path, shallow=False): drifts.append(f"content drift: {rel}") for name in dest_entries: if name not in source_entries: rel = f"{prefix}/{name}" if prefix else name drifts.append(f"stale mirror: {rel}") def run_check(source_root: Path, dest_root: Path) -> int: drifts: list[str] = [] source_templates = iter_template_dirs(source_root) dest_templates = iter_template_dirs(dest_root) for name, source_dir in source_templates.items(): dest_dir = dest_root / name if name not in dest_templates: drifts.append(f"missing mirror: {name}") continue compare_directory(source_dir, dest_dir, drifts, prefix=name) for name in dest_templates: if name not in source_templates: drifts.append(f"stale mirror: {name}") if drifts: for drift in drifts: print(drift) return 1 print("template sync ok") return 0 def run_sync(source_root: Path, dest_root: Path) -> int: source_templates = iter_template_dirs(source_root) dest_root.mkdir(parents=True, exist_ok=True) for name, source_dir in source_templates.items(): sync_directory(source_dir, dest_root / name) for child in list(dest_root.iterdir()): if child.name in source_templates: continue if child.is_dir(): shutil.rmtree(child) else: child.unlink(missing_ok=True) print(f"synced {len(source_templates)} template directories") return 0 def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--check", action="store_true") parser.add_argument("--source", type=Path, default=LIVE_TEMPLATES_DIR) parser.add_argument("--dest", type=Path, default=EXAMPLE_TEMPLATES_DIR) args = parser.parse_args() source_root = args.source.expanduser() dest_root = args.dest.expanduser() if args.check: return run_check(source_root, dest_root) return run_sync(source_root, dest_root) if __name__ == "__main__": raise SystemExit(main())