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]
|
branches: [main]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 10
|
||||||
container:
|
strategy:
|
||||||
image: docker.io/gradle:8.10.2-jdk21
|
matrix:
|
||||||
|
python: ["3.10", "3.11", "3.12"]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
build-pyz:
|
||||||
run: gradle --no-daemon shadowJar
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
- name: Test
|
if: github.event_name == 'push'
|
||||||
run: gradle --no-daemon test
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
- name: Stash jar for release
|
- uses: actions/setup-python@v5
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- run: make build
|
||||||
|
- if: startsWith(github.ref, 'refs/tags/v')
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: cloud-sync-jar
|
name: cloud-sync-pyz
|
||||||
path: build/libs/cloud-sync-*.jar
|
path: cloud-sync.pyz
|
||||||
|
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build
|
needs: build-pyz
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: cloud-sync-jar
|
name: cloud-sync-pyz
|
||||||
path: build/libs/
|
|
||||||
|
|
||||||
- name: Publish release
|
- name: Publish release
|
||||||
run: |
|
run: |
|
||||||
gh_token="${{ secrets.RELEASE_TOKEN }}"
|
gh_token="${{ secrets.RELEASE_TOKEN }}"
|
||||||
@@ -51,9 +58,7 @@ jobs:
|
|||||||
-d "{\"tag_name\":\"${tag}\",\"name\":\"${tag}\",\"draft\":false,\"prerelease\":false}" \
|
-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
|
"${GITEA_SERVER_URL}/api/v1/repos/${{ gitea.event.repository.full_name }}/releases" > /tmp/release.json
|
||||||
release_id=$(jq -r .id /tmp/release.json)
|
release_id=$(jq -r .id /tmp/release.json)
|
||||||
for jar in build/libs/cloud-sync-*.jar; do
|
curl -sS -X POST \
|
||||||
curl -sS -X POST \
|
-H "Authorization: token ${gh_token}" \
|
||||||
-H "Authorization: token ${gh_token}" \
|
-F "attachment=@cloud-sync.pyz" \
|
||||||
-F "attachment=@${jar}" \
|
"${GITEA_SERVER_URL}/api/v1/repos/${{ gitea.event.repository.full_name }}/releases/${release_id}/assets?name=cloud-sync.pyz"
|
||||||
"${GITEA_SERVER_URL}/api/v1/repos/${{ gitea.event.repository.full_name }}/releases/${release_id}/assets?name=$(basename $jar)"
|
|
||||||
done
|
|
||||||
|
|||||||
+8
-4
@@ -1,8 +1,12 @@
|
|||||||
.gradle/
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
.venv/
|
||||||
|
*.egg-info/
|
||||||
build/
|
build/
|
||||||
out/
|
dist/
|
||||||
*.iml
|
cloud-sync.pyz
|
||||||
|
.coverage
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.swp
|
*.swp
|
||||||
local.properties
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# cloud-sync — design
|
# 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.
|
**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.
|
**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
|
## Why this shape
|
||||||
|
|
||||||
@@ -29,8 +29,8 @@ flowchart LR
|
|||||||
pl["player PC"]:::external
|
pl["player PC"]:::external
|
||||||
op["operator
|
op["operator
|
||||||
(via SSH)"]:::external
|
(via SSH)"]:::external
|
||||||
jar["cloud-sync.jar
|
jar["cloud-sync.pyz
|
||||||
(in launcher's
|
(Python; in launcher's
|
||||||
pre/post hooks)"]:::deploy
|
pre/post hooks)"]:::deploy
|
||||||
restic["restic binary
|
restic["restic binary
|
||||||
(auto-downloaded
|
(auto-downloaded
|
||||||
@@ -115,7 +115,7 @@ Revocation = operator runs `automc-setup cloud revoke <discord_id>` which hits t
|
|||||||
|
|
||||||
## On-disk layout (client)
|
## 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>/
|
<pack-folder>/
|
||||||
@@ -145,21 +145,21 @@ Probed in order:
|
|||||||
|
|
||||||
### Jar placement
|
### 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
|
## 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)
|
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
|
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)
|
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
|
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
|
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`
|
- 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
|
- 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
|
- Auto-download restic binary on first run from upstream GitHub release
|
||||||
- Server-side nightly prune cron with operator-side master password key
|
- 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.
|
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`
|
## 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 |
|
| 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/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/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. |
|
| `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] cloud-svc reshapes to control plane, not archived
|
||||||
- [x] Two-port split — automc-net for provisioning, loopback for operator
|
- [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] 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] 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.
|
- [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
|
# 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
|
## 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).
|
||||||
|
|
||||||
```
|
```bash
|
||||||
Pre-launch command:
|
# build single-file zipapp
|
||||||
"$INST_JAVA" -jar /path/to/cloud-sync.jar pull \
|
make build # → cloud-sync.pyz (~53 KB)
|
||||||
--url=https://cloud.timemachine.center \
|
|
||||||
--pack-folder=$INST_MC_DIR
|
|
||||||
|
|
||||||
Post-exit command:
|
# or pip-install
|
||||||
"$INST_JAVA" -jar /path/to/cloud-sync.jar push \
|
make install # pip install -e .
|
||||||
--url=https://cloud.timemachine.center \
|
|
||||||
--pack-folder=$INST_MC_DIR
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
## CLI
|
||||||
|
|
||||||
```
|
```
|
||||||
java -jar cloud-sync.jar <subcommand> [flags]
|
python cloud-sync.pyz {pull,push} \
|
||||||
|
--url URL cloud-svc data plane URL (required)
|
||||||
Subcommands:
|
--pack-folder PATH Minecraft instance directory (default: cwd)
|
||||||
pull Fetch user's cloud state, apply conflict resolution, write to instance.
|
--token-file PATH override default <pack-folder>/.cloud-sync/token
|
||||||
push Walk instance, build snapshot, upload changed files.
|
--restic-binary PATH skip auto-discovery
|
||||||
|
--no-download fail if no usable restic; don't fetch from upstream
|
||||||
Flags:
|
-g, --no-gui headless mode
|
||||||
--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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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
|
cloud_sync.pull(cloud_sync.Args(
|
||||||
podman run --rm -v "$PWD":/work:Z -w /work docker.io/gradle:8.10.2-jdk21 \
|
url="https://cloud.tm.center",
|
||||||
gradle --no-daemon shadowJar
|
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
|
Per-instance state under `<pack-folder>/.cloud-sync/`:
|
||||||
./gradlew shadowJar
|
|
||||||
|
```
|
||||||
|
.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 |
|
## Where the data lives
|
||||||
|---|---|
|
|
||||||
| **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 |
|
|
||||||
|
|
||||||
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
|
## 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