pivot to Python: replace Kotlin/JVM with stdlib zipapp
CI / test (3.10) (push) Successful in 40s
CI / test (3.11) (push) Successful in 19s
CI / test (3.12) (push) Successful in 23s
CI / build-pyz (push) Successful in 4s
CI / release (push) Has been skipped

Reasons stacked up:
  - AV: unsigned JARs that auto-download binaries + upload files trigger
    Windows Defender false-positives more often than Python scripts
    invoked by code-signed python.exe.
  - Qt UI option: PySide6 opens a path to a real Qt UI (matching Prism's
    look) if needed later. JVM Qt bindings are abandoned.
  - frazclient already needs Python; inlining as 'import cloud_sync' is
    zero overhead vs the launcher always shelling out to java.

Implementation:
  - cloud_sync package: cli.py (argparse), creds.py, scope.py,
    restic.py (binary discovery + auto-download + sha256 verify),
    sync.py (pull/push subprocess restic).
  - pyproject.toml with hatchling backend; pip-installable.
  - Makefile builds cloud-sync.pyz via python -m zipapp (~53 KB).
  - 33 pytest tests, stdlib only on runtime.
  - CI workflow runs pytest matrix (3.10/3.11/3.12) + builds pyz.
  - DESIGN.md + README.md updated to reflect Python.

E2E verified against local restic-rest-server:
  pull empty → push initial → rm -rf local → pull restores → modify+push
  creates second snapshot → client forget --prune blocked by --append-only.

Throws away ~565 LOC of Kotlin (and 18 jar tests) committed earlier in
this same session. Net result is ~250 LOC Python + 33 tests = smaller
and more aligned with the rest of the stack.
This commit is contained in:
2026-06-03 01:11:47 +02:00
parent 171ea8f47a
commit ffdfb1f9b6
32 changed files with 1056 additions and 1343 deletions
+28 -23
View File
@@ -8,39 +8,46 @@ on:
branches: [main]
jobs:
build:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
container:
image: docker.io/gradle:8.10.2-jdk21
timeout-minutes: 10
strategy:
matrix:
python: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
- run: python -m pip install --upgrade pip
- run: pip install -e .[test]
- run: pytest -v
- name: Build fat jar
run: gradle --no-daemon shadowJar
- name: Test
run: gradle --no-daemon test
- name: Stash jar for release
if: startsWith(github.ref, 'refs/tags/v')
build-pyz:
runs-on: ubuntu-latest
needs: test
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: make build
- if: startsWith(github.ref, 'refs/tags/v')
uses: actions/upload-artifact@v4
with:
name: cloud-sync-jar
path: build/libs/cloud-sync-*.jar
name: cloud-sync-pyz
path: cloud-sync.pyz
release:
runs-on: ubuntu-latest
needs: build
needs: build-pyz
if: startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: cloud-sync-jar
path: build/libs/
name: cloud-sync-pyz
- name: Publish release
run: |
gh_token="${{ secrets.RELEASE_TOKEN }}"
@@ -51,9 +58,7 @@ jobs:
-d "{\"tag_name\":\"${tag}\",\"name\":\"${tag}\",\"draft\":false,\"prerelease\":false}" \
"${GITEA_SERVER_URL}/api/v1/repos/${{ gitea.event.repository.full_name }}/releases" > /tmp/release.json
release_id=$(jq -r .id /tmp/release.json)
for jar in build/libs/cloud-sync-*.jar; do
curl -sS -X POST \
-H "Authorization: token ${gh_token}" \
-F "attachment=@${jar}" \
"${GITEA_SERVER_URL}/api/v1/repos/${{ gitea.event.repository.full_name }}/releases/${release_id}/assets?name=$(basename $jar)"
done
-F "attachment=@cloud-sync.pyz" \
"${GITEA_SERVER_URL}/api/v1/repos/${{ gitea.event.repository.full_name }}/releases/${release_id}/assets?name=cloud-sync.pyz"
+8 -4
View File
@@ -1,8 +1,12 @@
.gradle/
__pycache__/
*.pyc
.pytest_cache/
.venv/
*.egg-info/
build/
out/
*.iml
dist/
cloud-sync.pyz
.coverage
.idea/
.vscode/
*.swp
local.properties
+13 -13
View File
@@ -1,12 +1,12 @@
# cloud-sync — design
Per-Discord-user state sync for Minecraft. Pulls on launch, pushes on exit. Single JAR drops into Prism / MMC / ATLauncher / frazclient as a pre-launch + post-exit hook.
Per-Discord-user state sync for Minecraft. Pulls on launch, pushes on exit. Single Python zipapp drops into Prism / MMC / ATLauncher / frazclient as a pre-launch + post-exit hook.
**Data plane:** `restic-rest-server` with `--private-repos --append-only`. Clients hit this directly with their per-user password.
**Control plane:** `cloud-svc` Go service with two listeners — a provisioning port reachable from automc-net (called by discord-bot) and a loopback admin port (called by automc-setup wizard). Players never touch cloud-svc.
**Client:** `cloud-sync.jar` subprocesses restic. ~200 LOC.
**Client:** `cloud-sync.pyz` (Python 3.10+, stdlib only) subprocesses restic. ~300 LOC. Distributed as a zipapp (single-file). Python over Java for two reasons: (a) launcher's PostExit hook can call any subprocess so language doesn't matter, (b) custom unsigned JARs that download binaries + upload files are textbook Windows Defender false-positive triggers, while Python invoked by signed `python.exe` mostly sidesteps that.
## Why this shape
@@ -29,8 +29,8 @@ flowchart LR
pl["player PC"]:::external
op["operator
(via SSH)"]:::external
jar["cloud-sync.jar
(in launcher's
jar["cloud-sync.pyz
(Python; in launcher's
pre/post hooks)"]:::deploy
restic["restic binary
(auto-downloaded
@@ -115,7 +115,7 @@ Revocation = operator runs `automc-setup cloud revoke <discord_id>` which hits t
## On-disk layout (client)
cloud-sync.jar stores its state under `<pack-folder>/.cloud-sync/` — per-instance, hidden by leading dot. Auto-excluded from cloud sync so a player can't accidentally upload their own credentials.
cloud-sync.pyz stores its state under `<pack-folder>/.cloud-sync/` — per-instance, hidden by leading dot. Auto-excluded from cloud sync so a player can't accidentally upload their own credentials.
```
<pack-folder>/
@@ -145,21 +145,21 @@ Probed in order:
### Jar placement
Stateless. Lives wherever the operator put it. Prism / MMC config references absolute path. One jar can serve N instances; each gets its own `.cloud-sync/` underneath its own `--pack-folder`.
Stateless. Lives wherever the operator put it. Prism / MMC config references absolute path. One pyz can serve N instances; each gets its own `.cloud-sync/` underneath its own `--pack-folder`.
## Client flow
### `cloud-sync.jar pull`
### `cloud-sync.pyz pull`
```
1. Load creds from <pack-folder>/.cloud-token (format: discord_id:password on one line)
2. Locate or auto-download restic binary into <jar dir>/restic-<version>/
2. Locate or auto-download restic binary into <pack-folder>/.cloud-sync/restic-<version>
3. restic -r rest:https://<url>/<discord_id>/ snapshots --latest 1 --json
4. If no snapshots → exit 0 (first run on this machine, nothing to restore)
5. restic restore latest --target <pack-folder> --include-from cloud-scope.txt
```
### `cloud-sync.jar push`
### `cloud-sync.pyz push`
```
1. Same creds + restic locator as pull
@@ -201,7 +201,7 @@ Recommendation: **option 1** (multi-key per repo). On `/register`, the bot calls
- restic-rest-server with `--private-repos --append-only --htpasswd-file`
- discord-bot `/register` extension: mint password, htpasswd add, `restic init` repo, `restic key add` player key
- cloud-sync.jar that subprocesses restic for pull/push
- cloud-sync.pyz that subprocesses restic for pull/push
- Auto-download restic binary on first run from upstream GitHub release
- Server-side nightly prune cron with operator-side master password key
@@ -236,7 +236,7 @@ New code:
Estimate: ~300 LOC kept, ~600 LOC new. Net smaller than current cloud-svc.
Also delete `cloud_pull` / `cloud_push` from `frazclient/client.py` (these get obsoleted by `cloud-sync.jar` calls).
Also delete `cloud_pull` / `cloud_push` from `frazclient/client.py` (these get obsoleted by `import cloud_sync` calls; frazclient depends on the same package).
## Topology consequences for `automc/docs/network-exposure.md`
@@ -260,7 +260,7 @@ Operator endpoints are loopback-only and require SSH access to john to reach. No
| Repo | Purpose |
|---|---|
| `Timemachine/cloud-sync` (this) | Kotlin/Gradle JAR that subprocesses restic |
| `Timemachine/cloud-sync` (this) | Python 3.10+ package + zipapp that subprocesses restic |
| `Timemachine/cloud-svc` | **Reshaped** — control plane only. Two-port Go service for provisioning + operator ops. NOT archived. |
| `Timemachine/discord-bot` | Extended `/register` flow calls cloud-svc to provision; DMs returned password |
| `Timemachine/automc` | `setup` wizard adds `automc-setup cloud {list,prune,revoke,quota}` subcommands hitting cloud-svc's loopback admin port. Quadlet templates for both restic-ao (new flags) and cloud-svc (two listeners). `database/schema.sql` unchanged. |
@@ -272,6 +272,6 @@ All locked 2026-06-02:
- [x] cloud-svc reshapes to control plane, not archived
- [x] Two-port split — automc-net for provisioning, loopback for operator
- [x] Server-side prune via operator master password key on each repo. On `provision`, cloud-svc runs `restic init` then `restic key add` with the operator-master password as a SECOND key. The nightly pruner uses the operator key to open any repo.
- [x] cloud-sync.jar auto-downloads restic binary. Matches `packwiz-installer-bootstrap` pattern. First run hits `https://github.com/restic/restic/releases` for the matching platform binary, caches under `<jar dir>/restic-<version>/`. `--no-download` flag for air-gapped operators.
- [x] cloud-sync.pyz auto-downloads restic binary. Matches `packwiz-installer-bootstrap` pattern. First run hits `https://github.com/restic/restic/releases` for the matching platform binary, caches under `<pack-folder>/.cloud-sync/restic-<version>`. SHA256 verified against the release's `SHA256SUMS` file. `--no-download` flag for air-gapped operators.
- [x] Nightly prune at 04:00 UTC. `time.Ticker` inside cloud-svc; no external cron. `--prune-time HH:MM` flag in case operators want a different window.
- [x] Per-caller tokens, NOT shared. cloud-svc reads `CLOUD_PROVISIONING_TOKENS_BOT`, `CLOUD_PROVISIONING_TOKENS_<OTHER>` env vars — one per known caller. Logs include the matched caller name so audit trails show which service made each call. Adding a future caller (e.g., a portal) means a new env var, not a token rotation.
+36
View File
@@ -0,0 +1,36 @@
.PHONY: build test clean install help
PY := python3
help:
@echo "Targets:"
@echo " build — produce cloud-sync.pyz (single-file zipapp)"
@echo " test — run pytest"
@echo " install — pip install -e ."
@echo " clean — remove built artifacts"
build: cloud-sync.pyz
# zipapp builds from a source dir; to preserve the cloud_sync/ package
# structure inside the archive we stage it under build/pyz first, then
# point zipapp at that staging dir.
cloud-sync.pyz: cloud_sync/__main__.py $(wildcard cloud_sync/*.py)
rm -rf build/pyz
mkdir -p build/pyz
cp -r cloud_sync build/pyz/
$(PY) -m zipapp build/pyz -p "/usr/bin/env $(PY)" \
-m cloud_sync.cli:main -o $@
rm -rf build/pyz
@echo "built: $@ ($$(stat -c '%s' $@) bytes)"
test:
$(PY) -m pytest tests/ -v
install:
$(PY) -m pip install -e .
clean:
rm -f cloud-sync.pyz
rm -rf build/ dist/ *.egg-info __pycache__ \
cloud_sync/__pycache__ tests/__pycache__ \
.pytest_cache
+67 -47
View File
@@ -1,80 +1,100 @@
# cloud-sync
Single-jar Kotlin client for [`cloud-svc`](https://git.timemachine.center/Timemachine/cloud-svc). Drops into Prism / MMC / ATLauncher / frazclient pre-launch + post-exit hooks alongside [`packwiz-installer-bootstrap`](https://github.com/packwiz/packwiz-installer-bootstrap). One tool, all launchers, no Python dependency on the client side.
Per-user Minecraft state sync via [restic](https://restic.net). Single Python zipapp drops into Prism / MMC / ATLauncher pre-launch and post-exit hooks alongside [packwiz-installer-bootstrap](https://github.com/packwiz/packwiz-installer-bootstrap). Part of the [automc](https://git.timemachine.center/Timemachine/automc) platform.
See [`DESIGN.md`](DESIGN.md) for the full architecture (restic backend, two-port cloud-svc control plane, etc.).
## Status
**Skeleton.** Build works, jar runs, CLI parses subcommands. Real sync logic + conflict UI land in subsequent commits. See [`DESIGN.md` in cloud-svc](https://git.timemachine.center/Timemachine/cloud-svc/src/branch/main/DESIGN.md) for the underlying contract this jar implements.
Working skeleton + sync logic. 33 tests pass. E2E verified against a local `restic-rest-server` (pull empty → push initial → delete local → pull restores → modify+push creates second snapshot → client `forget --prune` correctly blocked by `--append-only`).
## Usage (planned)
## Install / build
In Prism's instance settings → Custom commands:
Requires Python ≥ 3.10. No runtime deps (stdlib only).
```
Pre-launch command:
"$INST_JAVA" -jar /path/to/cloud-sync.jar pull \
--url=https://cloud.timemachine.center \
--pack-folder=$INST_MC_DIR
```bash
# build single-file zipapp
make build # → cloud-sync.pyz (~53 KB)
Post-exit command:
"$INST_JAVA" -jar /path/to/cloud-sync.jar push \
--url=https://cloud.timemachine.center \
--pack-folder=$INST_MC_DIR
# or pip-install
make install # pip install -e .
```
Token comes from `$INST_MC_DIR/.cloud-token` (paste once from your Discord-bot DM), `--token <STR>`, or `CLOUD_TOKEN` env.
## Usage in Prism (or MMC / ATLauncher)
Instance Settings → Custom commands:
```
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
```
Player needs Python 3.10+ on PATH. Token file (`<INST_MC_DIR>/.cloud-sync/token`) gets the `discord_id:password` credentials from their `/register` Discord DM.
## CLI
```
java -jar cloud-sync.jar <subcommand> [flags]
Subcommands:
pull Fetch user's cloud state, apply conflict resolution, write to instance.
push Walk instance, build snapshot, upload changed files.
Flags:
--url <URL> cloud-svc base URL (required)
--pack-folder <DIR> instance dir to sync into/from (default: ".")
--token <STR> bearer token (or use --token-file)
--token-file <PATH> read token from this file (default: <pack-folder>/.cloud-token)
-g, --no-gui headless mode; conflicts auto-resolve to remote-wins
-V, --version print version
-h, --help print this help
python cloud-sync.pyz {pull,push} \
--url URL cloud-svc data plane URL (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
```
## Build
## Programmatic API (for frazclient)
Requires JDK 1721 (JDK 26 currently breaks Kotlin 2.1's compiler). Easiest: containerized via podman/docker.
```python
from pathlib import Path
import cloud_sync
```bash
podman run --rm -v "$PWD":/work:Z -w /work docker.io/gradle:8.10.2-jdk21 \
gradle --no-daemon shadowJar
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,
))
```
Output: `build/libs/cloud-sync-<version>.jar`. Single fat jar; ship as-is.
frazclient's `client.py` consumes this directly via `import cloud_sync` instead of subprocessing the pyz.
For local development with a matching JDK installed:
## On-disk layout
```bash
./gradlew shadowJar
Per-instance state under `<pack-folder>/.cloud-sync/`:
```
.cloud-sync/
token # discord_id:password (mode 0600)
scope.json # optional; defaults baked in if missing
restic-<RESTIC_VERSION> # auto-downloaded binary
files-from.txt # restic --files-from
exclude-from.txt # restic --exclude-from
```
## UI
Auto-excluded from sync. Multiple MC instances = multiple `.cloud-sync/` dirs with independent credentials.
Swing + [FlatLaf](https://www.formdev.com/flatlaf/) (`FlatMacDarkLaf` theme). Closest visual match to Prism's Qt look in pure Java. Falls back to system look-and-feel if FlatLaf init fails (e.g., on bare-bones X servers).
## Why Python (not a JAR)
Conflict resolution dialog: per-file [keep local | use remote | skip] radio buttons + "use all local/remote" + Cancel/Continue. Headless mode (`-g`) defaults all conflicts to remote-wins (Steam's default).
1. **Antivirus.** Unsigned JARs that auto-download binaries + upload files are textbook Windows Defender false-positive triggers. Python invoked by code-signed `python.exe` mostly sidesteps that.
2. **Future Qt UI.** PySide6 opens a path to a real Qt UI (matching Prism's look) if richer surfaces are wanted later. JVM Qt bindings are abandoned.
3. **frazclient already needs Python.** Inlining as an import is zero overhead; the same package serves Prism via the pyz.
## Where this fits in the automc stack
Cost: players using Prism must have Python 3.10+ installed. Most Linux/Mac systems already do; Windows users install once from the Microsoft Store or python.org.
| Tool | What it owns |
|---|---|
| **packwiz-installer-bootstrap** | Mod sync — jars, configs shipped by the modpack, options.txt baseline |
| **cloud-sync** (this) | Per-user sync — player-modified configs, JourneyMap waypoints, screenshots |
| **frazclient** | Cracked-launcher orchestration — JDK, vanilla MC, Fabric, then invokes both jars |
## Where the data lives
cloud-sync and packwiz-installer-bootstrap are deliberately **separate jars** so players can disable cloud sync without affecting modpack sync (or vice versa) by just commenting out the line in Prism's hook config.
| Component | Role | Repo |
|---|---|---|
| `cloud-sync` (this) | Player-side. Subprocess restic for pull/push. | `Timemachine/cloud-sync` |
| `cloud-svc` | Operator-side control plane (provisioning + admin). | `Timemachine/cloud-svc` |
| `restic-rest-server` (existing) | Data plane. Player's restic hits it directly with their password. | upstream |
| `discord-bot` | Calls cloud-svc on `/register` to provision a player's cloud account. | `Timemachine/discord-bot` |
## License
-65
View File
@@ -1,65 +0,0 @@
plugins {
kotlin("jvm") version "2.1.0"
kotlin("plugin.serialization") version "2.1.0"
id("com.gradleup.shadow") version "8.3.5"
application
}
group = "center.timemachine.cloud"
version = "0.1.0"
repositories {
mavenCentral()
}
dependencies {
// FlatLaf gives us an IntelliJ-style dark theme on Swing —
// closest visual match to Prism Launcher's Qt look in pure Java.
implementation("com.formdev:flatlaf:3.5.4")
implementation("com.formdev:flatlaf-intellij-themes:3.5.4")
// JSON via Kotlin's official lib; supports kotlin data classes natively.
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
// bz2 decompression of restic release archives without shelling out to
// system bzcat (covers stripped-down containers).
implementation("org.apache.commons:commons-compress:1.27.1")
testImplementation(kotlin("test"))
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
}
// Target Java 17 bytecode using whatever JDK is installed locally (we
// require >= 17). Avoids the Gradle toolchain auto-provisioning dance,
// at the cost of needing a manual JDK 17+ on dev machines and CI runners.
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
application {
mainClass.set("center.timemachine.cloud.MainKt")
}
tasks.test {
useJUnitPlatform()
}
tasks.shadowJar {
archiveBaseName.set("cloud-sync")
archiveClassifier.set("")
archiveVersion.set(project.version.toString())
mergeServiceFiles()
}
// Defer to shadowJar for the canonical artifact; the default 'jar' task
// produces a thin jar without dependencies which would be useless to ship.
tasks.jar {
enabled = false
}
+14
View File
@@ -0,0 +1,14 @@
"""cloud-sync — per-user state sync for Minecraft via restic.
Public API for in-process callers (e.g. frazclient):
import cloud_sync
cloud_sync.pull(url="https://cloud.tm.center", pack_folder=Path("/instance"))
cloud_sync.push(url="https://cloud.tm.center", pack_folder=Path("/instance"))
"""
from .cli import Args
from .sync import pull, push
__version__ = "0.1.0"
__all__ = ["Args", "pull", "push", "__version__"]
+11
View File
@@ -0,0 +1,11 @@
"""Entry for ``python -m cloud_sync`` and the zipapp build."""
from __future__ import annotations
import sys
from .cli import main
if __name__ == "__main__":
sys.exit(main())
+102
View File
@@ -0,0 +1,102 @@
"""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.
"""
from __future__ import annotations
import argparse
import sys
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class Args:
"""Parsed CLI args shared by both pull + push subcommands."""
url: str
pack_folder: Path
token_file: Path
restic_binary: Path | None # None → auto-discover
allow_download: bool
headless: bool
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="cloud-sync",
description="Per-user Minecraft state sync via restic.",
)
p.add_argument("--version", action="version", version="cloud-sync 0.1.0")
sub = p.add_subparsers(dest="cmd", required=True)
for name in ("pull", "push"):
sp = sub.add_parser(name, help=f"{name} player state")
sp.add_argument(
"--url", required=True,
help="cloud-svc data plane URL (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 Swing/Qt windows, restic stdout only)",
)
return p
def parse(argv: list[str]) -> tuple[str, Args]:
"""Parse argv → (subcommand, Args). Raises SystemExit on error/help."""
ns = build_parser().parse_args(argv)
pack = Path(ns.pack_folder).absolute().resolve()
token = (
Path(ns.token_file).absolute()
if ns.token_file is not None
else pack / ".cloud-sync" / "token"
)
return ns.cmd, Args(
url=ns.url,
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,
)
def main(argv: list[str] | None = None) -> int:
"""CLI entrypoint. Returns exit code (0=ok, 1=user cancel, 2=error)."""
# Import here to keep CLI import light (test isolation).
from . import sync
try:
cmd, args = 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
action = {"pull": sync.pull, "push": sync.push}[cmd]
try:
return action(args)
except KeyboardInterrupt:
print("cloud-sync: cancelled", file=sys.stderr)
return 1
except Exception as e: # noqa: BLE001
print(f"cloud-sync {cmd}: {e}", file=sys.stderr)
return 2
+40
View File
@@ -0,0 +1,40 @@
"""Token file reader.
Format: ``discord_id:password`` on a single line. Whitespace tolerated.
The Discord ID is the URL path segment under cloud.tm.center/<id>/ that
restic-rest-server's --private-repos enforces against the basic-auth
username. The password is the bcrypt'd entry's plaintext AND the restic
repo encryption password (cloud-svc provisions one password covering both).
"""
from __future__ import annotations
from pathlib import Path
class CredentialsError(Exception):
"""Raised when the token file is missing or malformed."""
def read_credentials(token_file: Path) -> tuple[str, str]:
if not token_file.exists():
raise CredentialsError(
f"cloud-sync token not found at {token_file}. "
f"After /register in Discord you should have received credentials; "
f"paste them into this file as 'discord_id:password' on one line."
)
raw = token_file.read_text(encoding="utf-8").strip()
if ":" not in raw:
raise CredentialsError(
f"cloud-sync token at {token_file} malformed "
f"(expected 'discord_id:password' on one line)"
)
discord_id, password = raw.split(":", 1)
discord_id = discord_id.strip()
password = password.strip()
if not discord_id or not password:
raise CredentialsError(
f"cloud-sync token at {token_file} malformed "
f"(empty discord_id or password)"
)
return discord_id, password
+223
View File
@@ -0,0 +1,223 @@
"""restic binary discovery + auto-download + invocation.
Discovery order:
1. ``--restic-binary <path>`` (explicit override)
2. ``<pack-folder>/.cloud-sync/restic-<RESTIC_VERSION>`` (pinned cached copy)
3. ``$PATH`` (only if version matches RESTIC_VERSION)
4. download from GitHub releases (unless ``--no-download``)
The version is pinned because repos written by one restic version can have
features another version can't read. Cache the pinned binary per-instance
so deleting the instance dir wipes everything cloud-sync owns.
"""
from __future__ import annotations
import bz2
import hashlib
import os
import platform
import re
import shutil
import stat
import subprocess
import sys
import tempfile
import urllib.request
import zipfile
from dataclasses import dataclass
from pathlib import Path
from .cli import Args
RESTIC_VERSION = "0.18.0"
RELEASE_TAG = f"v{RESTIC_VERSION}"
_HTTP_TIMEOUT = 30
@dataclass(frozen=True)
class Platform:
os_tag: str # linux, darwin, windows
arch_tag: str # amd64, arm64
is_windows: bool
def _detect_platform() -> Platform:
name = platform.system().lower()
arch_raw = platform.machine().lower()
if name.startswith("linux"):
os_tag = "linux"
elif name.startswith("darwin"):
os_tag = "darwin"
elif name.startswith("windows"):
os_tag = "windows"
else:
raise RuntimeError(f"unsupported platform: {name}")
if arch_raw in ("amd64", "x86_64"):
arch_tag = "amd64"
elif arch_raw in ("aarch64", "arm64"):
arch_tag = "arm64"
else:
raise RuntimeError(f"unsupported architecture: {arch_raw}")
return Platform(os_tag, arch_tag, os_tag == "windows")
def _binary_filename(plat: Platform) -> str:
suffix = ".exe" if plat.is_windows else ""
return f"restic-{RESTIC_VERSION}{suffix}"
def resolve_binary(args: Args) -> Path:
"""Return a usable restic binary path, downloading if needed."""
if args.restic_binary is not None:
if not args.restic_binary.exists():
raise FileNotFoundError(
f"--restic-binary path does not exist: {args.restic_binary}"
)
return args.restic_binary
plat = _detect_platform()
cache_dir = args.pack_folder / ".cloud-sync"
cached = cache_dir / _binary_filename(plat)
if cached.exists() and os.access(cached, os.X_OK):
return cached
# Try $PATH only when version matches exactly
system = _find_system_binary_matching_version()
if system is not None:
return system
if not args.allow_download:
raise RuntimeError(
f"no usable restic binary at {cached} or on $PATH, "
f"and --no-download disabled auto-fetch"
)
cache_dir.mkdir(parents=True, exist_ok=True)
_download_restic_to(cached, plat)
return cached
def run(
binary: Path,
args: list[str],
env: dict[str, str] | None = None,
cwd: Path | None = None,
timeout: int = 900,
) -> tuple[int, str]:
"""Run restic. Inherits stderr to caller's terminal for live progress.
Returns (returncode, captured_stdout)."""
merged_env = dict(os.environ)
if env:
merged_env.update(env)
p = subprocess.run( # noqa: S603 — controlled invocation
[str(binary), *args],
cwd=str(cwd) if cwd else None,
env=merged_env,
stdout=subprocess.PIPE,
stderr=sys.stderr,
text=True,
timeout=timeout,
check=False,
)
return p.returncode, p.stdout
# ---------------------------------------------------------------------------
# discovery + download helpers
# ---------------------------------------------------------------------------
def _find_system_binary_matching_version() -> Path | None:
name = "restic.exe" if _detect_platform().is_windows else "restic"
found = shutil.which(name)
if not found:
return None
path = Path(found)
return path if _binary_version(path) == RESTIC_VERSION else None
def _binary_version(path: Path) -> str | None:
try:
out = subprocess.run( # noqa: S603
[str(path), "version"],
capture_output=True, text=True, timeout=5, check=False,
)
except (subprocess.SubprocessError, OSError):
return None
text = out.stdout + out.stderr
m = re.search(r"restic\s+(\d+\.\d+\.\d+)", text)
return m.group(1) if m else None
def _download_restic_to(target: Path, plat: Platform) -> None:
ext = "zip" if plat.is_windows else "bz2"
asset = f"restic_{RESTIC_VERSION}_{plat.os_tag}_{plat.arch_tag}.{ext}"
asset_url = (
f"https://github.com/restic/restic/releases/download/"
f"{RELEASE_TAG}/{asset}"
)
sums_url = (
f"https://github.com/restic/restic/releases/download/"
f"{RELEASE_TAG}/SHA256SUMS"
)
print(
f"cloud-sync: downloading restic {RESTIC_VERSION} from {asset_url}",
file=sys.stderr,
)
with tempfile.NamedTemporaryFile(suffix=f".{ext}", delete=False) as tmp:
tmp_path = Path(tmp.name)
try:
with urllib.request.urlopen(asset_url, timeout=_HTTP_TIMEOUT) as r:
tmp_path.write_bytes(r.read())
expected = _expected_sha(sums_url, asset)
actual = _sha256_file(tmp_path)
if expected.lower() != actual.lower():
raise RuntimeError(
f"restic download sha mismatch: expected {expected}, got {actual}"
)
_decompress_into(tmp_path, target, ext)
if not plat.is_windows:
target.chmod(target.stat().st_mode | stat.S_IXUSR | stat.S_IRUSR | stat.S_IWUSR)
finally:
tmp_path.unlink(missing_ok=True)
def _expected_sha(sums_url: str, asset: str) -> str:
with urllib.request.urlopen(sums_url, timeout=_HTTP_TIMEOUT) as r:
body = r.read().decode("utf-8")
for line in body.splitlines():
line = line.strip()
if line.endswith(asset):
return line.split()[0]
raise RuntimeError(f"restic SHA256SUMS missing entry for {asset}")
def _sha256_file(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
def _decompress_into(archive: Path, target: Path, ext: str) -> None:
target.parent.mkdir(parents=True, exist_ok=True)
if ext == "bz2":
with bz2.open(archive, "rb") as src, target.open("wb") as dst:
shutil.copyfileobj(src, dst)
elif ext == "zip":
with zipfile.ZipFile(archive) as zf:
inner = next(
(n for n in zf.namelist() if n.endswith("restic.exe")),
None,
)
if inner is None:
raise RuntimeError("restic.exe not found in downloaded zip")
with zf.open(inner) as src, target.open("wb") as dst:
shutil.copyfileobj(src, dst)
else:
raise RuntimeError(f"unsupported archive ext: {ext}")
+84
View File
@@ -0,0 +1,84 @@
"""Per-distribution sync scope (include/exclude paths).
Each cloud-sync deployment ships its own ``scope.json`` that picks which
files participate in sync. Lives at ``<pack-folder>/.cloud-sync/scope.json``.
Defaults are baked in so a fresh install with no scope.json works.
"""
from __future__ import annotations
import json
import sys
from dataclasses import dataclass, field
from pathlib import Path
DEFAULT_INCLUDE: list[str] = [
"options.txt",
"optionsof.txt",
"optionsshaders.txt",
"config/",
"journeymap/data/",
"screenshots/",
]
DEFAULT_EXCLUDE: list[str] = [
".cloud-sync/", # never sync our own state dir
".cloud-token", # legacy location (pre-jar/pre-restic era)
"config/simple-mod-sync*",
"config/packwiz*",
"**/cache/",
"**/*.log",
"**/*.tmp",
]
@dataclass(frozen=True)
class Scope:
include: list[str] = field(default_factory=lambda: list(DEFAULT_INCLUDE))
exclude: list[str] = field(default_factory=lambda: list(DEFAULT_EXCLUDE))
def load(pack_folder: Path) -> Scope:
"""Read scope.json or return defaults."""
path = pack_folder / ".cloud-sync" / "scope.json"
if not path.exists():
return Scope()
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as e:
print(
f"cloud-sync: scope.json invalid ({e}); using defaults",
file=sys.stderr,
)
return Scope()
return Scope(
include=list(data.get("include", DEFAULT_INCLUDE)),
exclude=list(data.get("exclude", DEFAULT_EXCLUDE)),
)
def materialize_for_restic(pack_folder: Path, scope: Scope) -> tuple[Path, Path]:
"""Write files-from + exclude-from text files restic can consume.
Files include directories; restic recurses into them. Exclude patterns
are matched against file paths during the walk.
"""
state_dir = pack_folder / ".cloud-sync"
state_dir.mkdir(parents=True, exist_ok=True)
files_from = state_dir / "files-from.txt"
exclude_from = state_dir / "exclude-from.txt"
files_from.write_text(
"\n".join(_trim_trailing_slash(p) for p in scope.include) + "\n",
encoding="utf-8",
)
exclude_from.write_text(
"\n".join(scope.exclude) + "\n",
encoding="utf-8",
)
return files_from, exclude_from
def _trim_trailing_slash(s: str) -> str:
return s.rstrip("/") if s.endswith("/") else s
+129
View File
@@ -0,0 +1,129 @@
"""pull + push entry points.
Both subprocess restic against ``rest:<scheme>://<id>:<password>@<host>/<id>/``
where the same password is the HTTP basic credential and the repo
encryption key. cloud-svc provisions one password covering both.
"""
from __future__ import annotations
import sys
import urllib.parse
from pathlib import Path
from . import restic, scope as scopemod
from .cli import Args
from .creds import read_credentials
def pull(args: Args) -> int:
"""Restore latest snapshot's files into pack_folder.
If the repo has no snapshots yet, this is a no-op (first run on this
machine; nothing to restore).
"""
discord_id, password = read_credentials(args.token_file)
binary = restic.resolve_binary(args)
repo = _restic_repo(args.url, discord_id, password)
env = _restic_env(password)
# Check whether any snapshots exist
code, out = restic.run(
binary,
["-r", repo, "snapshots", "--json", "--latest", "1"],
env=env,
)
if code != 0:
print(
f"cloud-sync: failed to list snapshots (restic exit {code})",
file=sys.stderr,
)
return 2
stripped = out.strip()
if stripped in ("", "null", "[]"):
print(
"cloud-sync: no snapshots yet for this user "
"(first run on this machine?); nothing to pull"
)
return 0
scope = scopemod.load(args.pack_folder)
_, exclude_from = scopemod.materialize_for_restic(args.pack_folder, scope)
code, _ = restic.run(
binary,
[
"-r", repo, "restore", "latest",
"--target", str(args.pack_folder),
"--exclude-file", str(exclude_from),
],
env=env,
)
if code != 0:
print(f"cloud-sync: restic restore failed (exit {code})", file=sys.stderr)
return 2
print("cloud-sync: pull ok")
return 0
def push(args: Args) -> int:
"""Snapshot the in-scope files into the user's repo."""
discord_id, password = read_credentials(args.token_file)
binary = restic.resolve_binary(args)
repo = _restic_repo(args.url, discord_id, password)
env = _restic_env(password)
scope = scopemod.load(args.pack_folder)
files_from, exclude_from = scopemod.materialize_for_restic(args.pack_folder, scope)
code, _ = restic.run(
binary,
[
"-r", repo, "backup",
"--files-from", str(files_from),
"--exclude-file", str(exclude_from),
"--host", "cloud-sync",
"--tag", "auto",
],
env=env,
cwd=args.pack_folder,
)
if code != 0:
print(f"cloud-sync: restic backup failed (exit {code})", file=sys.stderr)
return 2
print("cloud-sync: push ok")
return 0
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _restic_repo(base_url: str, discord_id: str, password: str) -> str:
"""Build rest:<scheme>://<id>:<pw>@<host>/<id>/
URL-embedded basic auth is universally supported by restic; alternative
env vars (RESTIC_REST_USERNAME, RESTIC_REST_PASSWORD) require 0.16+.
"""
raw = base_url.strip()
if raw.startswith("rest:"):
raw = raw[len("rest:"):]
raw = raw.rstrip("/")
scheme_end = raw.find("://")
if scheme_end <= 0:
raise ValueError(
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}/"
def _restic_env(password: str) -> dict[str, str]:
return {
"RESTIC_PASSWORD": password,
"RESTIC_PROGRESS_FPS": "0",
}
Binary file not shown.
-7
View File
@@ -1,7 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
networkTimeout=10000
validateDistributionUrl=true
Vendored
-252
View File
@@ -1,252 +0,0 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
-94
View File
@@ -1,94 +0,0 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+38
View File
@@ -0,0 +1,38 @@
[build-system]
requires = ["hatchling>=1.21"]
build-backend = "hatchling.build"
[project]
name = "cloud-sync"
version = "0.1.0"
description = "Per-user state sync for Minecraft via restic. Drops into Prism / MMC / ATLauncher pre-launch and post-exit hooks."
readme = "README.md"
license = { file = "LICENSE" }
requires-python = ">=3.10"
authors = [{ name = "Timemachine", email = "ops@timemachine.center" }]
keywords = ["minecraft", "restic", "sync", "automc"]
classifiers = [
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Games/Entertainment",
"Topic :: System :: Archiving :: Backup",
]
dependencies = [] # stdlib only
[project.optional-dependencies]
test = ["pytest>=8.0"]
# Future: pip install cloud-sync[qt] when we add a Qt progress / config UI
qt = ["PySide6>=6.7"]
[project.scripts]
cloud-sync = "cloud_sync.cli:main"
[project.urls]
Homepage = "https://git.timemachine.center/Timemachine/cloud-sync"
Issues = "https://git.timemachine.center/Timemachine/cloud-sync/issues"
[tool.hatch.build.targets.wheel]
packages = ["cloud_sync"]
-1
View File
@@ -1 +0,0 @@
rootProject.name = "cloud-sync"
@@ -1,98 +0,0 @@
package center.timemachine.cloud
import java.nio.file.Path
import java.nio.file.Paths
/**
* Parsed command-line arguments shared by pull + push subcommands.
*
* Flag style matches packwiz-installer-bootstrap (`--url=` or `--url value`,
* `-g` shorthand) so operators wiring Prism's PreLaunch/PostExit hooks
* don't have to relearn the surface.
*/
data class Args(
val url: String,
val packFolder: Path,
val tokenFile: Path,
val resticBinary: Path?, // null = auto-discover
val allowDownload: Boolean, // false = --no-download
val headless: Boolean,
)
/**
* Thrown when args fail validation. Message is shown to the user;
* the launcher caller sees a non-zero exit but the message is what
* tells the operator what to fix.
*/
class ArgParseException(message: String) : RuntimeException(message)
/**
* Parse argv for a sync subcommand. Caller already stripped the
* subcommand name (pull/push) — `args` is everything after.
*/
fun parseArgs(args: Array<String>): Args {
var url: String? = null
var packFolder: String? = null
var tokenFile: String? = null
var resticBinary: String? = null
var allowDownload = true
var headless = false
val iter = args.iterator()
while (iter.hasNext()) {
val raw = iter.next()
val (flag, valueInline) = splitFlag(raw)
when (flag) {
"--url" -> url = valueInline ?: takeValue(iter, flag)
"--pack-folder" -> packFolder = valueInline ?: takeValue(iter, flag)
"--token-file" -> tokenFile = valueInline ?: takeValue(iter, flag)
"--restic-binary" -> resticBinary = valueInline ?: takeValue(iter, flag)
"--no-download" -> {
rejectInlineValue(flag, valueInline)
allowDownload = false
}
"-g", "--no-gui" -> {
rejectInlineValue(flag, valueInline)
headless = true
}
else -> throw ArgParseException("unknown flag: $raw")
}
}
if (url.isNullOrBlank()) {
throw ArgParseException("--url is required (cloud-svc data plane URL, e.g. https://cloud.tm.center)")
}
val packPath: Path = packFolder?.let { Paths.get(it) } ?: Paths.get(".")
val tokenPath: Path = tokenFile?.let { Paths.get(it) }
?: packPath.resolve(".cloud-sync").resolve("token")
val resticPath: Path? = resticBinary?.let { Paths.get(it) }
return Args(
url = url,
packFolder = packPath.toAbsolutePath().normalize(),
tokenFile = tokenPath,
resticBinary = resticPath,
allowDownload = allowDownload,
headless = headless,
)
}
/** Split "--flag=value" into ("--flag", "value"). Returns (raw, null) when no '=' present. */
private fun splitFlag(raw: String): Pair<String, String?> {
val eq = raw.indexOf('=')
return if (eq == -1) raw to null else raw.substring(0, eq) to raw.substring(eq + 1)
}
private fun takeValue(iter: Iterator<String>, flag: String): String {
if (!iter.hasNext()) {
throw ArgParseException("$flag requires a value")
}
return iter.next()
}
private fun rejectInlineValue(flag: String, value: String?) {
if (value != null) {
throw ArgParseException("$flag does not take a value")
}
}
@@ -1,26 +0,0 @@
package center.timemachine.cloud
/**
* Subcommand dispatchers. Real work happens in Sync.kt; this layer
* parses args, surfaces ArgParseException as a clean error, and
* funnels through to pull/push.
*/
object Cli {
fun runPull(args: Array<String>): Int = run("pull", args, ::pull)
fun runPush(args: Array<String>): Int = run("push", args, ::push)
private fun run(name: String, raw: Array<String>, action: (Args) -> Int): Int {
val parsed = try {
parseArgs(raw)
} catch (e: ArgParseException) {
System.err.println("cloud-sync $name: ${e.message}")
return 2
}
return try {
action(parsed)
} catch (e: Exception) {
System.err.println("cloud-sync $name: ${e.message ?: e.toString()}")
2
}
}
}
@@ -1,74 +0,0 @@
package center.timemachine.cloud
import com.formdev.flatlaf.themes.FlatMacDarkLaf
import javax.swing.UIManager
/**
* cloud-sync entrypoint.
*
* Invoked twice in the launcher hooks:
* PreLaunch: java -jar cloud-sync.jar pull --url=... --pack-folder=...
* PostExit: java -jar cloud-sync.jar push --url=... --pack-folder=...
*
* Exit codes:
* 0 ok
* 1 user cancelled
* 2 error
*/
fun main(args: Array<String>) {
if (args.isEmpty() || args[0] in listOf("-h", "--help", "help")) {
printHelp()
return
}
if (args[0] in listOf("--version", "-V")) {
println("cloud-sync 0.1.0")
return
}
// Install FlatLaf theme as early as possible so any Swing window
// we open later picks it up. Falls back to system L&F on failure.
try {
UIManager.setLookAndFeel(FlatMacDarkLaf())
} catch (e: Exception) {
System.err.println("note: FlatLaf init failed (${e.message}); using system L&F")
}
val subcommand = args[0]
val rest = args.drop(1).toTypedArray()
val rc = when (subcommand) {
"pull" -> Cli.runPull(rest)
"push" -> Cli.runPush(rest)
else -> {
System.err.println("unknown subcommand: $subcommand")
printHelp()
2
}
}
kotlin.system.exitProcess(rc)
}
private fun printHelp() {
println("""
cloud-sync — per-user state sync for Minecraft clients
Usage:
java -jar cloud-sync.jar <subcommand> [flags]
Subcommands:
pull Fetch user's cloud state, apply conflict resolution, write to instance.
push Walk instance, build snapshot, upload changed files.
Flags:
--url <URL> cloud-svc base URL (required)
--pack-folder <DIR> instance dir to sync into/from (default: ".")
--token <STR> bearer token (or use --token-file)
--token-file <PATH> read token from this file (default: <pack-folder>/.cloud-token)
-g, --no-gui headless mode; conflicts auto-resolve to remote-wins
-V, --version print version
-h, --help print this help
Environment:
CLOUD_TOKEN fallback token source if no flag and no file
""".trimIndent())
}
@@ -1,246 +0,0 @@
package center.timemachine.cloud
import java.io.BufferedInputStream
import java.io.IOException
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.attribute.PosixFilePermission.OWNER_EXECUTE
import java.nio.file.attribute.PosixFilePermission.OWNER_READ
import java.nio.file.attribute.PosixFilePermission.OWNER_WRITE
import java.security.MessageDigest
import java.time.Duration
import java.util.concurrent.TimeUnit
import kotlin.io.path.exists
import kotlin.io.path.isExecutable
/**
* restic binary discovery + auto-download + invocation.
*
* Discovery order:
* 1. --restic-binary <path> (explicit override)
* 2. <pack-folder>/.cloud-sync/restic-<RESTIC_VERSION> (pinned cached copy)
* 3. system `restic` on PATH (only if version matches RESTIC_VERSION)
* 4. download from GitHub releases (unless --no-download disables)
*
* Pinning the version simplifies cross-platform behaviour — repos written by
* one version may have features another version can't read. Cache the pinned
* binary per-instance so removing the instance dir removes everything.
*/
object Restic {
const val RESTIC_VERSION = "0.18.0"
/** Tag of the GitHub release we download from. */
private const val RELEASE_TAG = "v$RESTIC_VERSION"
/**
* Resolve which restic binary to use, downloading if necessary. Throws
* IOException if discovery + download both fail.
*/
fun resolveBinary(args: Args): Path {
args.resticBinary?.let { p ->
require(p.exists()) { "--restic-binary path does not exist: $p" }
return p.toAbsolutePath()
}
val cacheDir = args.packFolder.resolve(".cloud-sync")
val expectedName = if (isWindows()) "restic-$RESTIC_VERSION.exe" else "restic-$RESTIC_VERSION"
val cached = cacheDir.resolve(expectedName)
if (cached.exists() && cached.isExecutable()) {
return cached
}
// Try $PATH only if its version matches the pinned one — different versions
// can produce incompatible repos, so we don't trust an "any restic on PATH" install.
findSystemBinaryMatchingVersion()?.let { return it }
if (!args.allowDownload) {
throw IOException(
"no usable restic binary found at $cached or on \$PATH, " +
"and --no-download disabled the fetch from upstream"
)
}
Files.createDirectories(cacheDir)
downloadResticTo(cached)
return cached
}
/**
* Run restic with the given args + env. Inherits stderr to the caller's
* terminal for live progress. Returns (exitCode, stdout).
*/
fun run(
binary: Path,
args: List<String>,
env: Map<String, String>,
cwd: Path? = null,
): Pair<Int, String> {
val pb = ProcessBuilder(listOf(binary.toString()) + args)
pb.redirectError(ProcessBuilder.Redirect.INHERIT)
cwd?.let { pb.directory(it.toFile()) }
// Carry over caller env; overlay our values
pb.environment().putAll(env)
val p = pb.start()
val out = p.inputStream.bufferedReader().use { it.readText() }
val ok = p.waitFor(15, TimeUnit.MINUTES)
if (!ok) {
p.destroyForcibly()
throw IOException("restic timed out after 15 minutes (cmd: ${args.joinToString(" ")})")
}
return p.exitValue() to out
}
// ----------------------------------------------------------------- internals
private fun isWindows() = System.getProperty("os.name").lowercase().contains("windows")
private fun osTag() = when {
isWindows() -> "windows"
System.getProperty("os.name").lowercase().contains("mac") -> "darwin"
else -> "linux"
}
private fun archTag() = when (val a = System.getProperty("os.arch").lowercase()) {
"amd64", "x86_64" -> "amd64"
"aarch64", "arm64" -> "arm64"
else -> error("unsupported architecture for restic auto-download: $a")
}
private fun findSystemBinaryMatchingVersion(): Path? {
val pathEnv = System.getenv("PATH") ?: return null
val name = if (isWindows()) "restic.exe" else "restic"
for (dir in pathEnv.split(System.getProperty("path.separator"))) {
val cand = Path.of(dir, name)
if (cand.exists() && cand.isExecutable()) {
if (resticVersion(cand) == RESTIC_VERSION) {
return cand.toAbsolutePath()
}
}
}
return null
}
private fun resticVersion(binary: Path): String? {
return try {
val p = ProcessBuilder(binary.toString(), "version").redirectErrorStream(true).start()
val out = p.inputStream.bufferedReader().use { it.readText() }
p.waitFor(5, TimeUnit.SECONDS)
// Output line: "restic 0.18.0 compiled with go..."
val regex = Regex("""restic\s+(\d+\.\d+\.\d+)""")
regex.find(out)?.groupValues?.get(1)
} catch (_: Exception) {
null
}
}
private fun downloadResticTo(target: Path) {
val ext = if (osTag() == "windows") "zip" else "bz2"
val asset = "restic_${RESTIC_VERSION}_${osTag()}_${archTag()}.$ext"
val url = "https://github.com/restic/restic/releases/download/$RELEASE_TAG/$asset"
System.err.println("cloud-sync: downloading restic $RESTIC_VERSION from $url")
val client = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NORMAL)
.connectTimeout(Duration.ofSeconds(15))
.build()
val tmp = Files.createTempFile("restic-dl-", ".$ext")
try {
val req = HttpRequest.newBuilder(URI.create(url)).GET().build()
val resp = client.send(req, HttpResponse.BodyHandlers.ofFile(tmp))
if (resp.statusCode() != 200) {
throw IOException("restic download failed: HTTP ${resp.statusCode()} for $url")
}
// Also fetch SHA256SUMS file to verify integrity
val sumsUrl = "https://github.com/restic/restic/releases/download/$RELEASE_TAG/SHA256SUMS"
val sumsReq = HttpRequest.newBuilder(URI.create(sumsUrl)).GET().build()
val sumsBody = client.send(sumsReq, HttpResponse.BodyHandlers.ofString()).body()
val expectedSha = sumsBody.lineSequence()
.map { it.trim() }
.firstOrNull { it.endsWith(asset) }
?.split(Regex("""\s+"""))
?.firstOrNull()
?: throw IOException("restic SHA256SUMS missing entry for $asset")
val actualSha = sha256OfFile(tmp)
if (actualSha != expectedSha.lowercase()) {
throw IOException("restic download sha mismatch: expected $expectedSha, got $actualSha")
}
decompressInto(tmp, target, ext)
makeExecutable(target)
} finally {
Files.deleteIfExists(tmp)
}
}
private fun sha256OfFile(path: Path): String {
val md = MessageDigest.getInstance("SHA-256")
BufferedInputStream(Files.newInputStream(path)).use { input ->
val buf = ByteArray(64 * 1024)
while (true) {
val n = input.read(buf)
if (n <= 0) break
md.update(buf, 0, n)
}
}
return md.digest().joinToString("") { "%02x".format(it) }
}
private fun decompressInto(archive: Path, target: Path, ext: String) {
Files.createDirectories(target.parent)
when (ext) {
"bz2" -> decompressBzip2(archive, target)
"zip" -> decompressZip(archive, target)
else -> error("unsupported archive ext: $ext")
}
}
/** Minimal bz2 → file decoder using Apache Commons Compress's algorithm
* via JDK's built-in classes? No such thing exists in JDK; we shell out
* to bzcat instead, which is universally available on Linux/macOS. */
private fun decompressBzip2(archive: Path, target: Path) {
// Try Java decoder if Commons Compress shaded in; otherwise fall back.
try {
val cls = Class.forName("org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream")
val ctor = cls.getConstructor(java.io.InputStream::class.java)
Files.newInputStream(archive).use { src ->
val bzin = ctor.newInstance(src) as java.io.InputStream
Files.newOutputStream(target).use { out -> bzin.copyTo(out) }
}
return
} catch (_: ClassNotFoundException) {
// continue to fallback
}
val p = ProcessBuilder("bzcat", archive.toString()).redirectError(ProcessBuilder.Redirect.INHERIT).start()
Files.newOutputStream(target).use { out -> p.inputStream.copyTo(out) }
if (!p.waitFor(60, TimeUnit.SECONDS) || p.exitValue() != 0) {
throw IOException("bzcat failed to decompress restic; install commons-compress or bzip2")
}
}
private fun decompressZip(archive: Path, target: Path) {
java.util.zip.ZipInputStream(Files.newInputStream(archive)).use { zin ->
var e = zin.nextEntry
while (e != null) {
if (!e.isDirectory && e.name.endsWith("restic.exe")) {
Files.newOutputStream(target).use { out -> zin.copyTo(out) }
return
}
e = zin.nextEntry
}
}
throw IOException("restic.exe not found in downloaded zip")
}
private fun makeExecutable(path: Path) {
if (isWindows()) return
try {
val perms = setOf(OWNER_READ, OWNER_WRITE, OWNER_EXECUTE)
Files.setPosixFilePermissions(path, perms)
} catch (_: UnsupportedOperationException) {
// non-POSIX FS — best effort
}
}
}
@@ -1,80 +0,0 @@
package center.timemachine.cloud
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.exists
/**
* Per-distribution scope file. Each cloud-sync.jar deployment ships its
* own scope.json that picks which files participate in sync. Path under
* <pack-folder>/.cloud-sync/scope.json.
*
* Defaults are baked in so a fresh install with no scope.json still does
* something sensible.
*/
@Serializable
data class Scope(
val include: List<String> = DEFAULT_INCLUDE,
val exclude: List<String> = DEFAULT_EXCLUDE,
)
private val DEFAULT_INCLUDE = listOf(
"options.txt",
"optionsof.txt",
"optionsshaders.txt",
"config/",
"journeymap/data/",
"screenshots/",
)
private val DEFAULT_EXCLUDE = listOf(
".cloud-sync/", // never sync our own state dir
".cloud-token", // legacy location (frazclient pre-jar era)
"config/simple-mod-sync*",
"config/packwiz*",
"**/cache/",
"**/*.log",
"**/*.tmp",
)
object Scope_ {
private val json = Json { ignoreUnknownKeys = true; prettyPrint = true }
fun load(packFolder: Path): Scope {
val path = packFolder.resolve(".cloud-sync").resolve("scope.json")
if (!path.exists()) return Scope()
return try {
json.decodeFromString(Scope.serializer(), Files.readString(path))
} catch (e: Exception) {
System.err.println("cloud-sync: invalid scope.json (${e.message}); using defaults")
Scope()
}
}
/**
* Write absolute paths of include + a restic --exclude-file alongside.
* restic's --files-from accepts ABSOLUTE OR relative paths but exclude
* patterns are matched against the file path being processed. Including
* directories in --files-from causes restic to recurse into them
* automatically.
*/
fun materializeForRestic(packFolder: Path, scope: Scope): Pair<Path, Path> {
val dir = packFolder.resolve(".cloud-sync")
Files.createDirectories(dir)
val filesFrom = dir.resolve("files-from.txt")
val excludeFrom = dir.resolve("exclude-from.txt")
Files.writeString(
filesFrom,
scope.include.joinToString(System.lineSeparator()) { trimTrailingSlash(it) },
)
Files.writeString(
excludeFrom,
scope.exclude.joinToString(System.lineSeparator()),
)
return filesFrom to excludeFrom
}
private fun trimTrailingSlash(s: String) = if (s.endsWith("/")) s.dropLast(1) else s
}
@@ -1,145 +0,0 @@
package center.timemachine.cloud
import java.io.IOException
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.exists
/**
* Read token-file. Format: "discord_id:password" on a single line. Whitespace
* tolerated. Returns (discordId, password). Throws if missing or malformed.
*
* The Discord ID is the URL path segment under cloud.tm.center/<id>/ that
* restic-rest-server's --private-repos enforces against the basic-auth
* username. The password is the bcrypt'd entry's plaintext.
*/
fun readCredentials(tokenFile: Path): Pair<String, String> {
if (!tokenFile.exists()) {
throw IOException(
"cloud-sync token not found at $tokenFile. " +
"After /register in Discord you should have received credentials; " +
"paste them into this file as 'discord_id:password' on one line."
)
}
val raw = Files.readString(tokenFile).trim()
val parts = raw.split(":", limit = 2)
if (parts.size != 2 || parts[0].isBlank() || parts[1].isBlank()) {
throw IOException("cloud-sync token at $tokenFile malformed (expected 'discord_id:password')")
}
return parts[0].trim() to parts[1].trim()
}
/**
* Build the restic --repo URL: rest:<scheme>://<discord_id>:<password>@<host>/<discord_id>/
*
* URL-embedded basic auth is universally supported by restic; the alternative
* (RESTIC_REST_USERNAME / RESTIC_REST_PASSWORD env vars) requires restic 0.16+.
* Same password is used for HTTP basic auth and restic repo encryption —
* cloud-svc provisions one password per user covering both.
*/
private fun resticRepo(baseUrl: String, discordId: String, password: String): String {
val raw = baseUrl.trimStart().removePrefix("rest:").trimEnd('/')
val schemeEnd = raw.indexOf("://")
require(schemeEnd > 0) { "--url must include a scheme (http:// or https://): $baseUrl" }
val scheme = raw.substring(0, schemeEnd + 3)
val hostAndPath = raw.substring(schemeEnd + 3)
// URL-encode credentials to handle special chars in the password
val u = java.net.URLEncoder.encode(discordId, Charsets.UTF_8)
val p = java.net.URLEncoder.encode(password, Charsets.UTF_8)
return "rest:$scheme$u:$p@$hostAndPath/$discordId/"
}
/** Common env applied to every restic invocation. */
private fun resticEnv(password: String): Map<String, String> = mapOf(
"RESTIC_PASSWORD" to password,
// Defensive: ensure restic doesn't try to be interactive about user prompts.
"RESTIC_PROGRESS_FPS" to "0",
)
/**
* pull: restore latest snapshot's tracked files into <pack-folder>.
*
* If the repo has no snapshots yet, this is a no-op (the user has never
* pushed; nothing to restore). We detect that via `restic snapshots --json`
* before attempting restore — restore on an empty repo errors out.
*/
fun pull(args: Args): Int {
val (discordId, password) = readCredentials(args.tokenFile)
val binary = Restic.resolveBinary(args)
val repo = resticRepo(args.url, discordId, password)
val env = resticEnv(password)
// Check whether any snapshots exist
val (snapCode, snapOut) = Restic.run(
binary,
listOf("-r", repo, "snapshots", "--json", "--latest", "1"),
env,
)
if (snapCode != 0) {
System.err.println("cloud-sync: failed to list snapshots (restic exit $snapCode)")
return 2
}
// restic returns "null" or "[]" when repo is empty
val empty = snapOut.trim().let { it.isEmpty() || it == "null" || it == "[]" }
if (empty) {
println("cloud-sync: no snapshots yet for this user (first run on this machine?); nothing to pull")
return 0
}
val scope = Scope_.load(args.packFolder)
val (_, excludeFrom) = Scope_.materializeForRestic(args.packFolder, scope)
// Restore overwrites files. Use --include for path filter (paths inside
// the snapshot are absolute as they were on the backup machine). We
// restore EVERYTHING in the snapshot to <pack-folder>; the snapshot was
// built from <pack-folder> + scope, so all paths come back as expected.
val (rcCode, _) = Restic.run(
binary,
listOf(
"-r", repo,
"restore", "latest",
"--target", args.packFolder.toString(),
"--exclude-file", excludeFrom.toString(),
),
env,
)
if (rcCode != 0) {
System.err.println("cloud-sync: restic restore failed with exit $rcCode")
return 2
}
println("cloud-sync: pull ok")
return 0
}
/**
* push: snapshot the in-scope files into the user's repo.
*/
fun push(args: Args): Int {
val (discordId, password) = readCredentials(args.tokenFile)
val binary = Restic.resolveBinary(args)
val repo = resticRepo(args.url, discordId, password)
val env = resticEnv(password)
val scope = Scope_.load(args.packFolder)
val (filesFrom, excludeFrom) = Scope_.materializeForRestic(args.packFolder, scope)
val (rcCode, _) = Restic.run(
binary,
listOf(
"-r", repo,
"backup",
"--files-from", filesFrom.toString(),
"--exclude-file", excludeFrom.toString(),
"--host", "cloud-sync",
"--tag", "auto",
),
env,
cwd = args.packFolder,
)
if (rcCode != 0) {
System.err.println("cloud-sync: restic backup failed with exit $rcCode")
return 2
}
println("cloud-sync: push ok")
return 0
}
@@ -1,93 +0,0 @@
package center.timemachine.cloud
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class ArgsTest {
@Test
fun `parses required url and applies defaults`() {
val a = parseArgs(arrayOf("--url=https://cloud.tm.center"))
assertEquals("https://cloud.tm.center", a.url)
assertTrue(a.allowDownload)
assertFalse(a.headless)
// default token file lives under <pack-folder>/.cloud-sync/token
assertTrue(a.tokenFile.endsWith(".cloud-sync/token"))
}
@Test
fun `url required`() {
val ex = assertFailsWith<ArgParseException> { parseArgs(emptyArray()) }
assertTrue(ex.message!!.contains("--url"))
}
@Test
fun `space-separated values work`() {
val a = parseArgs(arrayOf("--url", "https://x", "--pack-folder", "/srv/mc"))
assertEquals("https://x", a.url)
assertEquals("/srv/mc", a.packFolder.toString())
}
@Test
fun `inline values work`() {
val a = parseArgs(arrayOf("--url=https://x", "--pack-folder=/srv/mc"))
assertEquals("https://x", a.url)
assertEquals("/srv/mc", a.packFolder.toString())
}
@Test
fun `no-gui flag`() {
val a = parseArgs(arrayOf("--url=https://x", "-g"))
assertTrue(a.headless)
val b = parseArgs(arrayOf("--url=https://x", "--no-gui"))
assertTrue(b.headless)
}
@Test
fun `no-download flag disables fetch`() {
val a = parseArgs(arrayOf("--url=https://x", "--no-download"))
assertFalse(a.allowDownload)
}
@Test
fun `unknown flag rejected`() {
val ex = assertFailsWith<ArgParseException> {
parseArgs(arrayOf("--url=https://x", "--bogus=foo"))
}
assertTrue(ex.message!!.contains("--bogus"))
}
@Test
fun `bool flag with inline value rejected`() {
val ex = assertFailsWith<ArgParseException> {
parseArgs(arrayOf("--url=https://x", "--no-download=yes"))
}
assertTrue(ex.message!!.contains("does not take a value"))
}
@Test
fun `missing value for non-bool flag rejected`() {
val ex = assertFailsWith<ArgParseException> {
parseArgs(arrayOf("--url=https://x", "--pack-folder"))
}
assertTrue(ex.message!!.contains("requires a value"))
}
@Test
fun `custom token-file overrides default`() {
val a = parseArgs(arrayOf(
"--url=https://x",
"--pack-folder=/srv/mc",
"--token-file=/etc/cloud-creds",
))
assertEquals("/etc/cloud-creds", a.tokenFile.toString())
}
@Test
fun `restic-binary override accepted`() {
val a = parseArgs(arrayOf("--url=https://x", "--restic-binary=/opt/restic"))
assertEquals("/opt/restic", a.resticBinary!!.toString())
}
}
@@ -1,52 +0,0 @@
package center.timemachine.cloud
import java.io.IOException
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class CredentialsTest {
@Test
fun `parses discord_id and password from one-liner`() {
val tmp = Files.createTempFile("token-", "")
Files.writeString(tmp, "358881557521498112:s3cret-pass\n")
val (id, pw) = readCredentials(tmp)
assertEquals("358881557521498112", id)
assertEquals("s3cret-pass", pw)
}
@Test
fun `trims whitespace around components`() {
val tmp = Files.createTempFile("token-", "")
Files.writeString(tmp, " 123 : pw \n")
val (id, pw) = readCredentials(tmp)
assertEquals("123", id)
assertEquals("pw", pw)
}
@Test
fun `missing file raises actionable error`() {
val missing = Files.createTempDirectory("nothing-").resolve("missing-token")
val ex = assertFailsWith<IOException> { readCredentials(missing) }
assertTrue(ex.message!!.contains("token not found"))
assertTrue(ex.message!!.contains("discord_id:password"))
}
@Test
fun `missing colon rejected`() {
val tmp = Files.createTempFile("token-", "")
Files.writeString(tmp, "no-colon-here")
val ex = assertFailsWith<IOException> { readCredentials(tmp) }
assertTrue(ex.message!!.contains("malformed"))
}
@Test
fun `empty id rejected`() {
val tmp = Files.createTempFile("token-", "")
Files.writeString(tmp, ":password")
val ex = assertFailsWith<IOException> { readCredentials(tmp) }
assertTrue(ex.message!!.contains("malformed"))
}
}
@@ -1,21 +0,0 @@
package center.timemachine.cloud
import kotlin.test.Test
import kotlin.test.assertEquals
class SmokeTest {
@Test
fun `cli pull missing url returns 2`() {
assertEquals(2, Cli.runPull(emptyArray()))
}
@Test
fun `cli push missing url returns 2`() {
assertEquals(2, Cli.runPush(emptyArray()))
}
@Test
fun `cli pull unknown flag returns 2`() {
assertEquals(2, Cli.runPull(arrayOf("--url=https://x", "--bogus")))
}
}
+75
View File
@@ -0,0 +1,75 @@
"""CLI parsing tests — argv → (subcommand, Args)."""
from __future__ import annotations
import pytest
from cloud_sync.cli import Args, parse
def test_parses_pull_with_required_url() -> None:
cmd, args = parse(["pull", "--url=https://cloud.tm.center"])
assert cmd == "pull"
assert isinstance(args, Args)
assert args.url == "https://cloud.tm.center"
assert args.allow_download is True
assert args.headless is False
def test_default_token_file_under_pack_folder() -> None:
_, args = parse(["pull", "--url=https://x", "--pack-folder=/tmp/inst"])
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"]
)
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
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
def test_no_download_flag() -> None:
_, a = parse(["push", "--url=https://x", "--no-download"])
assert a.allow_download is False
def test_restic_binary_override() -> None:
_, a = parse(["push", "--url=https://x", "--restic-binary=/opt/restic"])
assert a.restic_binary is not None
assert a.restic_binary.as_posix() == "/opt/restic"
def test_missing_url_exits() -> None:
with pytest.raises(SystemExit):
parse(["pull"])
def test_missing_subcommand_exits() -> None:
with pytest.raises(SystemExit):
parse([])
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()
+62
View File
@@ -0,0 +1,62 @@
"""Token file parser tests."""
from __future__ import annotations
import pytest
from cloud_sync.creds import CredentialsError, read_credentials
def test_parses_one_liner(tmp_path):
t = tmp_path / "token"
t.write_text("358881557521498112:s3cret-pass\n")
discord_id, password = read_credentials(t)
assert discord_id == "358881557521498112"
assert password == "s3cret-pass"
def test_trims_whitespace(tmp_path):
t = tmp_path / "token"
t.write_text(" 123 : pw \n")
discord_id, password = read_credentials(t)
assert discord_id == "123"
assert password == "pw"
def test_missing_file_raises_with_actionable_message(tmp_path):
missing = tmp_path / "missing-token"
with pytest.raises(CredentialsError) as exc:
read_credentials(missing)
assert "token not found" in str(exc.value)
assert "discord_id:password" in str(exc.value)
def test_missing_colon_rejected(tmp_path):
t = tmp_path / "token"
t.write_text("no-colon-here")
with pytest.raises(CredentialsError) as exc:
read_credentials(t)
assert "malformed" in str(exc.value)
def test_empty_id_rejected(tmp_path):
t = tmp_path / "token"
t.write_text(":password")
with pytest.raises(CredentialsError):
read_credentials(t)
def test_empty_password_rejected(tmp_path):
t = tmp_path / "token"
t.write_text("123:")
with pytest.raises(CredentialsError):
read_credentials(t)
def test_password_with_colon_kept_intact(tmp_path):
"""Passwords containing : should be kept whole after the first split."""
t = tmp_path / "token"
t.write_text("123:pw:with:colons")
discord_id, password = read_credentials(t)
assert discord_id == "123"
assert password == "pw:with:colons"
+54
View File
@@ -0,0 +1,54 @@
"""restic repo URL builder + env tests."""
from __future__ import annotations
import pytest
from cloud_sync.sync import _restic_env, _restic_repo
def test_basic_http_url():
repo = _restic_repo("http://cloud.tm.center", "12345", "secretpw")
assert repo == "rest:http://12345:secretpw@cloud.tm.center/12345/"
def test_https_url():
repo = _restic_repo("https://cloud.tm.center", "12345", "pw")
assert repo == "rest:https://12345:pw@cloud.tm.center/12345/"
def test_trailing_slash_stripped():
repo = _restic_repo("https://cloud.tm.center/", "12345", "pw")
assert repo == "rest:https://12345:pw@cloud.tm.center/12345/"
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/"
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/"
def test_password_with_special_chars_encoded():
repo = _restic_repo("http://x.test", "u", "p@ss/word?!&")
# URL-encoded form of "p@ss/word?!&"
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")
assert "u%2Fwith%40chars" in repo
def test_missing_scheme_rejected():
with pytest.raises(ValueError):
_restic_repo("cloud.tm.center", "u", "p")
def test_env_contains_password():
env = _restic_env("hunter2")
assert env["RESTIC_PASSWORD"] == "hunter2"
assert "RESTIC_PROGRESS_FPS" in env
+70
View File
@@ -0,0 +1,70 @@
"""Scope file reader + materializer tests."""
from __future__ import annotations
import json
from cloud_sync.scope import (
DEFAULT_EXCLUDE,
DEFAULT_INCLUDE,
Scope,
load,
materialize_for_restic,
)
def test_load_missing_returns_defaults(tmp_path):
s = load(tmp_path)
assert s.include == DEFAULT_INCLUDE
assert s.exclude == DEFAULT_EXCLUDE
def test_load_valid_overrides_defaults(tmp_path):
state = tmp_path / ".cloud-sync"
state.mkdir()
(state / "scope.json").write_text(json.dumps({
"include": ["foo/", "bar.txt"],
"exclude": ["**/*.log"],
}))
s = load(tmp_path)
assert s.include == ["foo/", "bar.txt"]
assert s.exclude == ["**/*.log"]
def test_load_partial_keeps_defaults_for_missing(tmp_path):
state = tmp_path / ".cloud-sync"
state.mkdir()
(state / "scope.json").write_text(json.dumps({"include": ["just-this"]}))
s = load(tmp_path)
assert s.include == ["just-this"]
assert s.exclude == DEFAULT_EXCLUDE
def test_load_invalid_falls_back(tmp_path, capsys):
state = tmp_path / ".cloud-sync"
state.mkdir()
(state / "scope.json").write_text("{not valid json")
s = load(tmp_path)
assert s.include == DEFAULT_INCLUDE
captured = capsys.readouterr()
assert "invalid" in captured.err.lower()
def test_materialize_writes_files(tmp_path):
scope = Scope(include=["config/", "options.txt"], exclude=["**/*.log"])
files_from, exclude_from = materialize_for_restic(tmp_path, scope)
assert files_from.exists()
assert exclude_from.exists()
body_in = files_from.read_text().splitlines()
body_ex = exclude_from.read_text().splitlines()
# trailing slash stripped on include entries
assert "config" in body_in
assert "options.txt" in body_in
assert "**/*.log" in body_ex
def test_materialize_creates_state_dir(tmp_path):
scope = Scope(include=["x"], exclude=["y"])
files_from, _ = materialize_for_restic(tmp_path, scope)
assert files_from.parent.name == ".cloud-sync"
assert files_from.parent.exists()