pivot to Python: replace Kotlin/JVM with stdlib zipapp
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:
+30
-25
@@ -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
|
||||
curl -sS -X POST \
|
||||
-H "Authorization: token ${gh_token}" \
|
||||
-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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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 17–21 (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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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__"]
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
-7
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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 +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")))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user