#!/bin/bash set -euo pipefail script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" root_dir="$(cd -- "$script_dir/.." && pwd)" local_folder="${LOCAL_FOLDER:-$root_dir}" remote_folder="${REMOTE_FOLDER:-/opt/www/virtual/SkinbaseNova}" remote_server="${REMOTE_SERVER:-klevze@server3.klevze.si}" php_bin="${PHP_BIN:-php}" composer_bin="${COMPOSER_BIN:-composer}" ssh_bin="${SSH_BIN:-ssh}" rsync_bin="${RSYNC_BIN:-rsync}" local_build_command="${LOCAL_BUILD_COMMAND:-}" run_local_build=1 run_remote_migrations=1 run_db_sync=0 run_meilisearch_setup=0 auto_detect_meilisearch=1 deploy_mode="normal" db_sync_source="" legacy_db_sync_mode=0 force_db_sync=0 skip_maintenance=0 full_upgrade_pre_hook="${FULL_UPGRADE_PRE_HOOK:-}" full_upgrade_post_hook="${FULL_UPGRADE_POST_HOOK:-}" db_sync_confirm_target="${DB_SYNC_CONFIRM_TARGET:-}" db_sync_confirm_phrase="${DB_SYNC_CONFIRM_PHRASE:-}" meilisearch_models_csv="" readonly all_meilisearch_models_csv='App\Models\Artwork,App\Models\User,App\Models\Group,App\Models\Post,App\Models\Message' usage() { cat <<'EOF' Usage: bash sync.sh [options] Options: --mode=normal|full-upgrade Choose the deploy mode. Default: normal. --full-upgrade Alias for --mode=full-upgrade. --skip-build Skip local npm build before rsync. --skip-migrate Skip php artisan migrate on the server. --with-db-from=local Replace the production database with a dump from the local database. --confirm-db-sync-target HOST Must match the remote server name when running non-interactively. --confirm-db-sync-phrase TEXT Must equal 'replace production db from local' when running non-interactively. --with-db Legacy alias for --with-db-from=local. --force-db-sync Legacy extra confirmation flag for --with-db. --with-meilisearch Force Meilisearch settings sync and reimport all searchable models. --skip-meilisearch Skip Meilisearch refresh, including auto-detected refreshes. --upgrade-pre-hook CMD Run a remote shell command before Composer/migrations in full-upgrade mode. --upgrade-post-hook CMD Run a remote shell command after the deploy completes in full-upgrade mode. --no-maintenance Skip php artisan down/up during deploy. --help Show this help. Environment overrides: LOCAL_FOLDER, REMOTE_FOLDER, REMOTE_SERVER, PHP_BIN, COMPOSER_BIN, SSH_BIN, RSYNC_BIN, LOCAL_BUILD_COMMAND, DB_SYNC_CONFIRM_TARGET, DB_SYNC_CONFIRM_PHRASE, FULL_UPGRADE_PRE_HOOK, FULL_UPGRADE_POST_HOOK EOF } set_deploy_mode() { case "$1" in normal|full-upgrade) deploy_mode="$1" ;; *) echo "Unsupported deploy mode: $1" >&2 echo "Allowed values: normal, full-upgrade" >&2 exit 1 ;; esac } confirm_database_replacement() { local expected_phrase="replace production db from local" local typed_target="" local typed_phrase="" if [[ "$run_db_sync" -ne 1 || "$db_sync_source" != "local" ]]; then return fi echo "WARNING: this will overwrite the production database on $remote_server using your local database dump." if [[ -n "$db_sync_confirm_target" || -n "$db_sync_confirm_phrase" ]]; then if [[ "$db_sync_confirm_target" != "$remote_server" ]]; then echo "Refusing DB sync: --confirm-db-sync-target must exactly match $remote_server." >&2 exit 1 fi if [[ "$db_sync_confirm_phrase" != "$expected_phrase" ]]; then echo "Refusing DB sync: --confirm-db-sync-phrase must exactly equal '$expected_phrase'." >&2 exit 1 fi return fi if [[ ! -t 0 ]]; then echo "Refusing DB sync in non-interactive mode without --confirm-db-sync-target \"$remote_server\" and --confirm-db-sync-phrase \"$expected_phrase\"." >&2 exit 1 fi read -r -p "Type the remote server to confirm DB replacement [$remote_server]: " typed_target if [[ "$typed_target" != "$remote_server" ]]; then echo "Refusing DB sync: remote server confirmation did not match." >&2 exit 1 fi read -r -p "Type '$expected_phrase' to continue: " typed_phrase if [[ "$typed_phrase" != "$expected_phrase" ]]; then echo "Refusing DB sync: confirmation phrase did not match." >&2 exit 1 fi } is_wsl() { [[ -n "${WSL_DISTRO_NAME:-}" || -n "${WSL_INTEROP:-}" ]] } run_frontend_build() { if [[ -n "$local_build_command" ]]; then ( cd "$local_folder" eval "$local_build_command" ) return fi if is_wsl && command -v wslpath >/dev/null 2>&1 && command -v powershell.exe >/dev/null 2>&1; then local windows_local_folder windows_local_folder="$(wslpath -w "$local_folder")" echo "Detected WSL checkout; running frontend build with Windows npm.cmd to match local node_modules..." powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \ "Set-Location -LiteralPath '$windows_local_folder'; npm.cmd run build" return fi ( cd "$local_folder" npm run build ) } build_rsync_args() { rsync_args=( -rlvz --no-perms --no-times --omit-dir-times --delay-updates --delete --delete-delay --exclude ".phpintel/" --exclude "bootstrap/cache/" --exclude ".env" --exclude "public/hot" --exclude "node_modules" --exclude "public/files/" --exclude "resources/lang/" --exclude "storage/" --exclude ".git/" --exclude ".cursor/" --exclude ".venv/" --exclude "/var/php-tmp" --exclude "/oldSite" --exclude "/vendor" -e "$ssh_bin" ) } collect_sync_changed_files() { local itemized if ! itemized="$($rsync_bin "${rsync_args[@]}" --dry-run --itemize-changes "$local_folder/" "$remote_server:$remote_folder/" 2>/dev/null)"; then return 1 fi printf '%s\n' "$itemized" | awk ' /^deleting / { sub(/^deleting /, "", $0) if ($0 !~ /\/$/) print next } /^[<>ch.*][^ ]* / { path = $0 sub(/^[^ ]+ /, "", path) if (path !~ /\/$/) print path } ' | sed '/^$/d' | sort -u } detect_meilisearch_models_from_sync() { local changed_files local file local force_full=0 local -a models=() if ! changed_files="$(collect_sync_changed_files)"; then return 1 fi [[ -n "$changed_files" ]] || return 1 while IFS= read -r file; do case "$file" in config/scout.php|app/Console/Commands/ConfigureMeilisearchIndex.php) force_full=1 ;; app/Models/Artwork.php) models+=("App\Models\Artwork") ;; app/Models/User.php) models+=("App\Models\User") ;; app/Models/Group.php) models+=("App\Models\Group") ;; app/Models/Post.php) models+=("App\Models\Post") ;; app/Models/Message.php) models+=("App\Models\Message") ;; esac done <<< "$changed_files" if [[ "$force_full" -eq 1 ]]; then printf '%s\n' "$all_meilisearch_models_csv" return 0 fi [[ ${#models[@]} -gt 0 ]] || return 1 printf '%s\n' "$(printf '%s\n' "${models[@]}" | awk '!seen[$0]++' | paste -sd, -)" } while [[ $# -gt 0 ]]; do case "$1" in --mode) shift set_deploy_mode "${1:?Missing value for --mode}" ;; --mode=*) set_deploy_mode "${1#*=}" ;; --full-upgrade) deploy_mode="full-upgrade" ;; --skip-build) run_local_build=0 ;; --skip-migrate) run_remote_migrations=0 ;; --with-db-from=local) run_db_sync=1 db_sync_source="local" ;; --with-db-from=*) echo "Unsupported DB sync source in option: $1" >&2 echo "Only --with-db-from=local is supported." >&2 exit 1 ;; --with-db) run_db_sync=1 db_sync_source="local" legacy_db_sync_mode=1 ;; --force-db-sync) force_db_sync=1 ;; --confirm-db-sync-target) shift db_sync_confirm_target="${1:?Missing value for --confirm-db-sync-target}" ;; --confirm-db-sync-target=*) db_sync_confirm_target="${1#*=}" ;; --confirm-db-sync-phrase) shift db_sync_confirm_phrase="${1:?Missing value for --confirm-db-sync-phrase}" ;; --confirm-db-sync-phrase=*) db_sync_confirm_phrase="${1#*=}" ;; --with-meilisearch) run_meilisearch_setup=1 auto_detect_meilisearch=0 meilisearch_models_csv="$all_meilisearch_models_csv" ;; --skip-meilisearch) run_meilisearch_setup=0 auto_detect_meilisearch=0 meilisearch_models_csv="" ;; --upgrade-pre-hook) shift full_upgrade_pre_hook="${1:?Missing value for --upgrade-pre-hook}" ;; --upgrade-pre-hook=*) full_upgrade_pre_hook="${1#*=}" ;; --upgrade-post-hook) shift full_upgrade_post_hook="${1:?Missing value for --upgrade-post-hook}" ;; --upgrade-post-hook=*) full_upgrade_post_hook="${1#*=}" ;; --no-maintenance) skip_maintenance=1 ;; --help|-h) usage exit 0 ;; *) echo "Unknown option: $1" >&2 usage >&2 exit 1 ;; esac shift done if [[ -n "$full_upgrade_pre_hook" || -n "$full_upgrade_post_hook" ]] && [[ "$deploy_mode" != "full-upgrade" ]]; then echo "Upgrade hooks can only be used with --mode=full-upgrade." >&2 exit 1 fi if [[ "$run_db_sync" -eq 1 && "$db_sync_source" != "local" ]]; then echo "Refusing DB sync without an explicit source. Use --with-db-from=local." >&2 exit 1 fi if [[ "$run_db_sync" -eq 1 ]] && { [[ -z "$db_sync_confirm_target" && -n "$db_sync_confirm_phrase" ]] || [[ -n "$db_sync_confirm_target" && -z "$db_sync_confirm_phrase" ]]; }; then echo "Refusing DB sync: both --confirm-db-sync-target and --confirm-db-sync-phrase are required together when provided." >&2 exit 1 fi if [[ "$legacy_db_sync_mode" -eq 1 && "$force_db_sync" -ne 1 ]]; then echo "Refusing legacy --with-db without --force-db-sync. Prefer --with-db-from=local instead." >&2 exit 1 fi if [[ "$run_db_sync" -eq 1 ]]; then confirm_database_replacement fi if [[ "$deploy_mode" == "full-upgrade" && "$auto_detect_meilisearch" -eq 1 && "$run_meilisearch_setup" -eq 0 ]]; then run_meilisearch_setup=1 auto_detect_meilisearch=0 meilisearch_models_csv="$all_meilisearch_models_csv" fi if [[ "$run_local_build" -eq 1 ]]; then echo "Building frontend assets locally..." run_frontend_build fi build_rsync_args if [[ "$run_meilisearch_setup" -eq 0 && "$auto_detect_meilisearch" -eq 1 ]]; then if meilisearch_models_csv="$(detect_meilisearch_models_from_sync)"; then run_meilisearch_setup=1 echo "Detected Meilisearch-relevant changes in this deployment; will refresh indexes for: $meilisearch_models_csv" fi fi echo "Syncing application files to $remote_server..." "$rsync_bin" "${rsync_args[@]}" "$local_folder/" "$remote_server:$remote_folder/" if [[ "$run_db_sync" -eq 1 ]]; then echo "Replacing the production database from the local dump..." "$script_dir/push-db-to-prod.sh" \ --force \ --remote-server "$remote_server" \ --remote-folder "$remote_folder" \ $( [[ "$run_remote_migrations" -eq 0 ]] && printf '%s' '--skip-migrate' ) fi echo "Running remote Composer and Artisan steps..." "$ssh_bin" "$remote_server" \ REMOTE_FOLDER="$(printf '%q' "$remote_folder")" \ PHP_BIN="$(printf '%q' "$php_bin")" \ COMPOSER_BIN="$(printf '%q' "$composer_bin")" \ RUN_REMOTE_MIGRATIONS="$run_remote_migrations" \ SKIP_MAINTENANCE="$skip_maintenance" \ DEPLOY_MODE="$(printf '%q' "$deploy_mode")" \ RUN_MEILISEARCH_SETUP="$run_meilisearch_setup" \ FULL_UPGRADE_PRE_HOOK="$(printf '%q' "$full_upgrade_pre_hook")" \ FULL_UPGRADE_POST_HOOK="$(printf '%q' "$full_upgrade_post_hook")" \ MEILISEARCH_MODELS_CSV="$(printf '%q' "$meilisearch_models_csv")" \ 'bash -s' <<'EOF' set -euo pipefail cd "$REMOTE_FOLDER" ensure_php_runtime_dir() { local target_dir="$1" local -a privileged_cmd=() if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then privileged_cmd=(sudo -n) elif [[ "$(id -u)" -eq 0 ]]; then privileged_cmd=() fi if [[ ! -d "$target_dir" ]]; then if [[ ${#privileged_cmd[@]} -gt 0 ]]; then "${privileged_cmd[@]}" mkdir -p "$target_dir" else mkdir -p "$target_dir" fi fi if [[ ${#privileged_cmd[@]} -gt 0 || "$(id -u)" -eq 0 ]]; then "${privileged_cmd[@]}" chown -R skinbase:skinbase "$target_dir" "${privileged_cmd[@]}" chmod 770 "$target_dir" return fi chmod 770 "$target_dir" >/dev/null 2>&1 || true } ensure_php_runtime_dir "$REMOTE_FOLDER/var/php-tmp" ensure_php_runtime_dir "$REMOTE_FOLDER/var/php-sessions" bring_app_up() { if [[ "$SKIP_MAINTENANCE" -eq 0 ]]; then "$PHP_BIN" artisan up >/dev/null 2>&1 || true fi } run_remote_hook() { local hook_name="$1" local hook_command="$2" [[ -n "$hook_command" ]] || return 0 echo "Running ${hook_name}..." bash -lc "$hook_command" } trap bring_app_up EXIT if [[ "$DEPLOY_MODE" == "full-upgrade" ]]; then run_remote_hook "full-upgrade pre-hook" "${FULL_UPGRADE_PRE_HOOK:-}" fi "$COMPOSER_BIN" install --no-dev --prefer-dist --optimize-autoloader --no-interaction if [[ "$SKIP_MAINTENANCE" -eq 0 ]]; then "$PHP_BIN" artisan down --retry=60 || true fi if [[ "$RUN_REMOTE_MIGRATIONS" -eq 1 ]]; then "$PHP_BIN" artisan migrate --force fi "$PHP_BIN" artisan optimize:clear "$PHP_BIN" artisan optimize if [[ "$SKIP_MAINTENANCE" -eq 0 ]]; then "$PHP_BIN" artisan up trap - EXIT fi if ! "$PHP_BIN" artisan homepage:warm-guest-cache; then echo "Warning: homepage guest cache warm failed during deploy." >&2 fi if ! "$PHP_BIN" artisan posts:warm-trending; then echo "Warning: post trending cache warm failed during deploy." >&2 fi "$PHP_BIN" artisan queue:restart || true "$PHP_BIN" artisan horizon:terminate || true if [[ "$RUN_MEILISEARCH_SETUP" -eq 1 ]]; then if [[ -z "${MEILISEARCH_MODELS_CSV:-}" ]]; then MEILISEARCH_MODELS_CSV='App\Models\Artwork,App\Models\User,App\Models\Group,App\Models\Post,App\Models\Message' fi IFS=',' read -r -a meilisearch_models <<< "$MEILISEARCH_MODELS_CSV" echo "Importing searchable models into Meilisearch (auto-creates indexes)..." for model in "${meilisearch_models[@]}"; do [[ -n "$model" ]] || continue echo " -> $model" "$PHP_BIN" artisan scout:import "$model" done echo "Syncing Meilisearch index settings..." "$PHP_BIN" artisan scout:sync-index-settings echo "Meilisearch setup complete." fi if [[ "$DEPLOY_MODE" == "full-upgrade" ]]; then run_remote_hook "full-upgrade post-hook" "${FULL_UPGRADE_POST_HOOK:-}" fi EOF echo "Deployment complete."