Files
DawnsorrowandCursor 0d6b0b2f80 Initial SyncGames tree: agent, Android, deploy, docs.
Session-gated MinIO save sync with AppImage GUI, CLI edit/session flow, and Gitea release helper.

Co-authored-by: Cursor <[email protected]>
2026-07-14 22:06:36 -05:00

315 lines
9.9 KiB
Python

from __future__ import annotations
import argparse
import sys
from pathlib import Path
from syncgames.config import (
AgentConfig,
GameConfig,
default_config_root,
list_games,
load_agent_config,
load_game,
slugify,
write_game_toml,
)
from syncgames.errors import SyncGamesError
from syncgames.lease import break_lease
from syncgames.session import (
ensure_game_remote,
end_session,
restore_session,
start_session,
status_text,
)
from syncgames.store import build_store
from syncgames.watchers import watch_loop
def _cfg(args: argparse.Namespace) -> AgentConfig:
root = Path(args.config_root).expanduser() if getattr(args, "config_root", None) else None
return load_agent_config(root)
def cmd_add(args: argparse.Namespace) -> int:
cfg = _cfg(args)
store = build_store(cfg)
gid = args.id or slugify(args.name)
paths = args.paths or []
if not paths:
raise SyncGamesError("At least one --paths is required", key="config_error")
procs = args.process_name or []
game = GameConfig(
id=gid,
name=args.name,
platform=args.platform,
paths=paths,
versions_to_keep=args.versions,
steam_appid=args.steam_appid,
process_names=procs,
)
path = write_game_toml(cfg, game)
ensure_game_remote(store, game)
print(f"Added game {gid}{path}")
return 0
def cmd_remove(args: argparse.Namespace) -> int:
cfg = _cfg(args)
store = build_store(cfg)
game = load_game(cfg, args.game_id)
if args.purge_remote:
confirm = input(f"Type {game.id} to permanently purge remote: ")
if confirm != game.id:
print("Aborted")
return 1
# still retire first for safety unless --purge-remote means delete
dest = store.retire_game(game.id)
print(f"Retired then leaving retired/ in place at {dest} (manual purge from console)")
else:
dest = store.retire_game(game.id)
print(f"Remote moved to {dest}")
src = cfg.games_dir / f"{game.id}.toml"
if src.exists():
src.rename(src.with_suffix(".toml.removed"))
print(f"Local config renamed for {game.id}")
return 0
def cmd_edit(args: argparse.Namespace) -> int:
"""Update an existing game.toml; game id stays stable for MinIO prefixes."""
cfg = _cfg(args)
game = load_game(cfg, args.game_id)
if args.name:
game.name = args.name
if args.platform:
game.platform = args.platform
if args.paths:
game.paths = args.paths
if args.add_path:
for p in args.add_path:
if p not in game.paths:
game.paths.append(p)
if args.versions is not None:
game.versions_to_keep = args.versions
if args.steam_appid is not None:
game.steam_appid = args.steam_appid
if args.process_name is not None:
game.process_names = args.process_name
path = write_game_toml(cfg, game)
print(f"Updated {game.id}{path}")
for p in game.paths:
print(f" path: {p}")
return 0
def cmd_start(args: argparse.Namespace) -> int:
cfg = _cfg(args)
store = build_store(cfg)
game = load_game(cfg, args.game_id)
meta = start_session(cfg, store, game)
print(f"Started session {game.id}; lease={meta.lease}; live_hash={meta.live_hash}")
return 0
def cmd_end(args: argparse.Namespace) -> int:
cfg = _cfg(args)
store = build_store(cfg)
game = load_game(cfg, args.game_id)
meta = end_session(
cfg, store, game, force=args.force, force_hash=args.force_restore
)
print(f"Ended session {game.id}; new live_hash={meta.live_hash}")
return 0
def cmd_status(args: argparse.Namespace) -> int:
cfg = _cfg(args)
store = build_store(cfg)
game = load_game(cfg, args.game_id) if args.game_id else None
print(status_text(cfg, store, game))
return 0
def cmd_history(args: argparse.Namespace) -> int:
cfg = _cfg(args)
store = build_store(cfg)
game = load_game(cfg, args.game_id)
for item in store.list_history(game.id):
print(item)
return 0
def cmd_restore(args: argparse.Namespace) -> int:
cfg = _cfg(args)
store = build_store(cfg)
game = load_game(cfg, args.game_id)
confirm = input(
f"Restore {game.id} live from history {args.from_} ? Type YES: "
)
if confirm != "YES":
print("Aborted")
return 1
meta = restore_session(cfg, store, game, args.from_)
print(f"Restored {args.from_}; live_hash={meta.live_hash}. Lease held — end when done.")
return 0
def cmd_doctor(args: argparse.Namespace) -> int:
cfg = _cfg(args)
store = build_store(cfg)
print(f"device_id={cfg.device_id}")
print(f"store={cfg.store}")
print(f"config_root={cfg.config_root}")
print(f"state_root={cfg.state_root}")
if args.break_lease:
game = load_game(cfg, args.break_lease)
break_lease(store, game.id)
print(f"Broke lease for {game.id}")
try:
digest = store.probe()
print(f"probe_ok hash={digest}")
except Exception as e: # noqa: BLE001
print(f"probe_FAILED: {e}", file=sys.stderr)
return 1
for g in list_games(cfg):
meta = store.get_meta(g.id)
print(f"game {g.id}: meta={'yes' if meta else 'missing'}")
return 0
def cmd_export_ssot(args: argparse.Namespace) -> int:
cfg = _cfg(args)
store = build_store(cfg)
out = Path(args.out).expanduser().resolve()
store.export_ssot(out)
print(f"Exported SSOT to {out}")
return 0
def cmd_watch(args: argparse.Namespace) -> int:
cfg = _cfg(args)
store = build_store(cfg)
game = load_game(cfg, args.game_id)
def on_start() -> None:
print(f"[watch] start {game.id}")
try:
start_session(cfg, store, game)
except SyncGamesError as e:
print(f"[watch] start skipped: {e}", file=sys.stderr)
def on_end() -> None:
print(f"[watch] end {game.id}")
try:
end_session(cfg, store, game)
except SyncGamesError as e:
print(f"[watch] end failed: {e}", file=sys.stderr)
print(f"Watching {game.id} (Ctrl+C to stop)")
watch_loop(game, on_start, on_end)
return 0
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="syncgames", description="SyncGames session agent")
p.add_argument(
"--config-root",
default=None,
help=f"Config directory (default {default_config_root()})",
)
sub = p.add_subparsers(dest="cmd", required=True)
add = sub.add_parser("add", help="Declare a new game")
add.add_argument("--name", required=True)
add.add_argument("--id", default=None, help="Slug override")
add.add_argument("--platform", default="steam", choices=["steam", "eden", "yuzu", "other"])
add.add_argument("--paths", action="append", default=[])
add.add_argument("--versions", type=int, default=5)
add.add_argument("--steam-appid", type=int, default=None)
add.add_argument("--process-name", action="append", default=[])
add.set_defaults(func=cmd_add)
rem = sub.add_parser("remove", help="Retire a game")
rem.add_argument("game_id")
rem.add_argument("--purge-remote", action="store_true")
rem.set_defaults(func=cmd_remove)
ed = sub.add_parser("edit", help="Update an existing game config (id stays stable)")
ed.add_argument("game_id")
ed.add_argument("--name", default=None)
ed.add_argument("--platform", default=None, choices=["steam", "eden", "yuzu", "other"])
ed.add_argument("--paths", action="append", default=None, help="Replace all paths")
ed.add_argument("--add-path", action="append", default=None, help="Append a path")
ed.add_argument("--versions", type=int, default=None)
ed.add_argument("--steam-appid", type=int, default=None)
ed.add_argument("--process-name", action="append", default=None)
ed.set_defaults(func=cmd_edit)
st = sub.add_parser("start", help="Pull SSOT and acquire lease")
st.add_argument("game_id")
st.set_defaults(func=cmd_start)
en = sub.add_parser("end", help="Push WIP and release lease")
en.add_argument("game_id")
en.add_argument("--force", action="store_true", help="Allow end while process still seen")
en.add_argument(
"--force-restore",
action="store_true",
help="Bypass hash gate (dangerous)",
)
en.set_defaults(func=cmd_end)
su = sub.add_parser("status", help="Show leases and sessions")
su.add_argument("game_id", nargs="?")
su.set_defaults(func=cmd_status)
hi = sub.add_parser("history", help="List remote history prefixes")
hi.add_argument("game_id")
hi.set_defaults(func=cmd_history)
rs = sub.add_parser("restore", help="Promote history → live")
rs.add_argument("game_id")
rs.add_argument("--from", dest="from_", required=True, help="device/ts")
rs.set_defaults(func=cmd_restore)
doc = sub.add_parser("doctor", help="Connectivity + config probe")
doc.add_argument("--break-lease", metavar="GAME_ID", default=None)
doc.set_defaults(func=cmd_doctor)
ex = sub.add_parser("export-ssot", help="Export bucket/tree for Path 1 fallback")
ex.add_argument("--out", required=True)
ex.set_defaults(func=cmd_export_ssot)
wa = sub.add_parser("watch", help="Auto start/end around process lifetime")
wa.add_argument("game_id")
wa.set_defaults(func=cmd_watch)
gui = sub.add_parser("gui", help="Open thin setup / management GUI")
gui.set_defaults(func=cmd_gui)
return p
def cmd_gui(_args: argparse.Namespace) -> int:
from syncgames.gui.app import main as gui_main
gui_main()
return 0
def main(argv: list[str] | None = None) -> None:
parser = build_parser()
args = parser.parse_args(argv)
try:
code = args.func(args)
except SyncGamesError as e:
print(f"error[{e.key}]: {e}", file=sys.stderr)
raise SystemExit(2) from e
raise SystemExit(code)
if __name__ == "__main__":
main()