feat: opt-in by sync.json + per-instance ULID + restic subpath
CI / test (3.10) (push) Successful in 7s
CI / test (3.11) (push) Successful in 7s
CI / test (3.12) (push) Successful in 7s
CI / build-pyz (push) Successful in 4s
CI / release (push) Has been skipped

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 /<instance_id>/ 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)
This commit is contained in:
2026-06-05 09:53:20 +02:00
parent b31fdd023a
commit 20cfdf62f2
10 changed files with 792 additions and 115 deletions
+43 -16
View File
@@ -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 `<INST_MC_DIR>/.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 <pack-folder>/.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 `<pack_folder>/.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
+143 -40
View File
@@ -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 ``<pack>/.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: <pack-folder>/.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: <pack-folder>/.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"),
)
+169
View File
@@ -0,0 +1,169 @@
"""Per-instance sync.json — the opt-in marker + minimal config.
If ``<pack-folder>/.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]
+123
View File
@@ -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
``<pack>/.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
+4 -1
View File
@@ -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()
+80 -12
View File
@@ -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:<scheme>://<id>:<pw>@<host>/<id>/<instance_id>/``.
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]:
+62 -28
View File
@@ -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"])
+118
View File
@@ -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"
+21 -14
View File
@@ -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():
+29 -4
View File
@@ -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