Session-gated MinIO save sync with AppImage GUI, CLI edit/session flow, and Gitea release helper. Co-authored-by: Cursor <[email protected]>
315 lines
9.9 KiB
Python
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()
|