From 20cfdf62f263122bc6b6f2d339bf9546271f1c6a Mon Sep 17 00:00:00 2001 From: claude-timemachine Date: Fri, 5 Jun 2026 09:53:20 +0200 Subject: [PATCH] feat: opt-in by sync.json + per-instance ULID + restic subpath Reshapes the launcher integration around two ideas: 1. ONE global Prism PreLaunch/PostExit hook is enough for all instances. Wire it once at Settings > Default > Custom commands: python /opt/cloud-sync.pyz pull --pack-folder=$INST_MC_DIR python /opt/cloud-sync.pyz push --pack-folder=$INST_MC_DIR Instances WITHOUT .cloud-sync/sync.json are silent no-ops (rc=0, no UI, no banner). The opt-in probe runs BEFORE the UI factory so Prism's launch log stays clean for non-sync instances. 2. Per-instance opt-in via 'setup' / 'init' subcommands that mint a fresh ULID-style instance_id + write sync.json (mode 644) and token (mode 600). 'disable' removes sync.json; cloud data untouched. Restic URL gains an // subpath under the user's namespace, so two Prism instances of the same Discord user no longer share a snapshot timeline. --private-repos still gates on the first path segment (the username); deeper segments are user-controlled, so this works without server-side coordination. First-push-on-a-new- instance probes via 'restic cat config' and 'init's the per-instance repo if absent. UI label resolution is runtime-only (NEVER stored in sync.json) so the user renaming the Prism instance just propagates through on next launch: --instance-label > $INST_NAME > $INST_ID > instance_id[:8] Schema bumps: state.json schema: 1 -> 2, adds instance_id field. Schema-1 files are treated as missing (existing test1 user re-pulls fresh). sync.json schema: 1 (new file). CLI rework: pull / push no --url; load everything from sync.json setup interactive: Qt login dialog for token; URL prompt if --url omitted; falls back to stdin when headless init non-interactive setup; for scripted callers disable rm sync.json Args dataclass: drops 'url', adds 'instance_label'. cli.parse() now returns (cmd, Namespace); a separate args_from(ns) builds the Args so each subcommand can pluck the bits it needs from the Namespace without forcing a 'one Args fits all subcommands' shape. 73 tests green; pyz 75 KB. Smoke-verified locally: - pull/push on a folder without sync.json: silent rc=0, no banner - init writes sync.json (644) + token (600) with correct contents - disable removes sync.json, keeps token - mint produces unique 26-char base32 instance_ids - label resolution chain (flag > INST_NAME > INST_ID > prefix) --- README.md | 59 +++++++++---- cloud_sync/cli.py | 183 ++++++++++++++++++++++++++++++--------- cloud_sync/config.py | 169 ++++++++++++++++++++++++++++++++++++ cloud_sync/setup_flow.py | 123 ++++++++++++++++++++++++++ cloud_sync/state.py | 5 +- cloud_sync/sync.py | 92 +++++++++++++++++--- tests/test_cli.py | 90 +++++++++++++------ tests/test_config.py | 118 +++++++++++++++++++++++++ tests/test_repo_url.py | 35 +++++--- tests/test_state.py | 33 ++++++- 10 files changed, 792 insertions(+), 115 deletions(-) create mode 100644 cloud_sync/config.py create mode 100644 cloud_sync/setup_flow.py create mode 100644 tests/test_config.py diff --git a/README.md b/README.md index 94535b1..0597f98 100644 --- a/README.md +++ b/README.md @@ -24,32 +24,57 @@ make install # pip install -e . ## Usage in Prism (or MMC / ATLauncher) -Instance Settings → Custom commands: +**One-time global wiring.** Settings → Default → Custom commands (NOT per-instance): ``` -Pre-launch: - python /path/to/cloud-sync.pyz pull --url=https://cloud.tm.center --pack-folder=$INST_MC_DIR - -Post-exit: - python /path/to/cloud-sync.pyz push --url=https://cloud.tm.center --pack-folder=$INST_MC_DIR +Pre-launch: python /path/to/cloud-sync.pyz pull --pack-folder=$INST_MC_DIR +Post-exit: python /path/to/cloud-sync.pyz push --pack-folder=$INST_MC_DIR ``` -Player needs Python 3.10+ on PATH AND a Qt binding (`pip install PySide6`). The first pull on a fresh instance opens a "CONNECT TO THE NETWORK" dialog; the player pastes a token they got via `/cloud register` in Discord. The token lands at `/.cloud-sync/token`. +That single line works for every existing and future instance. Instances without a `.cloud-sync/sync.json` are no-ops (silent rc=0, MC launches normally) — sync only kicks in for instances you've explicitly opted in. -If `PySide6` / `PyQt6` is missing the pyz falls back to headless mode (status to stdout). The conflict + login dialogs do not have a headless mode — without Qt the conflict path aborts the launch defensively, and the login path tells the user to paste the token manually. +**Per-instance opt-in:** + +``` +# interactive — opens Qt login dialog for the token +python /path/to/cloud-sync.pyz setup --pack-folder=/path/to/instance/minecraft + +# scripted equivalent +python /path/to/cloud-sync.pyz init \ + --pack-folder=/path/to/instance/minecraft \ + --url=https://cloud.tm.center \ + --token=DISCORD_ID:PASSWORD +``` + +That mints a fresh `instance_id` (ULID), writes `.cloud-sync/sync.json` (the opt-in marker) and `.cloud-sync/token` (mode 600). Subsequent launches sync automatically via the global hook. + +**Opting out:** + +``` +python /path/to/cloud-sync.pyz disable --pack-folder=/path/to/instance/minecraft +``` + +Removes `sync.json` (cloud data untouched). Instance returns to no-op behavior. + +Player needs Python 3.10+ AND a Qt binding (`pip install PySide6`). Without Qt the pyz falls back to headless mode for status; the conflict + login dialogs are Qt-only — without them, conflict aborts the launch defensively and `setup` prompts for the token via stdin. ## CLI ``` -python cloud-sync.pyz {pull,push} \ - --url URL Timemachine Network endpoint (required) - --pack-folder PATH Minecraft instance directory (default: cwd) - --token-file PATH override default /.cloud-sync/token - --restic-binary PATH skip auto-discovery - --no-download fail if no usable restic; don't fetch from upstream - -g, --no-gui headless mode (no Qt windows) +instance-sync pull / push [--pack-folder=PATH] + [--token-file=PATH] + [--restic-binary=PATH] + [--no-download] + [-g | --no-gui] + [--instance-label="Friendly Name"] + +instance-sync setup [--url=URL] [--pack-folder=PATH] # interactive +instance-sync init --url=URL [--token=ID:PASS] ... # scripted +instance-sync disable [--pack-folder=PATH] ``` +`pull` and `push` don't take `--url` — it's loaded from `sync.json`. `--instance-label` overrides the UI display name (defaults to `$INST_NAME` from Prism, then `$INST_ID`, then the first 8 chars of `instance_id`). + ## Programmatic API (for frazclient) ```python @@ -57,15 +82,17 @@ from pathlib import Path import cloud_sync cloud_sync.pull(cloud_sync.Args( - url="https://cloud.tm.center", pack_folder=Path("/srv/mc/instance"), token_file=Path("/srv/mc/instance/.cloud-sync/token"), restic_binary=None, # auto-discover allow_download=True, headless=True, + instance_label=None, # default to env / id-prefix )) ``` +Url is taken from `/.cloud-sync/sync.json`; the caller is expected to have run `setup` or `init` beforehand (or to write sync.json themselves via `cloud_sync.config.write(...)`). + frazclient's `client.py` consumes this directly via `import cloud_sync` instead of subprocessing the pyz. ## On-disk layout diff --git a/cloud_sync/cli.py b/cloud_sync/cli.py index 09d7714..b3222ad 100644 --- a/cloud_sync/cli.py +++ b/cloud_sync/cli.py @@ -1,8 +1,22 @@ """CLI parsing + entry point dispatch. -Flag style mirrors packwiz-installer-bootstrap so operators wiring Prism's -PreLaunch/PostExit hooks don't relearn the surface. Supports both -``--url value`` and ``--url=value`` forms. +Subcommands: + + pull / push Sync. No-op if ``/.cloud-sync/sync.json`` is missing. + All settings (url, instance_id) read from sync.json — flags + are for runtime tweaks only. + setup Interactive opt-in: prompt URL + token, mint instance_id, + write sync.json + token. Player runs this once per instance. + init Non-interactive setup (scripted / CI). + disable Remove sync.json. Re-noops the instance. Cloud data untouched. + +Global Prism wiring (paste-once into Settings → Custom commands): + + PreLaunchCommand=python /opt/cloud-sync.pyz pull --pack-folder=$INST_MC_DIR + PostExitCommand=python /opt/cloud-sync.pyz push --pack-folder=$INST_MC_DIR + +Instance opt-in is then per-instance via ``setup``; non-enabled instances +launch normally without any sync work happening. """ from __future__ import annotations @@ -15,14 +29,16 @@ from pathlib import Path @dataclass(frozen=True) class Args: - """Parsed CLI args shared by both pull + push subcommands.""" + """Parsed CLI args. ``url`` comes from sync.json at runtime for pull/push; + the CLI ``--url`` flag is only consulted by ``setup`` / ``init`` (recorded + once into sync.json).""" - url: str pack_folder: Path token_file: Path restic_binary: Path | None # None → auto-discover allow_download: bool headless: bool + instance_label: str | None # override; usually unset (env fallback) def build_parser() -> argparse.ArgumentParser: @@ -31,66 +47,128 @@ def build_parser() -> argparse.ArgumentParser: description="Per-user Minecraft instance sync via the Timemachine Network.", ) p.add_argument("--version", action="version", version="instance-sync 0.1.0") - sub = p.add_subparsers(dest="cmd", required=True) + + # --- pull / push: shared flags, no required --url --- for name in ("pull", "push"): sp = sub.add_parser(name, help=f"{name} instance state") - sp.add_argument( - "--url", required=True, - help="Timemachine Network endpoint (e.g. https://cloud.tm.center)", - ) - sp.add_argument( - "--pack-folder", default=".", type=Path, - help="Minecraft instance directory (default: cwd)", - ) - sp.add_argument( - "--token-file", default=None, type=Path, - help="Token file path (default: /.cloud-sync/token)", - ) - sp.add_argument( - "--restic-binary", default=None, type=Path, - help="Path to a restic binary; overrides auto-discovery", - ) - sp.add_argument( - "--no-download", action="store_true", - help="Don't auto-fetch restic from upstream; fail if not found locally", - ) - sp.add_argument( - "-g", "--no-gui", action="store_true", - help="Headless mode (no Qt windows; status to stdout only)", - ) + _add_runtime_args(sp) + + # --- setup: interactive opt-in --- + sp = sub.add_parser( + "setup", + help="enable sync on this instance (interactive: prompt URL + token)", + ) + _add_runtime_args(sp) + sp.add_argument( + "--url", default=None, + help="Timemachine Network endpoint (skips the prompt if given)", + ) + + # --- init: non-interactive setup --- + sp = sub.add_parser( + "init", + help="non-interactive equivalent of setup (for scripted / CI use)", + ) + _add_runtime_args(sp) + sp.add_argument( + "--url", required=True, + help="Timemachine Network endpoint (required)", + ) + sp.add_argument( + "--token", default=None, + help="discord_id:password token (read from stdin if omitted)", + ) + + # --- disable: rm sync.json --- + sp = sub.add_parser( + "disable", + help="remove sync.json — re-noops the instance. Cloud data untouched.", + ) + sp.add_argument( + "--pack-folder", default=".", type=Path, + help="Minecraft instance directory (default: cwd)", + ) + return p -def parse(argv: list[str]) -> tuple[str, Args]: - """Parse argv → (subcommand, Args). Raises SystemExit on error/help.""" +def _add_runtime_args(sp: argparse.ArgumentParser) -> None: + sp.add_argument( + "--pack-folder", default=".", type=Path, + help="Minecraft instance directory (default: cwd)", + ) + sp.add_argument( + "--token-file", default=None, type=Path, + help="Token file path (default: /.cloud-sync/token)", + ) + sp.add_argument( + "--restic-binary", default=None, type=Path, + help="Path to a restic binary; overrides auto-discovery", + ) + sp.add_argument( + "--no-download", action="store_true", + help="Don't auto-fetch restic from upstream; fail if not found locally", + ) + sp.add_argument( + "-g", "--no-gui", action="store_true", + help="Headless mode (no Qt windows; status to stdout only)", + ) + sp.add_argument( + "--instance-label", default=None, + help="Override the instance display name. Default: $INST_NAME from " + "Prism's env, then $INST_ID, then first 8 chars of instance_id.", + ) + + +def parse(argv: list[str]) -> tuple[str, argparse.Namespace]: ns = build_parser().parse_args(argv) + return ns.cmd, ns + + +def args_from(ns: argparse.Namespace) -> Args: + """Convert argparse Namespace → Args dataclass. Centralizes path + normalization so subcommands don't each repeat the recipe.""" pack = Path(ns.pack_folder).absolute().resolve() token = ( Path(ns.token_file).absolute() - if ns.token_file is not None + if getattr(ns, "token_file", None) is not None else pack / ".cloud-sync" / "token" ) - return ns.cmd, Args( - url=ns.url, + return Args( pack_folder=pack, token_file=token, - restic_binary=Path(ns.restic_binary).absolute() if ns.restic_binary else None, - allow_download=not ns.no_download, - headless=ns.no_gui, + restic_binary=Path(ns.restic_binary).absolute() + if getattr(ns, "restic_binary", None) + else None, + allow_download=not getattr(ns, "no_download", False), + headless=getattr(ns, "no_gui", False), + instance_label=getattr(ns, "instance_label", None), ) def main(argv: list[str] | None = None) -> int: """CLI entrypoint. Returns exit code (0=ok, 1=user cancel, 2=error).""" - # Imports kept here so tests of parse() don't drag UI in. + # Lazy imports keep tests of parse() isolated from UI deps. from . import sync, ui try: - cmd, args = parse(sys.argv[1:] if argv is None else argv) + cmd, ns = parse(sys.argv[1:] if argv is None else argv) except SystemExit as e: return int(e.code) if isinstance(e.code, int) else 2 + if cmd == "disable": + return _run_disable(ns) + if cmd in ("setup", "init"): + return _run_setup(ns, cmd) + + # pull / push — opt-in gated on sync.json. We re-check here BEFORE + # spinning up the UI / printing any banner so non-sync-enabled + # instances launch in true silence (clean Prism log). + args = args_from(ns) + from . import config as cfgmod + if not cfgmod.exists(args.pack_folder): + return 0 action = {"pull": sync.pull, "push": sync.push}[cmd] progress = ui.make_progress(headless=args.headless) title = "Instance sync — pulling" if cmd == "pull" else "Instance sync — pushing" @@ -103,3 +181,28 @@ def main(argv: list[str] | None = None) -> int: except Exception as e: # noqa: BLE001 print(f"instance-sync {cmd}: {e}", file=sys.stderr) return 2 + + +def _run_disable(ns: argparse.Namespace) -> int: + from . import config as cfgmod + pack = Path(ns.pack_folder).absolute().resolve() + if cfgmod.delete(pack): + print(f"instance-sync: removed {cfgmod.config_path(pack)}") + else: + print(f"instance-sync: no sync.json at {cfgmod.config_path(pack)} (already disabled)") + return 0 + + +def _run_setup(ns: argparse.Namespace, cmd: str) -> int: + """Wire `setup` (interactive) and `init` (flags-only) through the same + flow module so both share the dialog/CLI logic.""" + from . import setup_flow + args = args_from(ns) + url = getattr(ns, "url", None) + token_override = getattr(ns, "token", None) + return setup_flow.run( + args=args, + url=url, + token=token_override, + interactive=(cmd == "setup"), + ) diff --git a/cloud_sync/config.py b/cloud_sync/config.py new file mode 100644 index 0000000..d4e1b07 --- /dev/null +++ b/cloud_sync/config.py @@ -0,0 +1,169 @@ +"""Per-instance sync.json — the opt-in marker + minimal config. + +If ``/.cloud-sync/sync.json`` doesn't exist, the pyz is a +no-op (PreLaunch hook returns 0, MC launches normally). Its presence +opts the instance into sync; its contents tell the script where to sync +to (`url`) and which restic sub-path to use (`instance_id`). + +Intentionally minimal: NO label (UI display name comes from +``$INST_NAME`` / ``$INST_ID`` / ``--instance-label`` at runtime so the +Prism rename flow Just Works), NO token (mode-600 ``token`` lives next +to it), NO state (``state.json`` is separate, written by the sync flow). + +instance_id is a 26-char ULID-style string (timestamp + 80 random bits, +base32, no padding) — sortable, URL-safe, no hyphens. Stable forever. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import platform +import secrets +import socket +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path + + +SCHEMA_VERSION = 1 + + +@dataclass(frozen=True) +class SyncConfig: + url: str + instance_id: str + created_at: datetime + host_fingerprint: str + + +def config_path(pack_folder: Path) -> Path: + return pack_folder / ".cloud-sync" / "sync.json" + + +def exists(pack_folder: Path) -> bool: + """Cheap opt-in check used by ``pull`` / ``push`` before doing anything else.""" + return config_path(pack_folder).exists() + + +def read(pack_folder: Path) -> SyncConfig | None: + """Parse sync.json or return None if missing / corrupt / wrong schema.""" + p = config_path(pack_folder) + if not p.exists(): + return None + try: + data = json.loads(p.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + if data.get("schema") != SCHEMA_VERSION: + return None + try: + return SyncConfig( + url=data["url"], + instance_id=data["instance_id"], + created_at=_parse_iso(data["created_at"]), + host_fingerprint=data.get("host_fingerprint", ""), + ) + except (KeyError, ValueError): + return None + + +def write(pack_folder: Path, cfg: SyncConfig) -> None: + """Persist sync.json (mode 644 — not a secret).""" + p = config_path(pack_folder) + p.parent.mkdir(parents=True, exist_ok=True) + payload = { + "schema": SCHEMA_VERSION, + "url": cfg.url, + "instance_id": cfg.instance_id, + "created_at": cfg.created_at.astimezone(timezone.utc) + .isoformat() + .replace("+00:00", "Z"), + "host_fingerprint": cfg.host_fingerprint, + } + p.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + p.chmod(0o644) + + +def delete(pack_folder: Path) -> bool: + """Remove sync.json. Used by the ``disable`` subcommand. Returns True if a + file was removed, False if nothing was there.""" + p = config_path(pack_folder) + if not p.exists(): + return False + p.unlink() + return True + + +# --------------------------------------------------------------------------- +# minting +# --------------------------------------------------------------------------- + + +def mint(url: str) -> SyncConfig: + """Build a fresh SyncConfig with a new ULID-style id and host fingerprint.""" + return SyncConfig( + url=url, + instance_id=new_instance_id(), + created_at=datetime.now(timezone.utc), + host_fingerprint=_machine_fingerprint(), + ) + + +def new_instance_id() -> str: + """26-char base32 string: 6 bytes ms timestamp + 10 bytes random. + + Like a ULID, minus the strict Crockford alphabet. Sortable by mint + time, URL-safe, no padding, no hyphens. + """ + ms = int(time.time() * 1000).to_bytes(6, "big") + rand = secrets.token_bytes(10) + return base64.b32encode(ms + rand).decode("ascii").rstrip("=") + + +def _machine_fingerprint() -> str: + """sha256 of (hostname || node || nanosecond timestamp), first 16 hex chars. + + Not a cryptographic identity — just a hint we can compare across pulls + to detect "this instance was first set up on a different machine" + cases later. Don't use it for authz. + """ + src = f"{platform.node()}:{socket.gethostname()}:{time.time_ns()}" + return hashlib.sha256(src.encode()).hexdigest()[:16] + + +def _parse_iso(s: str) -> datetime: + if s.endswith("Z"): + s = s[:-1] + "+00:00" + dt = datetime.fromisoformat(s) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +# --------------------------------------------------------------------------- +# display label resolution (NOT stored — derived per-run) +# --------------------------------------------------------------------------- + + +def resolve_label(flag_label: str | None, instance_id: str) -> str: + """Pick the UI display name for an instance. + + Precedence: + 1. ``--instance-label`` CLI flag (explicit override) + 2. ``$INST_NAME`` (Prism's rename-tracking display name) + 3. ``$INST_ID`` (Prism's stable folder name) + 4. first 8 chars of the instance_id (last-resort) + + Never stored on disk — recomputed every run so Prism renames flow + through naturally. + """ + if flag_label: + return flag_label + env = os.environ.get("INST_NAME") or os.environ.get("INST_ID") + if env: + return env + return instance_id[:8] diff --git a/cloud_sync/setup_flow.py b/cloud_sync/setup_flow.py new file mode 100644 index 0000000..8b22c4c --- /dev/null +++ b/cloud_sync/setup_flow.py @@ -0,0 +1,123 @@ +"""Interactive (``setup``) + non-interactive (``init``) opt-in flow. + +Both end up doing the same thing — write a fresh sync.json + token to +``/.cloud-sync/``. Difference is just how the URL + token get +collected: + + setup Qt login dialog for token; URL prompted at stdin if not + supplied via ``--url`` flag. Falls back to all-stdin when + headless / Qt missing. + init Both URL + token come from flags. No prompting. Suitable + for scripted CI runs or external launcher hooks (frazclient, + discord-bot ``/cloud register`` integration). + +This is the only path that creates sync.json; pull/push never auto-mint. +That makes opt-in explicit — a player intentionally enables sync rather +than being silently signed up the first time they launch an instance. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from . import config as cfgmod +from .cli import Args + + +def run( + args: Args, + url: str | None, + token: str | None, + interactive: bool, +) -> int: + """Drive the opt-in flow. Returns CLI exit code (0/1/2).""" + pack = args.pack_folder + cfg_path = cfgmod.config_path(pack) + + if cfgmod.exists(pack): + existing = cfgmod.read(pack) + if existing is not None: + print( + f"instance-sync: this instance is already sync-enabled\n" + f" sync.json: {cfg_path}\n" + f" url: {existing.url}\n" + f" instance_id: {existing.instance_id}\n" + f" created_at: {existing.created_at}\n" + "Run with `disable` first if you want to re-enroll.", + file=sys.stderr, + ) + return 2 + + if url is None: + if not interactive: + print( + "instance-sync: --url is required for `init`", + file=sys.stderr, + ) + return 2 + url = _prompt_url() + if not url: + return 1 + + if token is None: + token = _prompt_token(headless=args.headless or not interactive) + if token is None: + print("instance-sync: setup cancelled (no token)", file=sys.stderr) + return 1 + + if ":" not in token: + print( + "instance-sync: token must be discord_id:password", + file=sys.stderr, + ) + return 2 + head, _, tail = token.partition(":") + if not head.strip().isdigit() or not tail.strip(): + print( + "instance-sync: token malformed (discord_id must be numeric, " + "password must be non-empty)", + file=sys.stderr, + ) + return 2 + + cfg = cfgmod.mint(url=url.strip()) + cfgmod.write(pack, cfg) + + args.token_file.parent.mkdir(parents=True, exist_ok=True) + args.token_file.write_text(token.strip() + "\n", encoding="utf-8") + args.token_file.chmod(0o600) + + print( + f"instance-sync: enabled\n" + f" sync.json: {cfg_path}\n" + f" token: {args.token_file}\n" + f" url: {cfg.url}\n" + f" instance_id: {cfg.instance_id}" + ) + return 0 + + +def _prompt_url() -> str: + """stdin prompt for the network endpoint.""" + try: + v = input("Timemachine Network URL (e.g. https://cloud.tm.center): ").strip() + return v + except (EOFError, KeyboardInterrupt): + return "" + + +def _prompt_token(headless: bool) -> str | None: + """Returns the discord_id:password token string, or None if user cancelled.""" + if not headless: + try: + from .ui_qt import prompt_login_qt + except ImportError: + pass + else: + return prompt_login_qt() + # Headless fallback. + try: + return input("Paste token (discord_id:password): ").strip() + except (EOFError, KeyboardInterrupt): + return None diff --git a/cloud_sync/state.py b/cloud_sync/state.py index 234e084..600fba9 100644 --- a/cloud_sync/state.py +++ b/cloud_sync/state.py @@ -17,11 +17,12 @@ from datetime import datetime, timezone from pathlib import Path -SCHEMA_VERSION = 1 +SCHEMA_VERSION = 2 @dataclass(frozen=True) class State: + instance_id: str last_pulled_snapshot_id: str last_pulled_at: datetime host_tag: str = "instance-sync" @@ -44,6 +45,7 @@ def read(pack_folder: Path) -> State | None: return None try: return State( + instance_id=data["instance_id"], last_pulled_snapshot_id=data["last_pulled_snapshot_id"], last_pulled_at=_parse_iso(data["last_pulled_at"]), host_tag=data.get("host_tag", "instance-sync"), @@ -58,6 +60,7 @@ def write(pack_folder: Path, state: State) -> None: p.parent.mkdir(parents=True, exist_ok=True) payload = { "schema": SCHEMA_VERSION, + "instance_id": state.instance_id, "last_pulled_snapshot_id": state.last_pulled_snapshot_id, "last_pulled_at": state.last_pulled_at.astimezone(timezone.utc) .isoformat() diff --git a/cloud_sync/sync.py b/cloud_sync/sync.py index 0ea922b..40680f4 100644 --- a/cloud_sync/sync.py +++ b/cloud_sync/sync.py @@ -30,7 +30,7 @@ import urllib.parse from datetime import datetime, timezone from pathlib import Path -from . import restic, scope as scopemod, state as statemod +from . import config as cfgmod, restic, scope as scopemod, state as statemod from .cli import Args from .creds import read_credentials from .ui import HeadlessProgress, Progress @@ -39,11 +39,18 @@ from .ui import HeadlessProgress, Progress def pull(args: Args, progress: Progress | None = None) -> int: ui = progress or HeadlessProgress() - # First-run login. If the user declines, skip cloud sync without - # blocking the launch (return 0 — non-fatal for Prism PreLaunch). + # Opt-in gate: no sync.json → instance isn't sync-enabled, silent no-op. + # This is the path the global Prism PreLaunch hook takes for instances + # the player hasn't opted into sync. + sync_cfg = cfgmod.read(args.pack_folder) + if sync_cfg is None: + return 0 + + # First-run login. If the user declines, skip without blocking the + # launch (return 0 — non-fatal for Prism PreLaunch). if not args.token_file.exists(): if not _prompt_login_and_save(args, ui): - ui.set_status("Cloud sync skipped") + ui.set_status("Sync skipped") print("instance-sync: no token; skipping pull") return 0 @@ -52,8 +59,9 @@ def pull(args: Args, progress: Progress | None = None) -> int: ui.set_status("Resolving restic binary…") binary = restic.resolve_binary(args) - repo = _restic_repo(args.url, discord_id, password) + repo = _restic_repo(sync_cfg.url, discord_id, password, sync_cfg.instance_id) env = _restic_env() + label = cfgmod.resolve_label(args.instance_label, sync_cfg.instance_id) ui.set_status("Checking remote snapshots…") code, out = restic.run( @@ -99,7 +107,7 @@ def pull(args: Args, progress: Progress | None = None) -> int: if not modified: decision = "use_remote" else: - decision = _ask_conflict(modified, remote_time) + decision = _ask_conflict(modified, remote_time, label) if decision is None: # UI unavailable in headless mode → conservative: cancel ui.set_status("Conflict detected; no UI available") @@ -141,6 +149,7 @@ def pull(args: Args, progress: Progress | None = None) -> int: statemod.write( args.pack_folder, statemod.State( + instance_id=sync_cfg.instance_id, last_pulled_snapshot_id=remote_id, last_pulled_at=datetime.now(timezone.utc), ), @@ -153,8 +162,13 @@ def pull(args: Args, progress: Progress | None = None) -> int: def push(args: Args, progress: Progress | None = None) -> int: ui = progress or HeadlessProgress() + # Opt-in gate — see pull(). + sync_cfg = cfgmod.read(args.pack_folder) + if sync_cfg is None: + return 0 + if not args.token_file.exists(): - ui.set_status("No network token; skipping push") + ui.set_status("No token; skipping push") print("instance-sync: no token; skipping push") return 0 @@ -163,9 +177,15 @@ def push(args: Args, progress: Progress | None = None) -> int: ui.set_status("Resolving restic binary…") binary = restic.resolve_binary(args) - repo = _restic_repo(args.url, discord_id, password) + repo = _restic_repo(sync_cfg.url, discord_id, password, sync_cfg.instance_id) env = _restic_env() + # First-push-on-a-new-instance: probe + init if the per-instance repo + # doesn't exist yet. Idempotent on subsequent pushes. + code = _ensure_repo_initialized(binary, repo, env, ui) + if code != 0: + return code + scope = scopemod.load(args.pack_folder) files_from, exclude_from = scopemod.materialize_for_restic(args.pack_folder, scope) @@ -196,6 +216,7 @@ def push(args: Args, progress: Progress | None = None) -> int: statemod.write( args.pack_folder, statemod.State( + instance_id=sync_cfg.instance_id, last_pulled_snapshot_id=new_id, last_pulled_at=datetime.now(timezone.utc), ), @@ -290,6 +311,7 @@ def _matches_any(rel: Path, patterns: list[str]) -> bool: def _ask_conflict( modified: list[tuple[Path, datetime]], remote_time: datetime, + label: str, ) -> str | None: """Show the conflict dialog. Returns choice or None if no UI available.""" try: @@ -300,7 +322,7 @@ def _ask_conflict( return prompt_conflict_qt( local_modified=_format_dt(newest[1]), remote_modified=_format_dt(remote_time), - save_label="Minecraft save", + save_label=label, ) @@ -394,7 +416,16 @@ def _format_dt(dt: datetime) -> str: return f"{weekday}, {month} {local.day}, {local.year} at {hour}:{local.minute:02d} {ampm}" -def _restic_repo(base_url: str, discord_id: str, password: str) -> str: +def _restic_repo( + base_url: str, discord_id: str, password: str, instance_id: str +) -> str: + """Build ``rest:://:@///``. + + Per-instance subpath under the user's namespace. ``--private-repos`` + on restic-rest-server only enforces the FIRST path segment (the + username); deeper segments are user-controlled, so each instance + gets its own isolated restic repo without server-side coordination. + """ raw = base_url.strip() if raw.startswith("rest:"): raw = raw[len("rest:"):] @@ -402,13 +433,50 @@ def _restic_repo(base_url: str, discord_id: str, password: str) -> str: scheme_end = raw.find("://") if scheme_end <= 0: raise ValueError( - f"--url must include scheme (http:// or https://): {base_url!r}" + f"url must include scheme (http:// or https://): {base_url!r}" ) scheme = raw[: scheme_end + 3] host_and_path = raw[scheme_end + 3 :] u = urllib.parse.quote(discord_id, safe="") p = urllib.parse.quote(password, safe="") - return f"rest:{scheme}{u}:{p}@{host_and_path}/{discord_id}/" + iid = urllib.parse.quote(instance_id, safe="") + return f"rest:{scheme}{u}:{p}@{host_and_path}/{discord_id}/{iid}/" + + +def _ensure_repo_initialized( + binary: Path, repo: str, env: dict[str, str], ui: "Progress" +) -> int: + """`restic cat config` to probe; `restic init` if absent. Returns 0/1/2. + + Cheap to call before every push — `cat config` is a single HTTP GET. + Idempotent: if the repo already exists, init is skipped. + """ + code, _ = restic.run( + binary, + ["-r", repo, "--insecure-no-password", "cat", "config"], + env=env, + cancel_check=ui.is_cancelled, + ) + if code == 0: + return 0 + if code == -1: + return 1 + # cat config failed → assume not initialized; init it. + ui.set_status("Initializing remote repo…") + code, _ = restic.run( + binary, + ["-r", repo, "--insecure-no-password", "init"], + env=env, + cancel_check=ui.is_cancelled, + ) + if code == -1: + return 1 + if code != 0: + print( + f"instance-sync: restic init failed (exit {code})", file=sys.stderr + ) + return 2 + return 0 def _restic_env() -> dict[str, str]: diff --git a/tests/test_cli.py b/tests/test_cli.py index e018601..6c7e3f0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,63 +1,102 @@ -"""CLI parsing tests — argv → (subcommand, Args).""" +"""CLI parsing tests — argv → (subcommand, Namespace) and → Args.""" from __future__ import annotations import pytest -from cloud_sync.cli import Args, parse +from cloud_sync.cli import Args, args_from, parse -def test_parses_pull_with_required_url() -> None: - cmd, args = parse(["pull", "--url=https://cloud.tm.center"]) +# ---- pull / push ---- + + +def test_parses_pull_without_url() -> None: + """url is sync.json's job now — pull/push don't take --url.""" + cmd, ns = parse(["pull"]) assert cmd == "pull" + args = args_from(ns) assert isinstance(args, Args) - assert args.url == "https://cloud.tm.center" assert args.allow_download is True assert args.headless is False + assert args.instance_label is None def test_default_token_file_under_pack_folder() -> None: - _, args = parse(["pull", "--url=https://x", "--pack-folder=/tmp/inst"]) + _, ns = parse(["pull", "--pack-folder=/tmp/inst"]) + args = args_from(ns) assert args.token_file.as_posix().endswith(".cloud-sync/token") assert "/tmp/inst" in args.token_file.as_posix() def test_custom_token_file_overrides_default() -> None: - _, args = parse( - ["pull", "--url=https://x", "--pack-folder=/tmp/inst", - "--token-file=/etc/cloud-creds"] - ) + _, ns = parse([ + "pull", "--pack-folder=/tmp/inst", "--token-file=/etc/cloud-creds", + ]) + args = args_from(ns) assert args.token_file.as_posix() == "/etc/cloud-creds" def test_inline_and_space_separated_both_work() -> None: - _, a1 = parse(["pull", "--url=https://x", "--pack-folder=/srv"]) - _, a2 = parse(["pull", "--url", "https://x", "--pack-folder", "/srv"]) - assert a1.url == a2.url + _, ns1 = parse(["pull", "--pack-folder=/srv"]) + _, ns2 = parse(["pull", "--pack-folder", "/srv"]) + a1, a2 = args_from(ns1), args_from(ns2) assert a1.pack_folder == a2.pack_folder def test_no_gui_flag() -> None: - _, a = parse(["push", "--url=https://x", "-g"]) - assert a.headless is True - _, b = parse(["push", "--url=https://x", "--no-gui"]) - assert b.headless is True + _, a = parse(["push", "-g"]) + assert args_from(a).headless is True + _, b = parse(["push", "--no-gui"]) + assert args_from(b).headless is True def test_no_download_flag() -> None: - _, a = parse(["push", "--url=https://x", "--no-download"]) - assert a.allow_download is False + _, ns = parse(["push", "--no-download"]) + assert args_from(ns).allow_download is False def test_restic_binary_override() -> None: - _, a = parse(["push", "--url=https://x", "--restic-binary=/opt/restic"]) + _, ns = parse(["push", "--restic-binary=/opt/restic"]) + a = args_from(ns) assert a.restic_binary is not None assert a.restic_binary.as_posix() == "/opt/restic" -def test_missing_url_exits() -> None: +def test_instance_label_override() -> None: + _, ns = parse(["pull", "--instance-label=Frazaserver 1.21.4"]) + assert args_from(ns).instance_label == "Frazaserver 1.21.4" + + +def test_pack_folder_is_resolved_to_absolute() -> None: + _, ns = parse(["pull", "--pack-folder=."]) + assert args_from(ns).pack_folder.is_absolute() + + +# ---- setup / init / disable ---- + + +def test_setup_subcommand_accepts_optional_url() -> None: + cmd, ns = parse(["setup", "--url=https://x"]) + assert cmd == "setup" + assert ns.url == "https://x" + + +def test_init_requires_url() -> None: with pytest.raises(SystemExit): - parse(["pull"]) + parse(["init"]) + + +def test_init_accepts_url_and_token() -> None: + cmd, ns = parse(["init", "--url=https://x", "--token=42:secret"]) + assert cmd == "init" + assert ns.url == "https://x" + assert ns.token == "42:secret" + + +def test_disable_subcommand_parses() -> None: + cmd, ns = parse(["disable", "--pack-folder=/tmp/x"]) + assert cmd == "disable" + assert str(ns.pack_folder) == "/tmp/x" def test_missing_subcommand_exits() -> None: @@ -67,9 +106,4 @@ def test_missing_subcommand_exits() -> None: def test_unknown_subcommand_exits() -> None: with pytest.raises(SystemExit): - parse(["bogus", "--url=https://x"]) - - -def test_pack_folder_is_resolved_to_absolute() -> None: - _, a = parse(["pull", "--url=https://x", "--pack-folder=."]) - assert a.pack_folder.is_absolute() + parse(["bogus"]) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..3a6175c --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,118 @@ +"""sync.json read/write/mint + label resolution.""" + +from __future__ import annotations + +import os +import re +from pathlib import Path + +import pytest + +from cloud_sync import config as cfgmod + + +# ---- mint / new_instance_id ---- + + +def test_new_instance_id_shape(): + iid = cfgmod.new_instance_id() + assert len(iid) == 26 + # base32 alphabet (uppercase + 2-7), no padding + assert re.fullmatch(r"[A-Z2-7]+", iid), iid + + +def test_new_instance_id_uniqueness(): + ids = {cfgmod.new_instance_id() for _ in range(200)} + assert len(ids) == 200 # collisions effectively impossible + + +def test_mint_returns_filled_config(): + cfg = cfgmod.mint(url="https://x.test") + assert cfg.url == "https://x.test" + assert len(cfg.instance_id) == 26 + assert cfg.host_fingerprint # non-empty hex + assert cfg.created_at.tzinfo is not None + + +# ---- read / write / exists / delete ---- + + +def test_exists_false_when_missing(tmp_path: Path): + assert cfgmod.exists(tmp_path) is False + assert cfgmod.read(tmp_path) is None + + +def test_write_then_read_roundtrip(tmp_path: Path): + cfg = cfgmod.mint(url="https://x.test") + cfgmod.write(tmp_path, cfg) + assert cfgmod.exists(tmp_path) is True + got = cfgmod.read(tmp_path) + assert got is not None + assert got.url == cfg.url + assert got.instance_id == cfg.instance_id + assert got.host_fingerprint == cfg.host_fingerprint + + +def test_write_sets_mode_644(tmp_path: Path): + """sync.json is NOT a secret — it's the opt-in marker + (instance_id, url) + pair. token is the secret (mode 600).""" + cfg = cfgmod.mint(url="https://x.test") + cfgmod.write(tmp_path, cfg) + mode = cfgmod.config_path(tmp_path).stat().st_mode & 0o777 + assert mode == 0o644 + + +def test_delete_returns_true_when_existed(tmp_path: Path): + cfgmod.write(tmp_path, cfgmod.mint("https://x")) + assert cfgmod.delete(tmp_path) is True + assert cfgmod.exists(tmp_path) is False + + +def test_delete_returns_false_when_missing(tmp_path: Path): + assert cfgmod.delete(tmp_path) is False + + +def test_corrupt_json_returns_none(tmp_path: Path): + cfg_path = cfgmod.config_path(tmp_path) + cfg_path.parent.mkdir(parents=True) + cfg_path.write_text("{not json") + assert cfgmod.read(tmp_path) is None + + +def test_wrong_schema_returns_none(tmp_path: Path): + cfg_path = cfgmod.config_path(tmp_path) + cfg_path.parent.mkdir(parents=True) + cfg_path.write_text('{"schema": 999, "url": "x", "instance_id": "y", "created_at": "2026-01-01T00:00:00Z"}') + assert cfgmod.read(tmp_path) is None + + +# ---- resolve_label ---- + + +def test_label_flag_wins_over_env(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("INST_NAME", "from-env") + assert cfgmod.resolve_label("from-flag", "ABCDEF") == "from-flag" + + +def test_label_falls_back_to_inst_name(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("INST_NAME", "Frazaserver 1.21.4") + monkeypatch.delenv("INST_ID", raising=False) + assert cfgmod.resolve_label(None, "ABCDEF") == "Frazaserver 1.21.4" + + +def test_label_falls_back_to_inst_id(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("INST_NAME", raising=False) + monkeypatch.setenv("INST_ID", "26.1.2") + assert cfgmod.resolve_label(None, "ABCDEF") == "26.1.2" + + +def test_label_last_resort_uses_instance_id_prefix(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("INST_NAME", raising=False) + monkeypatch.delenv("INST_ID", raising=False) + assert cfgmod.resolve_label(None, "ABCDEFGHIJKLMN") == "ABCDEFGH" + + +def test_label_blank_flag_does_not_override(monkeypatch: pytest.MonkeyPatch): + """Empty string from --instance-label="" should not win over env.""" + monkeypatch.setenv("INST_NAME", "env-name") + assert cfgmod.resolve_label("", "ABCDEF") == "env-name" diff --git a/tests/test_repo_url.py b/tests/test_repo_url.py index dc41810..03abf7e 100644 --- a/tests/test_repo_url.py +++ b/tests/test_repo_url.py @@ -7,45 +7,52 @@ import pytest from cloud_sync.sync import _restic_env, _restic_repo +_IID = "01H7XJ4WB2KD5MNCYV8RQ6PTAZ" + + def test_basic_http_url(): - repo = _restic_repo("http://cloud.tm.center", "12345", "secretpw") - assert repo == "rest:http://12345:secretpw@cloud.tm.center/12345/" + repo = _restic_repo("http://cloud.tm.center", "12345", "secretpw", _IID) + assert repo == f"rest:http://12345:secretpw@cloud.tm.center/12345/{_IID}/" def test_https_url(): - repo = _restic_repo("https://cloud.tm.center", "12345", "pw") - assert repo == "rest:https://12345:pw@cloud.tm.center/12345/" + repo = _restic_repo("https://cloud.tm.center", "12345", "pw", _IID) + assert repo == f"rest:https://12345:pw@cloud.tm.center/12345/{_IID}/" def test_trailing_slash_stripped(): - repo = _restic_repo("https://cloud.tm.center/", "12345", "pw") - assert repo == "rest:https://12345:pw@cloud.tm.center/12345/" + repo = _restic_repo("https://cloud.tm.center/", "12345", "pw", _IID) + assert repo == f"rest:https://12345:pw@cloud.tm.center/12345/{_IID}/" def test_url_with_port(): - repo = _restic_repo("http://127.0.0.1:8002", "alice", "pw") - assert repo == "rest:http://alice:pw@127.0.0.1:8002/alice/" + repo = _restic_repo("http://127.0.0.1:8002", "alice", "pw", _IID) + assert repo == f"rest:http://alice:pw@127.0.0.1:8002/alice/{_IID}/" def test_rest_prefix_stripped_if_supplied(): - repo = _restic_repo("rest:http://x.test", "u", "p") - assert repo == "rest:http://u:p@x.test/u/" + repo = _restic_repo("rest:http://x.test", "u", "p", _IID) + assert repo == f"rest:http://u:p@x.test/u/{_IID}/" def test_password_with_special_chars_encoded(): - repo = _restic_repo("http://x.test", "u", "p@ss/word?!&") - # URL-encoded form of "p@ss/word?!&" + repo = _restic_repo("http://x.test", "u", "p@ss/word?!&", _IID) assert "p%40ss%2Fword%3F%21%26@x.test" in repo def test_user_with_special_chars_encoded(): - repo = _restic_repo("http://x.test", "u/with@chars", "pw") + repo = _restic_repo("http://x.test", "u/with@chars", "pw", _IID) assert "u%2Fwith%40chars" in repo +def test_instance_id_in_url_path(): + repo = _restic_repo("http://x.test", "u", "p", _IID) + assert repo.endswith(f"/u/{_IID}/") + + def test_missing_scheme_rejected(): with pytest.raises(ValueError): - _restic_repo("cloud.tm.center", "u", "p") + _restic_repo("cloud.tm.center", "u", "p", _IID) def test_env_does_not_contain_password(): diff --git a/tests/test_state.py b/tests/test_state.py index 52679e2..373f7b4 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -27,14 +27,22 @@ def test_read_missing_returns_none(tmp_path: Path): assert statemod.read(tmp_path) is None +_IID = "01H7XJ4WB2KD5MNCYV8RQ6PTAZ" + + def test_write_then_read_roundtrip(tmp_path: Path): dt = datetime(2026, 6, 5, 12, 34, 56, tzinfo=timezone.utc) statemod.write( tmp_path, - statemod.State(last_pulled_snapshot_id="abc123", last_pulled_at=dt), + statemod.State( + instance_id=_IID, + last_pulled_snapshot_id="abc123", + last_pulled_at=dt, + ), ) got = statemod.read(tmp_path) assert got is not None + assert got.instance_id == _IID assert got.last_pulled_snapshot_id == "abc123" assert got.last_pulled_at == dt @@ -43,6 +51,7 @@ def test_write_sets_mode_600(tmp_path: Path): statemod.write( tmp_path, statemod.State( + instance_id=_IID, last_pulled_snapshot_id="x", last_pulled_at=datetime.now(timezone.utc), ), @@ -56,6 +65,7 @@ def test_clear_idempotent(tmp_path: Path): statemod.write( tmp_path, statemod.State( + instance_id=_IID, last_pulled_snapshot_id="x", last_pulled_at=datetime.now(timezone.utc), ), @@ -68,9 +78,24 @@ def test_clear_idempotent(tmp_path: Path): def test_wrong_schema_returns_none(tmp_path: Path): p = tmp_path / ".cloud-sync" / "state.json" p.parent.mkdir(parents=True) - p.write_text( - json.dumps({"schema": 999, "last_pulled_snapshot_id": "x", "last_pulled_at": "2026-01-01T00:00:00Z"}) - ) + p.write_text(json.dumps({ + "schema": 999, "instance_id": _IID, + "last_pulled_snapshot_id": "x", + "last_pulled_at": "2026-01-01T00:00:00Z", + })) + assert statemod.read(tmp_path) is None + + +def test_schema_v1_returns_none(tmp_path: Path): + """Old schema-1 state.json (no instance_id) is treated as missing on + read. Triggers a fresh sync flow after the schema bump migration.""" + p = tmp_path / ".cloud-sync" / "state.json" + p.parent.mkdir(parents=True) + p.write_text(json.dumps({ + "schema": 1, + "last_pulled_snapshot_id": "x", + "last_pulled_at": "2026-01-01T00:00:00Z", + })) assert statemod.read(tmp_path) is None