diff --git a/package.json b/package.json index 8389abcb..7965ba04 100644 --- a/package.json +++ b/package.json @@ -124,6 +124,10 @@ "pagespeed:urls": "tsx -e \"import sitemap from './app/sitemap'; sitemap().then(urls => console.log(urls.map(u => u.url).join('\\n')))\"", "backup:db": "bash ./scripts/backup-db.sh", "restore:db": "bash ./scripts/restore-db.sh", + "cms:push:testing": "bash ./scripts/cms-sync.sh push testing", + "cms:push:prod": "bash ./scripts/cms-sync.sh push prod", + "cms:pull:testing": "bash ./scripts/cms-sync.sh pull testing", + "cms:pull:prod": "bash ./scripts/cms-sync.sh pull prod", "prepare": "husky", "preinstall": "npx only-allow pnpm" }, diff --git a/scripts/cms-sync.sh b/scripts/cms-sync.sh new file mode 100755 index 00000000..0e36d67d --- /dev/null +++ b/scripts/cms-sync.sh @@ -0,0 +1,206 @@ +#!/usr/bin/env bash +# ──────────────────────────────────────────────────────────────────────────── +# CMS Data Sync Tool +# Safely syncs Payload CMS data (DB + media) between environments. +# +# Usage: +# cms:push:testing – Push local → testing +# cms:push:prod – Push local → production +# cms:pull:testing – Pull testing → local +# cms:pull:prod – Pull production → local +# ──────────────────────────────────────────────────────────────────────────── +set -euo pipefail + +# Load environment variables +if [ -f .env ]; then + set -a; source .env; set +a +fi + +# ── Configuration ────────────────────────────────────────────────────────── +DIRECTION="${1:-}" # push | pull +TARGET="${2:-}" # testing | prod +SSH_HOST="root@alpha.mintel.me" +DB_USER="${PAYLOAD_DB_USER:-payload}" +DB_NAME="${PAYLOAD_DB_NAME:-payload}" +LOCAL_DB_CONTAINER="klz-2026-klz-db-1" +LOCAL_MEDIA_DIR="./public/media" +BACKUP_DIR="./backups" +TIMESTAMP=$(date +"%Y%m%d_%H%M%S") + +# Migration names to insert after restore (keeps Payload from prompting) +MIGRATIONS=( + "20260223_195005_products_collection:1" + "20260223_195151_remove_sku_unique:2" + "20260225_003500_add_pages_collection:3" +) + +# ── Resolve target environment ───────────────────────────────────────────── +resolve_target() { + case "$TARGET" in + testing) + REMOTE_PROJECT="klz-testing" + REMOTE_DB_CONTAINER="klz-testing-klz-db-1" + REMOTE_APP_CONTAINER="klz-testing-klz-app-1" + REMOTE_MEDIA_VOLUME="/var/lib/docker/volumes/klz-testing_klz_media_data/_data" + ;; + prod|production) + REMOTE_PROJECT="klz-cablescom" + REMOTE_DB_CONTAINER="klz-cablescom-klz-db-1" + REMOTE_APP_CONTAINER="klz-cablescom-klz-app-1" + REMOTE_MEDIA_VOLUME="/var/lib/docker/volumes/klz-cablescom_klz_media_data/_data" + ;; + *) + echo "❌ Unknown target: $TARGET" + echo " Valid targets: testing, prod" + exit 1 + ;; + esac +} + +# ── Sanitize migrations table ────────────────────────────────────────────── +sanitize_migrations() { + local container="$1" + local exec_prefix="$2" # "" for local, "ssh $SSH_HOST" for remote + + echo "🔧 Sanitizing payload_migrations table..." + local SQL="DELETE FROM payload_migrations WHERE batch = -1;" + for entry in "${MIGRATIONS[@]}"; do + local name="${entry%%:*}" + local batch="${entry##*:}" + SQL="$SQL INSERT INTO payload_migrations (name, batch) SELECT '$name', $batch WHERE NOT EXISTS (SELECT 1 FROM payload_migrations WHERE name = '$name');" + done + + if [ -z "$exec_prefix" ]; then + docker exec "$container" psql -U "$DB_USER" -d "$DB_NAME" -c "$SQL" + else + $exec_prefix "docker exec $container psql -U $DB_USER -d $DB_NAME -c \"$SQL\"" + fi +} + +# ── Safety: Create backup before overwriting ─────────────────────────────── +backup_local_db() { + mkdir -p "$BACKUP_DIR" + local file="$BACKUP_DIR/payload_pre_sync_${TIMESTAMP}.sql.gz" + echo "📦 Creating safety backup of local DB → $file" + docker exec "$LOCAL_DB_CONTAINER" pg_dump -U "$DB_USER" -d "$DB_NAME" --clean --if-exists | gzip > "$file" + echo "✅ Backup: $file ($(du -h "$file" | cut -f1))" +} + +backup_remote_db() { + local file="/tmp/payload_pre_sync_${TIMESTAMP}.sql.gz" + echo "📦 Creating safety backup of $TARGET DB → $SSH_HOST:$file" + ssh "$SSH_HOST" "docker exec $REMOTE_DB_CONTAINER pg_dump -U $DB_USER -d $DB_NAME --clean --if-exists | gzip > $file" + echo "✅ Remote backup: $file" +} + +# ── PUSH: local → remote ────────────────────────────────────────────────── +do_push() { + echo "" + echo "┌──────────────────────────────────────────────────┐" + echo "│ 📤 PUSH: local → $TARGET " + echo "│ This will OVERWRITE the $TARGET database! " + echo "│ A safety backup will be created first. " + echo "└──────────────────────────────────────────────────┘" + echo "" + read -p "Are you sure? (y/N) " -n 1 -r + echo "" + [[ ! $REPLY =~ ^[Yy]$ ]] && { echo "Cancelled."; exit 0; } + + # 1. Safety backup of remote + backup_remote_db + + # 2. Dump local DB + echo "📤 Dumping local database..." + local dump="/tmp/payload_push_${TIMESTAMP}.sql.gz" + docker exec "$LOCAL_DB_CONTAINER" pg_dump -U "$DB_USER" -d "$DB_NAME" --clean --if-exists | gzip > "$dump" + + # 3. Transfer and restore + echo "📤 Transferring to $SSH_HOST..." + scp "$dump" "$SSH_HOST:/tmp/payload_push.sql.gz" + + echo "🔄 Restoring database on $TARGET..." + ssh "$SSH_HOST" "gunzip -c /tmp/payload_push.sql.gz | docker exec -i $REMOTE_DB_CONTAINER psql -U $DB_USER -d $DB_NAME --quiet" + + # 4. Sanitize migrations + sanitize_migrations "$REMOTE_DB_CONTAINER" "ssh $SSH_HOST" + + # 5. Sync media + echo "🖼️ Syncing media files..." + rsync -az --delete --info=progress2 "$LOCAL_MEDIA_DIR/" "$SSH_HOST:$REMOTE_MEDIA_VOLUME/" + + # 6. Restart app + echo "🔄 Restarting $TARGET app container..." + ssh "$SSH_HOST" "docker restart $REMOTE_APP_CONTAINER" + + # Cleanup + rm -f "$dump" + ssh "$SSH_HOST" "rm -f /tmp/payload_push.sql.gz" + + echo "" + echo "✅ Push to $TARGET complete!" +} + +# ── PULL: remote → local ────────────────────────────────────────────────── +do_pull() { + echo "" + echo "┌──────────────────────────────────────────────────┐" + echo "│ 📥 PULL: $TARGET → local " + echo "│ This will OVERWRITE your local database! " + echo "│ A safety backup will be created first. " + echo "└──────────────────────────────────────────────────┘" + echo "" + read -p "Are you sure? (y/N) " -n 1 -r + echo "" + [[ ! $REPLY =~ ^[Yy]$ ]] && { echo "Cancelled."; exit 0; } + + # 1. Safety backup of local + backup_local_db + + # 2. Dump remote DB + echo "📥 Dumping $TARGET database..." + ssh "$SSH_HOST" "docker exec $REMOTE_DB_CONTAINER pg_dump -U $DB_USER -d $DB_NAME --clean --if-exists | gzip > /tmp/payload_pull.sql.gz" + + # 3. Transfer and restore + echo "📥 Downloading from $SSH_HOST..." + scp "$SSH_HOST:/tmp/payload_pull.sql.gz" "/tmp/payload_pull.sql.gz" + + echo "🔄 Restoring database locally..." + gunzip -c "/tmp/payload_pull.sql.gz" | docker exec -i "$LOCAL_DB_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" --quiet + + # 4. Sync media + echo "🖼️ Syncing media files..." + mkdir -p "$LOCAL_MEDIA_DIR" + rsync -az --delete --info=progress2 "$SSH_HOST:$REMOTE_MEDIA_VOLUME/" "$LOCAL_MEDIA_DIR/" + + # Cleanup + rm -f "/tmp/payload_pull.sql.gz" + ssh "$SSH_HOST" "rm -f /tmp/payload_pull.sql.gz" + + echo "" + echo "✅ Pull from $TARGET complete! Restart dev server to see changes." +} + +# ── Main ─────────────────────────────────────────────────────────────────── +if [ -z "$DIRECTION" ] || [ -z "$TARGET" ]; then + echo "📦 CMS Data Sync Tool" + echo "" + echo "Usage:" + echo " pnpm cms:push:testing Push local DB + media → testing" + echo " pnpm cms:push:prod Push local DB + media → production" + echo " pnpm cms:pull:testing Pull testing DB + media → local" + echo " pnpm cms:pull:prod Pull production DB + media → local" + echo "" + echo "Safety: A backup is always created before overwriting." + exit 1 +fi + +resolve_target + +case "$DIRECTION" in + push) do_push ;; + pull) do_pull ;; + *) + echo "❌ Unknown direction: $DIRECTION (use 'push' or 'pull')" + exit 1 + ;; +esac