#!/bin/sh set -e # Two modes: # install — the database has no WordPress yet: core, language, plugins, # theme and the project seeder are set up from scratch. # update — the site already exists: only the WordPress core and the # installed plugins are updated. Content, options, widgets, # users and manually configured settings are never touched. # # Whatever a plugin/theme adds on disk (uploads, new plugin folders, edited # files) survives, because state lives in the db_data and wordpress_data # volumes, not in this script. WP_PATH=/var/www/html SITE_URL="${WORDPRESS_SITE_URL:-http://localhost:${WORDPRESS_PORT:-8080}}" ADMIN_USER="${WORDPRESS_ADMIN_USER:-admin}" ADMIN_PASSWORD="${WORDPRESS_ADMIN_PASSWORD:-admin}" ADMIN_EMAIL="${WORDPRESS_ADMIN_EMAIL:-admin@example.com}" ADMIN_TITLE="${WORDPRESS_SITE_TITLE:-Gustavo Portfolio}" WP_THEME="${WP_THEME:-gustavoo-portfolio}" WP_LOCALE="${WP_LOCALE:-pt_BR}" # Plugins from the WordPress.org directory that must exist even when they are # not committed to wp-content/plugins. Installed only when missing, then # activated. Format: slug (or URL). # fluentform, fluent-crm and fluent-smtp are required by gustavoo-portfolio-core # (contact forms, CRM feeds, SMTP); without them the core plugin refuses to # activate and the seeder never runs. WP_EXTRA_PLUGINS="${WP_EXTRA_PLUGINS:-post-views-counter seo-by-rank-math google-site-kit fluentform fluent-crm fluent-smtp}" wp_run() { wp --path="$WP_PATH" "$@" } log() { echo "[wpcli] $*" } warn() { echo "[wpcli] WARN: $*" >&2 } # ─── Wait for dependencies ─────────────────────────────────────────────────── # The loop fails fast on wrong credentials instead of spinning forever: when # the password in .env does not match the one stored in the db_data volume, # `db check` answers "Access denied" immediately, and waiting longer changes # nothing. Any other failure (server down, still starting) keeps waiting, with # a timeout so a dead database cannot hang the boot silently. log "Waiting for database..." DB_TRIES=0 while true; do if DB_WAIT_OUT="$(wp_run db check 2>&1)"; then break fi if printf '%s' "$DB_WAIT_OUT" | grep -q "Access denied"; then echo "[wpcli] ERROR: the database rejected the credentials from .env." >&2 echo "[wpcli]" >&2 echo "[wpcli] The database volume keeps the password used on the first run." >&2 echo "[wpcli] Changing WORDPRESS_DB_PASSWORD or MARIADB_ROOT_PASSWORD in" >&2 echo "[wpcli] .env alone does not update it, so WordPress stops connecting." >&2 echo "[wpcli]" >&2 echo "[wpcli] Put the original password back in .env and restart the containers." >&2 echo "[wpcli] (To really change it, see 'Passwords' in README.md.)" >&2 exit 1 fi DB_TRIES=$((DB_TRIES + 1)) if [ "$DB_TRIES" -ge 150 ]; then echo "[wpcli] ERROR: the database did not become ready in time." >&2 printf '%s\n' "$DB_WAIT_OUT" | tail -3 >&2 exit 1 fi sleep 2 done log "Waiting for WordPress core files..." until [ -f "$WP_PATH/wp-load.php" ]; do sleep 2 done # The wp:cli image runs as a different uid than the wordpress image. Grant # write access to whoever needs it without changing the volume ownership: # the core folder (plugins, themes, wp-config.php) and wp-content itself. # Bind-mounted wp-content/plugins|themes come from the host, so their # permissions are checked too. if [ ! -w "$WP_PATH/wp-content" ]; then log "$WP_PATH/wp-content not writable by uid $(id -u), fixing permissions..." find "$WP_PATH" -maxdepth 1 -exec chmod g+rwX {} + || true find "$WP_PATH/wp-content" -maxdepth 1 -exec chmod g+rwX {} + || true mkdir -p "$WP_PATH/wp-content/uploads" 2>/dev/null || true find "$WP_PATH/wp-content/uploads" -maxdepth 1 -exec chmod g+rwX {} + 2>/dev/null || true fi # Plugins are installed by wp-cli inside the bind-mounted folder, so wp-cli # must be able to write there. The compose default (user 33:33) matches the # wordpress image; a host-owned folder is reported instead of failing silently. for dir in "$WP_PATH/wp-content/plugins" "$WP_PATH/wp-content/themes"; do [ -d "$dir" ] || continue if [ ! -w "$dir" ]; then log "$dir is owned by another user — adjusting group permissions..." find "$dir" -maxdepth 0 -exec chmod g+rwX {} + 2>/dev/null || true fi if [ ! -w "$dir" ]; then warn "cannot write to $dir as uid $(id -u)/gid $(id -g). Plugin install/update will fail." warn "fix with: sudo chmod -R g+rwX wp-content/plugins wp-content/themes && sudo chgrp $(id -gn) wp-content/plugins wp-content/themes" fi done if [ ! -w "$WP_PATH" ]; then echo "[wpcli] ERROR: $WP_PATH is not writable by uid $(id -u). Aborting." >&2 exit 1 fi # ─── Mode detection ────────────────────────────────────────────────────────── if wp_run core is-installed 2>/dev/null; then MODE=update else MODE=install fi # ═════════════════════════════════════════════════════════════════════════════ # INSTALL — first run on an empty database # ═════════════════════════════════════════════════════════════════════════════ if [ "$MODE" = install ]; then log "No WordPress installation found — starting first-time setup." log "Installing WordPress core at $SITE_URL..." wp_run core install \ --url="$SITE_URL" \ --title="$ADMIN_TITLE" \ --admin_user="$ADMIN_USER" \ --admin_password="$ADMIN_PASSWORD" \ --admin_email="$ADMIN_EMAIL" \ --skip-email log "Setting site URL to $SITE_URL..." wp_run option update home "$SITE_URL" wp_run option update siteurl "$SITE_URL" # ═════════════════════════════════════════════════════════════════════════════ # UPDATE — the site already exists, keep every piece of data # ═════════════════════════════════════════════════════════════════════════════ else log "Existing installation detected — keeping database, content and settings." fi # ─── Language (both modes) ─────────────────────────────────────────────────── if ! wp_run language core is-installed "$WP_LOCALE" 2>/dev/null; then log "Installing $WP_LOCALE language pack..." wp_run language core install "$WP_LOCALE" fi if [ "$(wp_run option get WPLANG 2>/dev/null || true)" != "$WP_LOCALE" ]; then log "Activating $WP_LOCALE site language..." wp_run language core activate "$WP_LOCALE" fi # ─── Redis constants (both modes) ──────────────────────────────────────────── # Values from .env override wp-config.php; anything empty keeps the current # value, so a manual edit is never lost. REDIS_HOST="${WP_REDIS_HOST:-}" REDIS_PORT="${WP_REDIS_PORT:-}" REDIS_PASSWORD="${WP_REDIS_PASSWORD:-}" REDIS_DATABASE="${WP_REDIS_DATABASE:-}" if [ -n "$REDIS_HOST" ]; then wp_run config set WP_REDIS_HOST "$REDIS_HOST" --type=constant >/dev/null fi if [ -n "$REDIS_PORT" ]; then wp_run config set WP_REDIS_PORT "$REDIS_PORT" --type=constant --raw >/dev/null fi if [ -n "$REDIS_PASSWORD" ]; then wp_run config set WP_REDIS_PASSWORD "$REDIS_PASSWORD" --type=constant >/dev/null fi if [ -n "$REDIS_DATABASE" ]; then wp_run config set WP_REDIS_DATABASE "$REDIS_DATABASE" --type=constant --raw >/dev/null fi # ═════════════════════════════════════════════════════════════════════════════ # UPDATES — only on an existing installation, only when an update exists. # Uses the WordPress upgrader so database migrations run as on the dashboard. # ═════════════════════════════════════════════════════════════════════════════ update_core() { UPDATE_JSON="$(wp_run core check-update --format=json 2>/dev/null || echo '[]')" case "$UPDATE_JSON" in ''|'[]'|'null') log "WordPress core is up to date ($(wp_run core version 2>/dev/null))." return 0 ;; esac # no --minor: security/latest release is the desired behaviour, and the # major upgrade keeps content and database intact through wp-cli. log "New WordPress version available — updating core..." wp_run core update || warn "core update failed" } update_plugins() { # Only plugins that exist locally are updated; nothing new is downloaded. STALE="$(wp_run plugin list --status=active --fields=name,update --format=csv 2>/dev/null \ | awk -F, 'NR>1 && $2 != "" && $2 != "none" && $2 != "available" {print $1}')" if [ -z "$STALE" ]; then log "All active plugins are up to date." return 0 fi for plugin in $STALE; do log "Updating plugin '$plugin'..." wp_run plugin update "$plugin" || warn "could not update plugin '$plugin'" done } if [ "$MODE" = update ] && [ -z "${WP_SKIP_UPDATES:-}" ]; then update_core update_plugins fi # ─── Extra plugins from WordPress.org (both modes) ─────────────────────────── # These plugins are not versioned in Git: the setup installs them from the # WordPress.org directory so the repository stays small. Only installed when # missing: an update is handled by the update step, so a local version or # manual change is never overwritten here. INSTALLED_ANY=0 FAILED_PLUGINS="" for plugin in $WP_EXTRA_PLUGINS; do if wp_run plugin is-installed "$plugin" 2>/dev/null; then continue fi log "Installing plugin '$plugin' from WordPress.org..." if wp_run plugin install "$plugin"; then INSTALLED_ANY=1 else warn "could not install plugin '$plugin'" FAILED_PLUGINS="$FAILED_PLUGINS $plugin" fi done # ─── Plugins (both modes) ──────────────────────────────────────────────────── # Every plugin in the bind-mounted wp-content/plugins folder is the desired set: # new folders are activated, missing ones are never downloaded. Plugins bundled # with WordPress that are not part of the project are removed. FORBIDDEN_PATTERN="${FORBIDDEN_PATTERN:-^akismet$|^hello$}" if [ -n "${WP_ALLOW_FORBIDDEN_PLUGINS:-}" ]; then FORBIDDEN_PATTERN="" fi # Drop-ins (db.php, object-cache.php, ...) live in wp-content and are managed # by their parent plugin, they are never activated directly. is_dropin() { wp_run plugin list --field=name --status=dropin 2>/dev/null | grep -qx "$1" } log "Activating plugins found in $WP_PATH/wp-content/plugins..." for plugin in $(wp_run plugin list --field=name --skip-plugins --skip-themes 2>/dev/null || wp_run plugin list --field=name); do if [ -n "$FORBIDDEN_PATTERN" ] && printf '%s' "$plugin" | grep -Eq "$FORBIDDEN_PATTERN"; then continue fi if is_dropin "$plugin"; then continue fi if ! wp_run plugin is-active "$plugin" 2>/dev/null; then log "Activating plugin '$plugin'..." wp_run plugin activate "$plugin" || warn "could not activate '$plugin'" fi done for plugin in $(wp_run plugin list --field=name --skip-plugins --skip-themes 2>/dev/null || wp_run plugin list --field=name); do if [ -z "$FORBIDDEN_PATTERN" ]; then break fi if printf '%s' "$plugin" | grep -Eq "$FORBIDDEN_PATTERN"; then log "Removing unwanted plugin '$plugin'..." wp_run plugin deactivate "$plugin" 2>/dev/null || true wp_run plugin uninstall "$plugin" 2>/dev/null || true wp_run plugin delete "$plugin" 2>/dev/null || true fi done # ─── Post Views Counter (first install only) ───────────────────────────────── # Counts views for posts and pages, with manual placement so the theme decides # where the counter appears (shortcode [post-views]). Existing settings are # never changed: this only runs on a fresh installation. if [ "$MODE" = install ] && wp_run plugin is-active post-views-counter 2>/dev/null; then log "Configuring Post Views Counter (posts + pages, manual placement)..." wp_run eval ' $general = get_option( "post_views_counter_settings_general", array() ); if ( ! is_array( $general ) ) { $general = array(); } $types = isset( $general["post_types_count"] ) && is_array( $general["post_types_count"] ) ? $general["post_types_count"] : array( "post" ); foreach ( array( "post", "page" ) as $type ) { if ( ! in_array( $type, $types, true ) ) { $types[] = $type; } } $general["post_types_count"] = array_values( $types ); update_option( "post_views_counter_settings_general", $general ); $display = get_option( "post_views_counter_settings_display", array() ); if ( ! is_array( $display ) ) { $display = array(); } $display["position"] = "manual"; update_option( "post_views_counter_settings_display", $display ); $pvc = Post_Views_Counter(); if ( isset( $pvc->options ) ) { $pvc->options["general"]["post_types_count"] = $general["post_types_count"]; $pvc->options["display"]["position"] = "manual"; } echo "Post Views Counter: post_types=[post,page] position=[manual]" . PHP_EOL; ' || warn "could not configure Post Views Counter" fi # Rank Math SEO and Site Kit are installed and activated only; no wizard, SEO # settings, Search Console or Analytics connection is configured here. # ─── Redis object cache (both modes) ───────────────────────────────────────── if ! wp_run plugin is-installed redis-cache 2>/dev/null; then log "Installing Redis Object Cache plugin..." wp_run plugin install redis-cache fi if ! wp_run plugin is-active redis-cache 2>/dev/null; then log "Activating Redis Object Cache plugin..." wp_run plugin activate redis-cache || warn "could not activate redis-cache" fi # The drop-in (wp-content/object-cache.php) is what actually routes the cache to # Redis; it is (re)created when missing, stale or disabled. if ! wp_run redis status 2>/dev/null | grep -qE '^[[:space:]]*Drop-in:[[:space:]]+Valid'; then log "Enabling Redis object cache drop-in..." wp_run redis enable >/dev/null 2>&1 || wp_run redis update-dropin >/dev/null 2>&1 || true fi if [ -n "$REDIS_HOST" ]; then REDIS_STATUS="$(wp_run redis status 2>/dev/null || true)" # The status output may lead with the "Drop-ins: [...]" block, so the checks # match the key line anywhere in the output. if printf '%s' "$REDIS_STATUS" | grep -qE '^[[:space:]]*Status:[[:space:]]+Connected'; then log "Redis object cache connected." else warn "Redis object cache could not connect — check the redis service." fi if printf '%s' "$REDIS_STATUS" | grep -qE '^[[:space:]]*Drop-in:[[:space:]]+Valid'; then log "Redis drop-in (wp-content/object-cache.php) is valid." else warn "Redis drop-in is missing or invalid — re-run 'docker compose restart wpcli'." fi fi # ─── Theme (both modes) ────────────────────────────────────────────────────── # Checked on disk, not with `wp theme is-installed`: in an existing install # that check needs the database, and any hiccup made the script skip the theme. if [ ! -f "$WP_PATH/wp-content/themes/$WP_THEME/style.css" ]; then echo "[wpcli] ERROR: theme '$WP_THEME' is missing from wp-content/themes." >&2 echo "[wpcli] It is part of the repository: run 'git checkout -- wp-content/themes'" >&2 echo "[wpcli] on the host, then restart the containers." >&2 exit 1 fi # Always attempted (harmless when already active) and fatal on failure: a site # without its theme renders broken pages while the script reports success. log "Activating $WP_THEME theme..." if ! wp_run theme activate "$WP_THEME"; then echo "[wpcli] ERROR: could not activate theme '$WP_THEME'." >&2 exit 1 fi # Only project themes stay available: every other theme (the WordPress defaults) # is removed. WP_KEEP_THEMES lists themes to preserve on purpose. log "Removing leftover themes..." for theme in $(wp_run theme list --field=name 2>/dev/null); do if [ "$theme" = "$WP_THEME" ]; then continue fi if [ -n "${WP_KEEP_THEMES:-}" ] && printf '%s' " $WP_KEEP_THEMES " | grep -q " $theme "; then continue fi if wp_run theme is-active "$theme" 2>/dev/null; then continue fi log "Removing theme '$theme'..." wp_run theme delete "$theme" || warn "could not delete theme '$theme'" done # ─── Project seeder (both modes, idempotent) ───────────────────────────────── # Only fills in what is missing (pages, forms, feed, widget defaults). Content # edited by the administrator is never overwritten. if wp_run plugin is-active gustavoo-portfolio-core 2>/dev/null; then log "Running portfolio seeder..." wp_run eval 'GSO_Seeder::maybe_seed();' || warn "seeder failed" log "Seeder state: $(wp_run option get gso_portfolio_seed_state --format=json 2>/dev/null || echo '{}')" fi log "Setup complete ($MODE). Site available at $SITE_URL" # A plugin that could not be installed leaves the site without a feature the # project expects (SEO, analytics, view counts). Fail loudly instead of # reporting success: the container is restarted by the operator and the next # run simply tries again, since installation is idempotent. if [ -n "$FAILED_PLUGINS" ]; then warn "Required plugins are missing:$FAILED_PLUGINS" warn "Check network access to downloads.wordpress.org and restart the container." exit 1 fi