#!/bin/bash # ============================================================================= # 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