MC-texture-gen / sync-server.sh
applepie69's picture
Upload 6 files
da6aa82 verified
Raw
History Blame Contribute Delete
31.8 kB
#!/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