#!/usr/bin/env python3 """Sync every configured Zed Hours project to the server in one run. Reads a project list from a simple config file (one project per line: `PATH [SLUG]`), validates the shared ZED_HOURS_* environment once, and invokes sync.py for each project that has a `.zed-hours/` directory. Designed for a systemd user timer: per-project failures are reported but do not stop the remaining projects; the run exits nonzero if any sync failed. """ import argparse import os import subprocess import sys from pathlib import Path DEFAULT_CONFIG = Path.home() / ".config" / "zed-hours-sync" / "projects.txt" REQUIRED_SETTINGS = ("ZED_HOURS_SERVER_URL", "ZED_HOURS_SERVER_TOKEN", "ZED_HOURS_DEVICE_ID") def parse_config(config_path: Path) -> list[tuple[Path, str | None]]: """Return (project_root, slug_or_None) pairs from the config file. Paths with spaces are not supported; keep the `PATH [SLUG]` format simple. """ projects: list[tuple[Path, str | None]] = [] for lineno, raw in enumerate(config_path.read_text(encoding="utf-8").splitlines(), 1): line = raw.strip() if not line or line.startswith("#"): continue parts = line.split() if len(parts) == 1: root, slug = parts[0], None elif len(parts) == 2: root, slug = parts else: raise ValueError(f"{config_path}:{lineno}: expected 'PATH [SLUG]', got: {line}") root_path = Path(os.path.expanduser(root)) projects.append((root_path, slug)) return projects def main() -> int: parser = argparse.ArgumentParser(description="Sync all configured Zed Hours projects.") parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG, help="Project list file (default: %(default)s)") args = parser.parse_args() missing = [name for name in REQUIRED_SETTINGS if not os.environ.get(name, "").strip()] if missing: print(f"Missing environment variables: {', '.join(missing)}", file=sys.stderr) return 2 if not args.config.exists(): print(f"Config file not found: {args.config}", file=sys.stderr) return 2 try: projects = parse_config(args.config) except (OSError, ValueError) as exc: print(f"Invalid config: {exc}", file=sys.stderr) return 2 if not projects: print(f"No projects configured in {args.config}.") return 0 sync_script = Path(__file__).resolve().parent / "sync.py" failed: list[str] = [] for root, slug in projects: if not (root / ".zed-hours").is_dir(): print(f"Skipping {root}: no .zed-hours directory") continue command = [sys.executable, str(sync_script), "--project-root", str(root)] if slug: command += ["--project-slug", slug] print(f"--- Syncing {root}" + (f" as '{slug}'" if slug else "") + " ---", flush=True) result = subprocess.run(command, check=False) if result.returncode != 0: failed.append(str(root)) if failed: print(f"Sync finished with failures: {', '.join(failed)}", file=sys.stderr) return 1 print("Sync finished OK.") return 0 if __name__ == "__main__": raise SystemExit(main())