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]>
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user