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
+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"),
)