Spaces:
Running
Running
| # ============================================================================= | |
| # sync-server.sh — Bidirectional sync between MC server and HuggingFace Dataset | |
| # | |
| # Modes: | |
| # pull — Download files from HF dataset to local server directory | |
| # push — Upload local server files to HF dataset | |
| # first-boot — Check if dataset is empty; if so, push defaults after server init | |
| # watch — Background loop: pull configs every 60s, push world every 5min | |
| # plugins — Download plugin JARs listed in plugin-urls.txt | |
| # | |
| # Required env vars: | |
| # HF_DATASET_ID — defaults to "applepie69/MCP-Server" (private) | |
| # HF_TOKEN — HuggingFace write token (set as Space secret) | |
| # | |
| # Optional env vars: | |
| # SERVER_DIR — MC server directory (default: /server) | |
| # SYNC_BRANCH — Git branch in dataset repo (default: main) | |
| # | |
| # Plugin installation: | |
| # Add a file called "plugin-urls.txt" to the dataset root. | |
| # Each line should be a direct download URL to a .jar file. | |
| # On pull/startup, the script downloads them to SERVER_DIR/plugins/. | |
| # Example plugin-urls.txt: | |
| # https://github.com/ViaVersion/ViaVersion/releases/download/5.9.1/ViaVersion-5.9.1.jar | |
| # https://example.com/MyPlugin.jar | |
| # | |
| # MC Console Commands: | |
| # Commands are sent via FIFO at /tmp/mc-console (created by start.sh). | |
| # This is the CORRECT way to send commands — socat to port 25565 does NOT | |
| # work because MC's game protocol is not plain text. | |
| # ============================================================================= | |
| set -uo pipefail | |
| # NOTE: removed 'set -e' to prevent sync failures from killing the watcher | |
| SERVER_DIR="${SERVER_DIR:-/server}" | |
| PROXY_DIR="${PROXY_DIR:-/proxy}" | |
| SYNC_BRANCH="${SYNC_BRANCH:-main}" | |
| DATASET_REPO="${HF_DATASET_ID:-applepie69/MCP-Server}" | |
| HF_TOKEN="${HF_TOKEN:?HF_TOKEN is not set}" | |
| SYNC_DIR="/tmp/hf-dataset-sync" | |
| MC_CONSOLE="/tmp/mc-console" | |
| PROXY_CONSOLE="/tmp/proxy-console" | |
| # Files/dirs to sync (relative to SERVER_DIR) | |
| CONFIG_FILES=( | |
| "server.properties" | |
| "ops.json" | |
| "whitelist.json" | |
| "banned-players.json" | |
| "banned-ips.json" | |
| "bukkit.yml" | |
| "spigot.yml" | |
| "paper-global.yml" | |
| "paper-world-defaults.yml" | |
| "commands.yml" | |
| "help.yml" | |
| ) | |
| CONFIG_DIRS=( | |
| "plugins" | |
| ) | |
| WORLD_DIRS=( | |
| "world" | |
| "world_nether" | |
| "world_the_end" | |
| ) | |
| # Files to exclude from plugin dir sync (rejected by HF even with LFS) | |
| # .db/.sqlite files are synced separately via the HF API (sync-binaries.py) | |
| PLUGIN_EXCLUDES=( | |
| '--exclude=*.jar' | |
| '--exclude=*.zip' | |
| '--exclude=*.db' | |
| '--exclude=*.db-journal' | |
| '--exclude=*.db-wal' | |
| '--exclude=*.db-shm' | |
| '--exclude=*.sqlite' | |
| '--exclude=*.sqlite-journal' | |
| '--exclude=*.sqlite-wal' | |
| '--exclude=*.sqlite-shm' | |
| '--exclude=spark/tmp/' | |
| ) | |
| # Files at server root that should be synced (not in any dir category) | |
| ROOT_SYNC_FILES=( | |
| "plugin-urls.txt" | |
| "playit-secret.txt" | |
| ) | |
| # ─── Proxy sync files (relative to PROXY_DIR) ──────────────────────────────── | |
| # BungeeCord proxy config + EaglerXServer plugin configs | |
| PROXY_CONFIG_FILES=( | |
| "config.yml" | |
| ) | |
| PROXY_CONFIG_DIRS=( | |
| "plugins" | |
| ) | |
| # Excludes for proxy plugin dir (same binary restrictions as backend) | |
| PROXY_PLUGIN_EXCLUDES=( | |
| '--exclude=*.jar' | |
| '--exclude=*.zip' | |
| '--exclude=*.db' | |
| '--exclude=*.db-journal' | |
| '--exclude=*.db-wal' | |
| '--exclude=*.db-shm' | |
| '--exclude=*.sqlite' | |
| '--exclude=*.sqlite-journal' | |
| '--exclude=*.sqlite-wal' | |
| '--exclude=*.sqlite-shm' | |
| ) | |
| # Colors for logging | |
| RED='\033[0;31m' | |
| GREEN='\033[0;32m' | |
| YELLOW='\033[1;33m' | |
| CYAN='\033[0;36m' | |
| NC='\033[0m' | |
| log() { echo -e "${GREEN}[SYNC]${NC} $*"; } | |
| warn() { echo -e "${YELLOW}[SYNC:WARN]${NC} $*"; } | |
| error() { echo -e "${RED}[SYNC:ERR]${NC} $*"; } | |
| info() { echo -e "${CYAN}[SYNC:INFO]${NC} $*"; } | |
| # ─── Console command helpers ──────────────────────────────────────────────── | |
| # Sends a command to the Paper backend console via the FIFO. | |
| mc_cmd() { | |
| local cmd="${1:-}" | |
| if [ -z "${cmd}" ]; then | |
| return 1 | |
| fi | |
| if [ -p "${MC_CONSOLE}" ]; then | |
| echo "${cmd}" > "${MC_CONSOLE}" 2>/dev/null | |
| else | |
| warn "MC console FIFO not available — command not sent: ${cmd}" | |
| return 1 | |
| fi | |
| } | |
| # Sends a command to the BungeeCord proxy console via the FIFO. | |
| proxy_cmd() { | |
| local cmd="${1:-}" | |
| if [ -z "${cmd}" ]; then | |
| return 1 | |
| fi | |
| if [ -p "${PROXY_CONSOLE}" ]; then | |
| echo "${cmd}" > "${PROXY_CONSOLE}" 2>/dev/null | |
| else | |
| warn "Proxy console FIFO not available — command not sent: ${cmd}" | |
| return 1 | |
| fi | |
| } | |
| # ─── Plugin Download ───────────────────────────────────────────────────────── | |
| download_plugins() { | |
| # Download plugin JARs from URLs listed in plugin-urls.txt | |
| # This avoids pushing binary .jar files to the dataset | |
| local urls_file="${1:-${SERVER_DIR}/plugin-urls.txt}" | |
| # Also check dataset clone dir as fallback | |
| if [ ! -f "${urls_file}" ] && [ -f "${SYNC_DIR}/plugin-urls.txt" ]; then | |
| urls_file="${SYNC_DIR}/plugin-urls.txt" | |
| fi | |
| if [ ! -f "${urls_file}" ]; then | |
| info "No plugin-urls.txt found — skipping plugin downloads." | |
| return 0 | |
| fi | |
| local plugin_dir="${SERVER_DIR}/plugins" | |
| mkdir -p "${plugin_dir}" | |
| local count=0 | |
| local failed=0 | |
| while IFS= read -r url || [ -n "${url}" ]; do | |
| # Skip empty lines and comments | |
| [[ -z "${url}" || "${url}" =~ ^[[:space:]]*# ]] && continue | |
| # Trim whitespace | |
| url=$(echo "${url}" | xargs) | |
| # Extract filename from URL (strip query string first) | |
| local filename | |
| filename=$(echo "${url}" | sed 's/?.*//' | xargs basename) | |
| local dest="${plugin_dir}/${filename}" | |
| # Check if already downloaded (skip if file exists and is non-empty) | |
| if [ -f "${dest}" ] && [ -s "${dest}" ]; then | |
| info " Plugin already exists: ${filename}" | |
| count=$((count+1)) | |
| continue | |
| fi | |
| info " Downloading plugin: ${filename} from ${url}..." | |
| # Use User-Agent (required by Modrinth/CDN), follow redirects, fail on HTTP errors | |
| if curl -L --fail -o "${dest}" \ | |
| -H "User-Agent: MC-Server-Sync/1.0 (HuggingFace Space)" \ | |
| -H "Accept: application/java-archive,application/octet-stream,*/*" \ | |
| "${url}" 2>&1; then | |
| # Verify it's actually a JAR (not an HTML error page) | |
| if file "${dest}" 2>/dev/null | grep -qi 'zip\|jar\|java'; then | |
| log " Downloaded: ${filename} ($(stat -c%s "${dest}" 2>/dev/null || echo '?') bytes)" | |
| count=$((count+1)) | |
| else | |
| warn " Downloaded file is not a valid JAR: ${filename}" | |
| warn " $(head -c 200 "${dest}" 2>/dev/null)" | |
| rm -f "${dest}" 2>/dev/null | |
| failed=$((failed+1)) | |
| fi | |
| else | |
| warn " Failed to download: ${filename} (curl exit code: $?)" | |
| rm -f "${dest}" 2>/dev/null | |
| failed=$((failed+1)) | |
| fi | |
| done < "${urls_file}" | |
| if [ ${count} -gt 0 ] || [ ${failed} -gt 0 ]; then | |
| log "Plugins: ${count} OK, ${failed} failed" | |
| fi | |
| } | |
| # ─── HF Dataset Operations ─────────────────────────────────────────────────── | |
| clone_dataset() { | |
| log "Cloning dataset ${DATASET_REPO}..." | |
| rm -rf "${SYNC_DIR}" | |
| mkdir -p "${SYNC_DIR}" | |
| if ! git clone --depth 1 --branch "${SYNC_BRANCH}" \ | |
| "https://x-access-token:${HF_TOKEN}@huggingface.co/datasets/${DATASET_REPO}" \ | |
| "${SYNC_DIR}" 2>&1; then | |
| # Branch might not exist yet; try without branch | |
| warn "Branch '${SYNC_BRANCH}' not found, cloning default branch..." | |
| if ! git clone --depth 1 \ | |
| "https://x-access-token:${HF_TOKEN}@huggingface.co/datasets/${DATASET_REPO}" \ | |
| "${SYNC_DIR}" 2>&1; then | |
| error "Failed to clone dataset. Check HF_DATASET_ID and HF_TOKEN." | |
| return 1 | |
| fi | |
| fi | |
| # Configure git identity and LFS | |
| cd "${SYNC_DIR}" | |
| git config user.email "mc-server-sync@huggingface.co" | |
| git config user.name "MC Server Sync" | |
| # Initialize git-lfs and set up tracking for binary files | |
| git lfs install 2>/dev/null || true | |
| # Clean up old .db/.sqlite entries from .gitattributes — HF rejects these even with LFS | |
| # They are now synced via sync-binaries.py (HF API) instead | |
| if [ -f .gitattributes ] && grep -q '\.db\|\.sqlite' .gitattributes 2>/dev/null; then | |
| info "Removing old .db/.sqlite LFS entries from .gitattributes..." | |
| sed -i '/\.db/d' .gitattributes | |
| sed -i '/\.sqlite/d' .gitattributes | |
| # Also remove the comment line if it exists | |
| sed -i '/Plugin database files/d' .gitattributes | |
| git add .gitattributes | |
| info "Cleaned up .gitattributes (removed .db/.sqlite LFS entries)" | |
| fi | |
| # Create .gitattributes if it doesn't exist to track binary MC world files with LFS | |
| # NOTE: .db/.sqlite files are NOT tracked here — HF rejects them even with LFS. | |
| # They are synced separately via sync-binaries.py (HF API). | |
| if [ ! -f .gitattributes ] || ! grep -q '\.mca' .gitattributes 2>/dev/null; then | |
| cat >> .gitattributes <<'GATTR' | |
| # Minecraft world binary files — use Git LFS | |
| *.mca filter=lfs diff=lfs merge=lfs -text | |
| *.dat filter=lfs diff=lfs merge=lfs -text | |
| *.dat_old filter=lfs diff=lfs merge=lfs -text | |
| *.jfr filter=lfs diff=lfs merge=lfs -text | |
| *.so filter=lfs diff=lfs merge=lfs -text | |
| *.so.tmp filter=lfs diff=lfs merge=lfs -text | |
| GATTR | |
| git add .gitattributes | |
| info "Added .gitattributes for LFS tracking (world files only — .db synced via API)" | |
| fi | |
| cd - > /dev/null | |
| log "Dataset cloned successfully." | |
| } | |
| dataset_is_empty() { | |
| [ ! -d "${SYNC_DIR}" ] && return 0 | |
| # Check if there are any tracked files (besides .gitattributes / README) | |
| local files | |
| files=$(cd "${SYNC_DIR}" && git ls-files | grep -v -E '^\.gitattributes$|^README\.md$' || true) | |
| [ -z "${files}" ] | |
| } | |
| push_to_dataset() { | |
| local commit_msg="${1:-Sync server files}" | |
| cd "${SYNC_DIR}" | |
| # STEP 1: Commit .gitattributes FIRST so LFS tracking is active | |
| # before any binary files are staged | |
| if [ -f .gitattributes ]; then | |
| git add .gitattributes | |
| if ! git diff --cached --quiet; then | |
| git commit -m "Update .gitattributes for LFS" || true | |
| info "Committed .gitattributes first (LFS setup)" | |
| fi | |
| fi | |
| # STEP 1b: Remove binary files that HF rejects from git tracking | |
| # (.db, .sqlite, .jar, .zip — even with LFS, HF rejects these via git push) | |
| # These are now synced via sync-binaries.py (HF API) or plugin-urls.txt instead | |
| local removed_binaries=0 | |
| for pattern in '*.db' '*.db-journal' '*.db-wal' '*.db-shm' '*.sqlite' '*.sqlite-journal' '*.sqlite-wal' '*.sqlite-shm' '*.jar' '*.zip'; do | |
| while IFS= read -r tracked_file; do | |
| if [ -n "${tracked_file}" ]; then | |
| # Don't remove files inside world dirs (they need LFS and are OK) | |
| if ! echo "${tracked_file}" | grep -qE '^(world|world_nether|world_the_end)/'; then | |
| git rm --cached -f "${tracked_file}" 2>/dev/null || true | |
| rm -f "${tracked_file}" 2>/dev/null || true | |
| info "Removed binary from git: ${tracked_file}" | |
| removed_binaries=$((removed_binaries+1)) | |
| fi | |
| fi | |
| done < <(git ls-files -- "${pattern}" 2>/dev/null) | |
| done | |
| if [ ${removed_binaries} -gt 0 ]; then | |
| git commit -m "Remove binary files (now synced via HF API)" --allow-empty || true | |
| info "Committed removal of ${removed_binaries} binary files from git" | |
| fi | |
| # STEP 2: Now stage everything else | |
| git add -A | |
| # Check if there's anything to commit | |
| if git diff --cached --quiet; then | |
| info "No changes to push." | |
| cd - > /dev/null | |
| return 0 | |
| fi | |
| git commit -m "${commit_msg}" --allow-empty | |
| # Push with retry — show actual errors for debugging | |
| local retries=3 | |
| local i=0 | |
| while [ $i -lt $retries ]; do | |
| info "Push attempt $((i+1))/${retries}..." | |
| if git push origin HEAD:"${SYNC_BRANCH}" 2>&1; then | |
| log "Pushed to dataset: ${commit_msg}" | |
| cd - > /dev/null | |
| return 0 | |
| fi | |
| warn "Push failed, retrying ($((i+1))/${retries})..." | |
| # Try pulling remote changes first in case of conflict | |
| git pull --rebase origin "${SYNC_BRANCH}" 2>&1 || true | |
| sleep 5 | |
| i=$((i+1)) | |
| done | |
| error "Failed to push after ${retries} retries." | |
| cd - > /dev/null | |
| return 1 | |
| } | |
| # ─── File Copy Helpers ─────────────────────────────────────────────────────── | |
| # Copy files FROM dataset TO server directory | |
| pull_files() { | |
| local src="${SYNC_DIR}" | |
| local dst="${SERVER_DIR}" | |
| local pulled=0 | |
| log "Pulling files from dataset to server..." | |
| # Config files | |
| for f in "${CONFIG_FILES[@]}"; do | |
| if [ -f "${src}/${f}" ]; then | |
| mkdir -p "$(dirname "${dst}/${f}")" | |
| cp -f "${src}/${f}" "${dst}/${f}" | |
| info " Pulled: ${f}" | |
| pulled=$((pulled+1)) | |
| fi | |
| done | |
| # Config directories (plugin configs — skip .jar/.zip and spark temps) | |
| # .db/.sqlite files are NOT pulled here — they are pulled via HF API below | |
| for d in "${CONFIG_DIRS[@]}"; do | |
| if [ -d "${src}/${d}" ]; then | |
| mkdir -p "${dst}/${d}" | |
| if command -v rsync &>/dev/null; then | |
| rsync -a "${PLUGIN_EXCLUDES[@]}" "${src}/${d}/" "${dst}/${d}/" | |
| else | |
| cp -rf "${src}/${d}/." "${dst}/${d}/" | |
| find "${dst}/${d}" -name '*.jar' -delete 2>/dev/null || true | |
| find "${dst}/${d}" -name '*.zip' -delete 2>/dev/null || true | |
| find "${dst}/${d}" -name '*.db' -delete 2>/dev/null || true | |
| find "${dst}/${d}" -name '*.db-journal' -delete 2>/dev/null || true | |
| find "${dst}/${d}" -name '*.db-wal' -delete 2>/dev/null || true | |
| find "${dst}/${d}" -name '*.db-shm' -delete 2>/dev/null || true | |
| find "${dst}/${d}" -name '*.sqlite' -delete 2>/dev/null || true | |
| find "${dst}/${d}" -name '*.sqlite-journal' -delete 2>/dev/null || true | |
| find "${dst}/${d}" -name '*.sqlite-wal' -delete 2>/dev/null || true | |
| find "${dst}/${d}" -name '*.sqlite-shm' -delete 2>/dev/null || true | |
| rm -rf "${dst}/${d}/spark/tmp" 2>/dev/null || true | |
| fi | |
| info " Pulled dir: ${d}/" | |
| pulled=$((pulled+1)) | |
| fi | |
| done | |
| # Binary files (.db, .sqlite) — pulled via HF API separately | |
| # This is safe at startup because the server hasn't started yet. | |
| info "Pulling binary files via HF API..." | |
| /opt/hf-venv/bin/python3 /sync-binaries.py pull || warn "Binary file pull failed (non-critical)" | |
| # World directories | |
| for d in "${WORLD_DIRS[@]}"; do | |
| if [ -d "${src}/${d}" ]; then | |
| mkdir -p "${dst}/${d}" | |
| if command -v rsync &>/dev/null; then | |
| rsync -a "${src}/${d}/" "${dst}/${d}/" | |
| else | |
| cp -rf "${src}/${d}/." "${dst}/${d}/" | |
| fi | |
| info " Pulled world: ${d}/" | |
| pulled=$((pulled+1)) | |
| fi | |
| done | |
| # Root-level sync files (plugin-urls.txt, etc.) | |
| for f in "${ROOT_SYNC_FILES[@]}"; do | |
| if [ -f "${src}/${f}" ]; then | |
| mkdir -p "$(dirname "${dst}/${f}")" | |
| cp -f "${src}/${f}" "${dst}/${f}" | |
| info " Pulled: ${f}" | |
| pulled=$((pulled+1)) | |
| fi | |
| done | |
| # ─── Proxy files (BungeeCord + EaglerXServer configs) ────────────── | |
| local proxy_src="${SYNC_DIR}/proxy" | |
| local proxy_dst="${PROXY_DIR}" | |
| if [ -d "${proxy_src}" ]; then | |
| info "Pulling proxy files from dataset..." | |
| # Proxy config files (config.yml, etc.) | |
| for f in "${PROXY_CONFIG_FILES[@]}"; do | |
| if [ -f "${proxy_src}/${f}" ]; then | |
| mkdir -p "$(dirname "${proxy_dst}/${f}")" | |
| cp -f "${proxy_src}/${f}" "${proxy_dst}/${f}" | |
| info " Pulled proxy: ${f}" | |
| pulled=$((pulled+1)) | |
| fi | |
| done | |
| # Proxy config directories (EaglerXServer configs, etc.) | |
| for d in "${PROXY_CONFIG_DIRS[@]}"; do | |
| if [ -d "${proxy_src}/${d}" ]; then | |
| mkdir -p "${proxy_dst}/${d}" | |
| if command -v rsync &>/dev/null; then | |
| rsync -a "${PROXY_PLUGIN_EXCLUDES[@]}" "${proxy_src}/${d}/" "${proxy_dst}/${d}/" | |
| else | |
| cp -rf "${proxy_src}/${d}/." "${proxy_dst}/${d}/" | |
| find "${proxy_dst}/${d}" -name '*.jar' -delete 2>/dev/null || true | |
| find "${proxy_dst}/${d}" -name '*.zip' -delete 2>/dev/null || true | |
| find "${proxy_dst}/${d}" -name '*.db' -delete 2>/dev/null || true | |
| find "${proxy_dst}/${d}" -name '*.sqlite' -delete 2>/dev/null || true | |
| fi | |
| info " Pulled proxy dir: ${d}/" | |
| pulled=$((pulled+1)) | |
| fi | |
| done | |
| # Proxy root files (server-icon.png, etc.) | |
| if [ -f "${proxy_src}/server-icon.png" ]; then | |
| cp -f "${proxy_src}/server-icon.png" "${proxy_dst}/server-icon.png" | |
| info " Pulled proxy: server-icon.png" | |
| pulled=$((pulled+1)) | |
| fi | |
| fi | |
| log "Pull complete. ${pulled} items synced." | |
| } | |
| # Copy files FROM server directory TO dataset staging area | |
| push_files() { | |
| local src="${SERVER_DIR}" | |
| local dst="${SYNC_DIR}" | |
| local pushed=0 | |
| log "Staging server files for push..." | |
| # Config files | |
| for f in "${CONFIG_FILES[@]}"; do | |
| if [ -f "${src}/${f}" ]; then | |
| mkdir -p "$(dirname "${dst}/${f}")" | |
| cp -f "${src}/${f}" "${dst}/${f}" | |
| pushed=$((pushed+1)) | |
| fi | |
| done | |
| # Config directories (plugin configs — skip .jar/.zip/.db and spark temps) | |
| # HuggingFace rejects binary files even with LFS — .db/.sqlite synced via HF API | |
| for d in "${CONFIG_DIRS[@]}"; do | |
| if [ -d "${src}/${d}" ]; then | |
| mkdir -p "${dst}/${d}" | |
| if command -v rsync &>/dev/null; then | |
| rsync -a "${PLUGIN_EXCLUDES[@]}" "${src}/${d}/" "${dst}/${d}/" | |
| else | |
| # Fallback: cp everything, then remove excluded files from dst | |
| cp -rf "${src}/${d}/." "${dst}/${d}/" | |
| find "${dst}/${d}" -name '*.jar' -delete 2>/dev/null || true | |
| find "${dst}/${d}" -name '*.zip' -delete 2>/dev/null || true | |
| find "${dst}/${d}" -name '*.db' -delete 2>/dev/null || true | |
| find "${dst}/${d}" -name '*.db-journal' -delete 2>/dev/null || true | |
| find "${dst}/${d}" -name '*.db-wal' -delete 2>/dev/null || true | |
| find "${dst}/${d}" -name '*.db-shm' -delete 2>/dev/null || true | |
| find "${dst}/${d}" -name '*.sqlite' -delete 2>/dev/null || true | |
| find "${dst}/${d}" -name '*.sqlite-journal' -delete 2>/dev/null || true | |
| find "${dst}/${d}" -name '*.sqlite-wal' -delete 2>/dev/null || true | |
| find "${dst}/${d}" -name '*.sqlite-shm' -delete 2>/dev/null || true | |
| rm -rf "${dst}/${d}/spark/tmp" 2>/dev/null || true | |
| fi | |
| pushed=$((pushed+1)) | |
| fi | |
| done | |
| # World directories | |
| for d in "${WORLD_DIRS[@]}"; do | |
| if [ -d "${src}/${d}" ]; then | |
| mkdir -p "${dst}/${d}" | |
| if command -v rsync &>/dev/null; then | |
| rsync -a "${src}/${d}/" "${dst}/${d}/" | |
| else | |
| cp -rf "${src}/${d}/." "${dst}/${d}/" | |
| fi | |
| pushed=$((pushed+1)) | |
| fi | |
| done | |
| # Root-level sync files (plugin-urls.txt, etc.) | |
| for f in "${ROOT_SYNC_FILES[@]}"; do | |
| if [ -f "${src}/${f}" ]; then | |
| mkdir -p "$(dirname "${dst}/${f}")" | |
| cp -f "${src}/${f}" "${dst}/${f}" | |
| pushed=$((pushed+1)) | |
| fi | |
| done | |
| # Also remove any stale binary files from dataset staging that may have | |
| # been pulled from a previous (broken) push — HF rejects all of these | |
| find "${dst}" -name '*.jar' -not -path '*/world/*' -delete 2>/dev/null || true | |
| find "${dst}" -name '*.zip' -not -path '*/world/*' -delete 2>/dev/null || true | |
| find "${dst}" -name '*.db' -not -path '*/world/*' -delete 2>/dev/null || true | |
| find "${dst}" -name '*.db-journal' -not -path '*/world/*' -delete 2>/dev/null || true | |
| find "${dst}" -name '*.db-wal' -not -path '*/world/*' -delete 2>/dev/null || true | |
| find "${dst}" -name '*.db-shm' -not -path '*/world/*' -delete 2>/dev/null || true | |
| find "${dst}" -name '*.sqlite' -not -path '*/world/*' -delete 2>/dev/null || true | |
| find "${dst}" -name '*.sqlite-journal' -not -path '*/world/*' -delete 2>/dev/null || true | |
| find "${dst}" -name '*.sqlite-wal' -not -path '*/world/*' -delete 2>/dev/null || true | |
| find "${dst}" -name '*.sqlite-shm' -not -path '*/world/*' -delete 2>/dev/null || true | |
| rm -rf "${dst}/plugins/spark/tmp" 2>/dev/null || true | |
| # ─── Proxy files (BungeeCord + EaglerXServer configs) ────────────── | |
| local proxy_src="${PROXY_DIR}" | |
| local proxy_dst="${SYNC_DIR}/proxy" | |
| if [ -d "${proxy_src}" ]; then | |
| info "Staging proxy files for push..." | |
| mkdir -p "${proxy_dst}" | |
| # Proxy config files | |
| for f in "${PROXY_CONFIG_FILES[@]}"; do | |
| if [ -f "${proxy_src}/${f}" ]; then | |
| mkdir -p "$(dirname "${proxy_dst}/${f}")" | |
| cp -f "${proxy_src}/${f}" "${proxy_dst}/${f}" | |
| pushed=$((pushed+1)) | |
| fi | |
| done | |
| # Proxy config directories (EaglerXServer plugin configs) | |
| for d in "${PROXY_CONFIG_DIRS[@]}"; do | |
| if [ -d "${proxy_src}/${d}" ]; then | |
| mkdir -p "${proxy_dst}/${d}" | |
| if command -v rsync &>/dev/null; then | |
| rsync -a "${PROXY_PLUGIN_EXCLUDES[@]}" "${proxy_src}/${d}/" "${proxy_dst}/${d}/" | |
| else | |
| cp -rf "${proxy_src}/${d}/." "${proxy_dst}/${d}/" | |
| find "${proxy_dst}/${d}" -name '*.jar' -delete 2>/dev/null || true | |
| find "${proxy_dst}/${d}" -name '*.zip' -delete 2>/dev/null || true | |
| find "${proxy_dst}/${d}" -name '*.db' -delete 2>/dev/null || true | |
| find "${proxy_dst}/${d}" -name '*.sqlite' -delete 2>/dev/null || true | |
| fi | |
| pushed=$((pushed+1)) | |
| fi | |
| done | |
| # Proxy root files | |
| if [ -f "${proxy_src}/server-icon.png" ]; then | |
| cp -f "${proxy_src}/server-icon.png" "${proxy_dst}/server-icon.png" | |
| pushed=$((pushed+1)) | |
| fi | |
| # Clean up binary files from proxy staging too | |
| find "${proxy_dst}" -name '*.jar' -delete 2>/dev/null || true | |
| find "${proxy_dst}" -name '*.zip' -delete 2>/dev/null || true | |
| find "${proxy_dst}" -name '*.db' -delete 2>/dev/null || true | |
| find "${proxy_dst}" -name '*.sqlite' -delete 2>/dev/null || true | |
| fi | |
| log "Staged ${pushed} items for push." | |
| } | |
| # ─── Main Commands ─────────────────────────────────────────────────────────── | |
| cmd_pull() { | |
| log "=== PULL MODE ===" | |
| clone_dataset | |
| if dataset_is_empty; then | |
| warn "Dataset is empty — first boot. Will push defaults after server starts." | |
| echo "FIRST_BOOT=1" > /tmp/sync-first-boot-flag | |
| return 0 | |
| fi | |
| pull_files | |
| # Download plugins from URLs | |
| download_plugins | |
| rm -f /tmp/sync-first-boot-flag | |
| log "=== PULL COMPLETE ===" | |
| } | |
| cmd_push() { | |
| local msg="${1:-Auto-sync server files}" | |
| log "=== PUSH MODE ===" | |
| clone_dataset | |
| push_files | |
| push_to_dataset "${msg}" | |
| # Push binary files (.db, .sqlite) via HF API separately | |
| info "Pushing binary files via HF API..." | |
| /opt/hf-venv/bin/python3 /sync-binaries.py push || warn "Binary file push failed (non-critical)" | |
| log "=== PUSH COMPLETE ===" | |
| } | |
| cmd_first_boot() { | |
| log "=== FIRST BOOT UPLOAD ===" | |
| # Called after the MC server has started and generated default files | |
| clone_dataset | |
| push_files | |
| push_to_dataset "Initial server files — first boot" | |
| # Push binary files (.db, .sqlite) via HF API separately | |
| info "Pushing binary files via HF API..." | |
| /opt/hf-venv/bin/python3 /sync-binaries.py push || warn "Binary file push failed (non-critical)" | |
| rm -f /tmp/sync-first-boot-flag | |
| log "=== FIRST BOOT UPLOAD COMPLETE ===" | |
| } | |
| cmd_watch() { | |
| local config_interval=60 # seconds between config pulls | |
| local world_interval=300 # seconds between world pushes | |
| local last_config_pull=0 | |
| local last_world_push=0 | |
| local last_plugin_check=0 # seconds between plugin URL checks | |
| local plugin_interval=300 # check for new plugins every 5 min | |
| log "=== WATCH MODE ===" | |
| log "Config pull interval: ${config_interval}s" | |
| log "World push interval: ${world_interval}s" | |
| log "Plugin URL check interval: ${plugin_interval}s" | |
| log "Binary file sync: PUSH only (during world push) — no periodic pull to avoid corrupting active .db files" | |
| while true; do | |
| local now | |
| now=$(date +%s) | |
| # Pull config changes from dataset periodically | |
| # NOTE: Only pulls root-level config files. Plugin configs are NOT | |
| # pulled during watch mode to avoid overwriting runtime changes | |
| # made by plugins (e.g. Aurelium saving settings). Plugin configs | |
| # are only synced on initial pull (startup). | |
| if [ $((now - last_config_pull)) -ge ${config_interval} ]; then | |
| info "Periodic config pull..." | |
| if clone_dataset 2>/dev/null; then | |
| # Only pull root-level config files (not world — too large for frequent pulls) | |
| for f in "${CONFIG_FILES[@]}"; do | |
| if [ -f "${SYNC_DIR}/${f}" ]; then | |
| if [ -f "${SERVER_DIR}/${f}" ]; then | |
| # Compare: only overwrite if different | |
| if ! diff -q "${SYNC_DIR}/${f}" "${SERVER_DIR}/${f}" &>/dev/null; then | |
| cp -f "${SYNC_DIR}/${f}" "${SERVER_DIR}/${f}" | |
| warn "Config changed remotely: ${f}" | |
| fi | |
| else | |
| cp -f "${SYNC_DIR}/${f}" "${SERVER_DIR}/${f}" | |
| warn "New config from remote: ${f}" | |
| fi | |
| fi | |
| done | |
| # Root-level sync files (plugin-urls.txt, playit-secret.txt) | |
| for f in "${ROOT_SYNC_FILES[@]}"; do | |
| if [ -f "${SYNC_DIR}/${f}" ]; then | |
| if [ -f "${SERVER_DIR}/${f}" ]; then | |
| if ! diff -q "${SYNC_DIR}/${f}" "${SERVER_DIR}/${f}" &>/dev/null; then | |
| cp -f "${SYNC_DIR}/${f}" "${SERVER_DIR}/${f}" | |
| info "Root file changed remotely: ${f}" | |
| fi | |
| else | |
| cp -f "${SYNC_DIR}/${f}" "${SERVER_DIR}/${f}" | |
| info "New root file from remote: ${f}" | |
| fi | |
| fi | |
| done | |
| # NOTE: Plugin configs are NOT pulled during watch mode. | |
| # Plugins write to their configs at runtime; overwriting them | |
| # every 60s would lose those changes. Plugin configs are synced | |
| # only on startup (cmd_pull) and pushed every 5 min (push_files). | |
| # | |
| # NOTE: Binary files (.db, .sqlite) are also NOT pulled during | |
| # watch mode. Overwriting .db files while plugins have them open | |
| # can corrupt the database. Binary files are only pulled on | |
| # startup (cmd_pull) when the server is NOT running yet. | |
| fi | |
| last_config_pull=${now} | |
| fi | |
| # Check for new plugin downloads periodically | |
| if [ $((now - last_plugin_check)) -ge ${plugin_interval} ]; then | |
| info "Checking for new plugin downloads..." | |
| download_plugins # uses /server/plugin-urls.txt by default | |
| last_plugin_check=${now} | |
| fi | |
| # Push world saves + plugin DBs periodically | |
| if [ $((now - last_world_push)) -ge ${world_interval} ]; then | |
| info "Periodic world push..." | |
| # Save-all FIRST via FIFO so MC flushes chunks from RAM to disk. | |
| # This replaces the old socat approach which didn't work | |
| # (port 25565 speaks MC protocol, not plain text). | |
| if mc_cmd "save-all flush"; then | |
| info "save-all flush sent — waiting 15s for save to complete..." | |
| sleep 15 | |
| else | |
| warn "Could not send save-all flush (FIFO not available). Pushing current disk state..." | |
| fi | |
| if clone_dataset 2>/dev/null; then | |
| push_files | |
| push_to_dataset "Periodic world save — $(date -u '+%Y-%m-%d %H:%M UTC')" || true | |
| fi | |
| # Push binary files (.db, .sqlite) via HF API | |
| info "Periodic binary file push..." | |
| /opt/hf-venv/bin/python3 /sync-binaries.py push || warn "Binary file push failed (non-critical)" | |
| last_world_push=${now} | |
| fi | |
| sleep 10 | |
| done | |
| } | |
| # ─── CLI ───────────────────────────────────────────────────────────────────── | |
| case "${1:-help}" in | |
| pull) | |
| cmd_pull | |
| ;; | |
| push) | |
| cmd_push "${2:-Manual sync}" | |
| ;; | |
| first-boot) | |
| cmd_first_boot | |
| ;; | |
| watch) | |
| cmd_watch | |
| ;; | |
| plugins) | |
| download_plugins "${2:-${SERVER_DIR}/plugin-urls.txt}" | |
| ;; | |
| help|*) | |
| echo "Usage: $0 {pull|push|first-boot|watch|plugins}" | |
| echo "" | |
| echo " pull — Download files from HF dataset to server" | |
| echo " push [msg] — Upload server files to HF dataset" | |
| echo " first-boot — Upload initial server files (used on first startup)" | |
| echo " watch — Background: pull configs every 60s, push world every 5min" | |
| echo " plugins [file]— Download plugin JARs from URLs in plugin-urls.txt" | |
| echo "" | |
| echo "Plugin installation:" | |
| echo " Create 'plugin-urls.txt' in the dataset root with one download URL per line." | |
| echo " The script downloads them to SERVER_DIR/plugins/ on pull and every 5 min." | |
| echo "" | |
| echo "Required env vars: HF_DATASET_ID, HF_TOKEN" | |
| echo "Optional env vars: SERVER_DIR (default: /server), SYNC_BRANCH (default: main)" | |
| ;; | |
| esac | |