#!/bin/bash set -euo pipefail # ============================================================================= # EPIC WEB CONTROL — Enterprise Update Script # Compatible with Laravel 13 + Nitro V3 + MariaDB # ============================================================================= SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # --- Load .env --------------------------------------------------------------- if [ -f "$SCRIPT_DIR/.env" ]; then set -a . "$SCRIPT_DIR/.env" set +a fi # --- Logging ----------------------------------------------------------------- LOG_DIR="${NITRO_LOG_DIR:-$SCRIPT_DIR/storage/logs}" mkdir -p "$LOG_DIR" LOG_FILE="$LOG_DIR/update-$(date +%Y%m%d-%H%M%S).log" exec > >(tee -a "$LOG_FILE") 2>&1 STEP_NUM=0 STEP_TOTAL=8 START_TIME=$(date +%s) step() { STEP_NUM=$((STEP_NUM + 1)) echo "" echo "╔══════════════════════════════════════════════════════════════╗" printf "║ Step %d/%d — %-49s║\n" "$STEP_NUM" "$STEP_TOTAL" "$1" echo "╚══════════════════════════════════════════════════════════════╝" } info() { echo " --> $1"; } ok() { echo " [OK] $1"; } warn() { echo " [WARN] $1"; } fail() { echo " [FAIL] $1"; } die() { echo ""; echo "=== ❌ $1 ==="; cleanup_notify_failure "$1"; exit 1; } # --- Notifications ----------------------------------------------------------- NOTIFY_URL="${NITRO_NOTIFY_URL:-}" NOTIFY_FAILED=false notify() { local status="$1" local message="$2" [ -z "$NOTIFY_URL" ] && return 0 local elapsed=$(( $(date +%s) - START_TIME )) local elapsed_fmt elapsed_fmt=$(printf '%dm %ds' $((elapsed/60)) $((elapsed%60))) curl -s -o /dev/null -X POST "$NOTIFY_URL" \ -H "Content-Type: application/json" \ -d "{ \"text\": \"[Nitro Update] $status\", \"attachments\": [{ \"color\": \"$([ "$status" = "SUCCESS" ] && echo \"good\" || echo \"danger\")\", \"fields\": [ {\"title\": \"Status\", \"value\": \"$message\", \"short\": true}, {\"title\": \"Duration\", \"value\": \"$elapsed_fmt\", \"short\": true}, {\"title\": \"Server\", \"value\": \"$(hostname)\", \"short\": true}, {\"title\": \"Log\", \"value\": \"$LOG_FILE\", \"short\": false} ] }] }" 2>/dev/null || true } cleanup_notify_failure() { $NOTIFY_FAILED && return NOTIFY_FAILED=true notify "FAILED" "$1" } # --- Rollback support -------------------------------------------------------- LAST_BACKUP="" ROLLBACK_NEEDED=false rollback_db() { [ -z "$LAST_BACKUP" ] && { warn "No backup file known — cannot rollback DB."; return 1; } [ ! -f "$LAST_BACKUP" ] && { warn "Backup file not found: $LAST_BACKUP"; return 1; } info "Rolling back database from: $LAST_BACKUP" mariadb $MYSQL_CRED "$DB_NAME" < "$LAST_BACKUP" && ok "Database restored." || warn "Rollback failed." } cleanup_on_exit() { local exit_code=$? if [ $exit_code -ne 0 ] && [ "$ROLLBACK_NEEDED" = true ]; then echo "" echo "=== ⚠️ Update failed — initiating rollback ===" rollback_db fi [ $exit_code -ne 0 ] && cleanup_notify_failure "Script exited with code $exit_code" } trap cleanup_on_exit EXIT trap 'echo ""; die "Interrupted by user"' SIGINT SIGTERM # --- Pre-flight: interactive prompts (must be before unbuffer) --------------- # Site URL if [ -z "${NITRO_SITE_URL:-}" ] && [ -n "${APP_URL:-}" ]; then NITRO_SITE_URL="$APP_URL" info "Using APP_URL from .env: $NITRO_SITE_URL" fi if [ -z "${NITRO_SITE_URL:-}" ]; then read -r -p " Enter your site URL (e.g. https://example.com): " NITRO_SITE_URL NITRO_SITE_URL="${NITRO_SITE_URL%/}" [ -z "$NITRO_SITE_URL" ] && die "NITRO_SITE_URL is required" export NITRO_SITE_URL fi # Branch if [ -z "${NITRO_BRANCH:-}" ]; then read -r -p " Enter branch (main/dev) [main]: " NITRO_BRANCH NITRO_BRANCH="${NITRO_BRANCH:-main}" case "$NITRO_BRANCH" in main|dev) ;; *) die "Invalid branch '$NITRO_BRANCH'. Use main or dev." ;; esac export NITRO_BRANCH fi # Dry-run mode DRY_RUN=false if [ "${1:-}" = "--dry-run" ] || [ "${1:-}" = "-n" ]; then DRY_RUN=true echo "" info "DRY-RUN mode: no changes will be made" fi # --- Unbuffer re-exec -------------------------------------------------------- if [ -z "${_UNBUFFERED:-}" ] && command -v unbuffer &> /dev/null; then export _UNBUFFERED=1 exec unbuffer bash "$0" "$@" fi # --- Single-instance lock ---------------------------------------------------- LOCKFILE="/tmp/$(basename "$0").lock" exec 200>"$LOCKFILE" flock -n 200 || die "Another instance is already running" # --- Configuration ----------------------------------------------------------- DB_NAME="${NITRO_DB_NAME:-habbo}" DB_HOST="${NITRO_DB_HOST:-127.0.0.1}" DB_PORT="${NITRO_DB_PORT:-3306}" DB_USER="${NITRO_DB_USER:-root}" DB_PASS="${NITRO_DB_PASS:-}" EMULATOR_SERVICE="${NITRO_EMULATOR_SERVICE:-emulator}" EMULATOR_DIR="${NITRO_EMULATOR_PATH:-/var/www/emulator}" SQL_DIR="${NITRO_SQL_DIR:-$EMULATOR_DIR/Database Updates}" GAMEDATA_CONF_DIR="${NITRO_GAMEDATA_DIR:-/var/www/Gamedata/config}" NITRO_SRC_DIR="${NITRO_CLIENT_DIR:-/var/www/Nitro-V3/public/configuration}" BACKUP_DIR="${NITRO_BACKUP_DIR:-$EMULATOR_DIR/Database Updates/backups}" NITRO_CLIENT="${NITRO_CLIENT_SRC:-/var/www/Nitro-V3}" NITRO_RENDERER="${NITRO_RENDERER_SRC:-/var/www/Nitro_Render_V3}" NITRO_BRANCH="${NITRO_BRANCH:-main}" MIN_DISK_GB="${NITRO_MIN_DISK_GB:-5}" HEALTH_RETRIES="${NITRO_HEALTH_RETRIES:-12}" HEALTH_INTERVAL="${NITRO_HEALTH_INTERVAL:-5}" # Derive ws/wss protocol and domain case "$NITRO_SITE_URL" in https://*) NITRO_WS_PROTO="wss://" ; NITRO_DOMAIN="${NITRO_SITE_URL#https://}" ;; http://*) NITRO_WS_PROTO="ws://" ; NITRO_DOMAIN="${NITRO_SITE_URL#http://}" ;; *) NITRO_WS_PROTO="wss://" ; NITRO_DOMAIN="$NITRO_SITE_URL" ;; esac # Critical URLs (override via NITRO_* env vars) NITRO_IMAGE_LIBRARY_URL="${NITRO_IMAGE_LIBRARY_URL:-$NITRO_SITE_URL/gamedata/c_images/}" NITRO_HOF_FURNITURE_URL="${NITRO_HOF_FURNITURE_URL:-$NITRO_SITE_URL/gamedata/icons}" NITRO_API_URL="${NITRO_API_URL:-$NITRO_SITE_URL}" NITRO_SOCKET_URL="${NITRO_SOCKET_URL:-${NITRO_WS_PROTO}ws.${NITRO_DOMAIN}}" NITRO_CAMERA_URL="${NITRO_CAMERA_URL:-$NITRO_SITE_URL/camera/photo/}" NITRO_GAMEDATA_URL="${NITRO_GAMEDATA_URL:-$NITRO_SITE_URL/gamedata}" NITRO_ASSET_URL="${NITRO_ASSET_URL:-$NITRO_SITE_URL/gamedata/bundled}" NITRO_FURNI_ASSET_ICON_URL="${NITRO_FURNI_ASSET_ICON_URL:-$NITRO_SITE_URL/gamedata/icons/%libname%%param%_icon.png}" # MySQL credentials MYSQL_CRED="-h $DB_HOST -P $DB_PORT -u $DB_USER --ssl-verify-server-cert=OFF" [ -n "$DB_PASS" ] && export MYSQL_PWD="$DB_PASS" # ============================================================================= # PRE-FLIGHT CHECKS # ============================================================================= step "Pre-flight Checks" info "Checking required commands..." for cmd in git mariadb mariadb-dump mvn node python3 yarn; do command -v "$cmd" &>/dev/null || die "Required command not found: $cmd" done ok "All required commands available" info "Checking directories..." REQUIRED_DIRS=( "$EMULATOR_DIR/Emulator" "$NITRO_RENDERER" "$NITRO_CLIENT" "$NITRO_SRC_DIR" ) for dir in "${REQUIRED_DIRS[@]}"; do [ -d "$dir" ] || die "Required directory not found: $dir" ok "Found: $dir" done info "Checking disk space..." AVAIL_KB=$(df --output=avail "$EMULATOR_DIR" 2>/dev/null | tail -1) AVAIL_GB=$(( AVAIL_KB / 1024 / 1024 )) [ "$AVAIL_GB" -lt "$MIN_DISK_GB" ] && die "Only ${AVAIL_GB}GB free on $EMULATOR_DIR (min ${MIN_DISK_GB}GB required)" ok "${AVAIL_GB}GB disk space available" info "Checking database connection..." mariadb $MYSQL_CRED -e "SELECT 1" "$DB_NAME" &>/dev/null || die "Cannot connect to database $DB_NAME on $DB_HOST:$DB_PORT" ok "Database connection OK" if [ "$DRY_RUN" = true ]; then echo "" warn "DRY-RUN — skipping actual update" echo " Site: $NITRO_SITE_URL" echo " Branch: $NITRO_BRANCH" echo " Emulator: $EMULATOR_DIR/Emulator" echo " Nitro-V3: $NITRO_CLIENT" echo " Renderer: $NITRO_RENDERER" echo "" info "Dry-run complete. Pass --dry-run or -n to preview." exit 0 fi echo "" info "Site: $NITRO_SITE_URL | Branch: $NITRO_BRANCH" info "Log: $LOG_FILE" # ============================================================================= # HELPERS # ============================================================================= clean_node_modules() { rm -rf node_modules 2>/dev/null || sudo rm -rf node_modules 2>/dev/null || true if [ "$(stat -c '%U' .)" != "$(whoami)" ] && command -v sudo &> /dev/null; then sudo chown -R "$(whoami)":"$(whoami)" . fi } git_update() { local repo="$1" local branch="$2" cd "$repo" git stash --include-untracked || true local old_head old_head=$(git rev-parse HEAD) if ! git checkout "$branch" 2>/dev/null; then local alt="${branch^}" if [ "$alt" != "$branch" ] && git checkout "$alt" 2>/dev/null; then info "Branch '$branch' not found, using '$alt' instead" branch="$alt" else die "Branch '$branch' (or '$alt') not found in $repo" fi fi git pull [ "$(git rev-parse HEAD)" != "$old_head" ] } HAD_UPDATES=false NITRO_BUILT=false # ============================================================================= # 1. UPDATE & BUILD EMULATOR # ============================================================================= step "Update & Build Emulator" if git_update "$EMULATOR_DIR/Emulator" "$NITRO_BRANCH"; then info "New commits detected" HAD_UPDATES=true ROLLBACK_NEEDED=true # Database backup info "Creating database backup..." mkdir -p "$BACKUP_DIR" BACKUP_FILE="$BACKUP_DIR/backup_$(date +%Y%m%d_%H%M%S).sql" if mariadb-dump $MYSQL_CRED --force --skip-lock-tables --routines --events --triggers "$DB_NAME" > "$BACKUP_FILE"; then LAST_BACKUP="$BACKUP_FILE" BK_SIZE=$(stat -c%s "$BACKUP_FILE" 2>/dev/null || echo 0) [ "$BK_SIZE" -lt 1024 ] && { rm -f "$BACKUP_FILE"; die "Backup is too small (${BK_SIZE}B) — possible corruption, aborting"; } ok "Backup saved: $(numfmt --to=iec "$BK_SIZE") — $BACKUP_FILE" else die "Database backup failed" fi # SQL imports info "Checking for new SQL files..." if [ -d "$SQL_DIR" ]; then SQL_COUNT=0 while IFS= read -r -d '' sql_file; do info "Importing: $(basename "$sql_file")" mariadb $MYSQL_CRED --force "$DB_NAME" < "$sql_file" || warn "Import failed for $(basename "$sql_file")" SQL_COUNT=$((SQL_COUNT + 1)) done < <(find "$SQL_DIR" -name '*.sql' -mmin -10 -not -path "$BACKUP_DIR/*" -print0 2>/dev/null) [ "$SQL_COUNT" -gt 0 ] && ok "$SQL_COUNT SQL file(s) imported" || info "No new SQL files found" else info "SQL directory not found, skipping" fi # Maven build info "Building emulator (mvn package)..." cd "$EMULATOR_DIR/Emulator" mvn package -q || die "Maven build failed" ok "Build complete" JAR_FILE=$(find target -maxdepth 1 -name 'Habbo-*-jar-with-dependencies.jar' -printf '%T@ %p\n' 2>/dev/null | sort -rn | sed -n '1s/^[0-9.]* //p' | xargs basename) [ -z "$JAR_FILE" ] && die "No jar file found in target/" ok "Jar: $JAR_FILE" info "Updating emulator launch script..." cd target/ cat << EOF > emulator #!/bin/sh file_name_emulator=emulator.log current_time=\$(date "+%H%M_%d-%m-%Y") file_name=\$file_name_emulator.\$current_time mv /var/log/emu/emulator.log /var/log/emu/\$file_name java -Dfile.encoding=UTF8 -Xmx2G -jar $JAR_FILE EOF chmod +x emulator ok "Launch script updated" ROLLBACK_NEEDED=false else info "Already up to date, skipping" fi # ============================================================================= # 2. UPDATE NITRO_RENDER_V3 # ============================================================================= step "Update Nitro_Render_V3" if git_update "$NITRO_RENDERER" "$NITRO_BRANCH"; then info "New commits detected" HAD_UPDATES=true yarn install --frozen-lockfile || { info "Retrying with clean node_modules..."; clean_node_modules && yarn install; } || die "yarn install failed for Nitro_Render_V3" ok "Dependencies installed" else info "Already up to date, skipping" fi # ============================================================================= # 3. UPDATE & BUILD NITRO-V3 # ============================================================================= step "Update & Build Nitro-V3" if git_update "$NITRO_CLIENT" "$NITRO_BRANCH"; then info "New commits detected" HAD_UPDATES=true NITRO_BUILT=true yarn install --frozen-lockfile || { info "Retrying with clean node_modules..."; clean_node_modules && yarn install; } || die "yarn install failed for Nitro-V3" ok "Dependencies installed" info "Building Nitro-V3 (yarn build)..." yarn build || die "yarn build failed" ok "Build complete" else info "Already up to date, skipping build" fi # custom-themes/index.json mkdir -p "$NITRO_CLIENT/dist/custom-themes" if [ ! -f "$NITRO_CLIENT/dist/custom-themes/index.json" ]; then echo '{"themes":[]}' > "$NITRO_CLIENT/dist/custom-themes/index.json" ok "Created custom-themes/index.json" else ok "custom-themes/index.json exists" fi # ============================================================================= # 4. SYNC CONFIGS # ============================================================================= step "Sync Configurations" mkdir -p "$GAMEDATA_CONF_DIR" MERGE_SCRIPT="$(dirname "$0")/scripts/merge-config.cjs" NITRO_DIST_CONFIG_DIR="$NITRO_CLIENT/dist/configuration" # Make dirs writable for dir in "$NITRO_SRC_DIR" "$GAMEDATA_CONF_DIR"; do if [ -d "$dir" ] && [ ! -w "$dir" ] && command -v sudo &> /dev/null; then sudo chown -R "$(whoami)":"$(whoami)" "$dir" 2>/dev/null || true fi done EXAMPLE_COUNT=0 for example_file in "$NITRO_SRC_DIR"/*.example; do [ -f "$example_file" ] || continue base=$(basename "$example_file") target_name="${base%.example}" case "$target_name" in *.*) ;; *) target_name="$target_name.json" ;; esac node "$MERGE_SCRIPT" "$example_file" "$NITRO_SRC_DIR/$target_name" 2>/dev/null node "$MERGE_SCRIPT" "$example_file" "$GAMEDATA_CONF_DIR/$target_name" 2>/dev/null if [ -d "$NITRO_DIST_CONFIG_DIR" ]; then cp "$NITRO_SRC_DIR/$target_name" "$NITRO_DIST_CONFIG_DIR/$target_name" 2>/dev/null || true fi EXAMPLE_COUNT=$((EXAMPLE_COUNT + 1)) done ok "$EXAMPLE_COUNT .example file(s) processed" # Force critical URLs info "Applying critical config URLs..." FORCE_CONFIG_SCRIPT=$(cat << 'PYEOF' import json, sys, os paths = [ os.path.join(os.environ.get('NITRO_SRC_DIR', ''), 'renderer-config.json'), os.path.join(os.environ.get('NITRO_DIST_CONFIG_DIR', ''), 'renderer-config.json'), os.path.join(os.environ.get('NITRO_SRC_DIR', ''), 'ui-config.json'), os.path.join(os.environ.get('NITRO_DIST_CONFIG_DIR', ''), 'ui-config.json'), ] for path in paths: if not os.path.exists(path): continue with open(path, 'r') as f: data = json.load(f) for key in ['image.library.url', 'hof.furni.url', 'gamedata.url', 'asset.url']: env_val = os.environ.get(f'NITRO_{key.upper().replace(".", "_")}') if env_val: data[key] = env_val for key in ['api.url', 'socket.url', 'furni.asset.icon.url']: if key in data: env_val = os.environ.get(f'NITRO_{key.upper().replace(".", "_")}') if env_val: data[key] = env_val if 'gamedata.url' in data: data['radio.url'] = data['gamedata.url'] + '/config/radio-stations.json5?t=%timestamp%' data['soundboard.url'] = data['gamedata.url'] + '/config/soundboard-sounds.json5?t=%timestamp%' if 'show.google.ads' in data: data['show.google.ads'] = False with open(path, 'w') as f: json.dump(data, f, indent=(4 if 'dist' not in path else None), separators=(',', ':') if 'dist' in path else (',', ': ')) print(f' [OK] Fixed: {os.path.basename(path)}') print(' [OK] All config URLs synced') PYEOF ) cd "$NITRO_CLIENT" && NITRO_SRC_DIR="$NITRO_SRC_DIR" NITRO_DIST_CONFIG_DIR="$NITRO_DIST_CONFIG_DIR" \ NITRO_IMAGE_LIBRARY_URL="$NITRO_IMAGE_LIBRARY_URL" NITRO_HOF_FURNITURE_URL="$NITRO_HOF_FURNITURE_URL" \ NITRO_API_URL="$NITRO_API_URL" NITRO_SOCKET_URL="$NITRO_SOCKET_URL" \ NITRO_GAMEDATA_URL="$NITRO_GAMEDATA_URL" NITRO_ASSET_URL="$NITRO_ASSET_URL" \ NITRO_FURNI_ASSET_ICON_URL="$NITRO_FURNI_ASSET_ICON_URL" \ python3 -c "$FORCE_CONFIG_SCRIPT" # ============================================================================= # 5. CLEANUP # ============================================================================= step "Cleanup" info "Removing logs older than 14 days..." find "$EMULATOR_DIR" -name "*.log" -mtime +14 -exec rm -f {} \; 2>/dev/null || true info "Managing backups (keeping max 5)..." if [ -d "$BACKUP_DIR" ]; then find "$BACKUP_DIR" -maxdepth 1 -name '*.sql' -printf '%T@ %p\n' 2>/dev/null | sort -rn | tail -n +6 | sed 's/^[0-9.]* //' | xargs -r rm -f || true fi if [ "$HAD_UPDATES" = true ]; then info "Cleaning Yarn cache..." yarn cache clean 2>/dev/null || true info "Removing old .jar files..." find "$EMULATOR_DIR/Emulator/target" -maxdepth 1 -name 'Habbo-*-jar-with-dependencies.jar' -printf '%T@ %p\n' 2>/dev/null | sort -rn | tail -n +2 | sed 's/^[0-9.]* //' | xargs -r rm -f || true fi ok "Cleanup complete" # ============================================================================= # 6. PERMISSIONS # ============================================================================= step "Set Permissions" if command -v sudo &> /dev/null; then for dir in "$NITRO_CLIENT" "$NITRO_RENDERER" "$EMULATOR_DIR" "$GAMEDATA_CONF_DIR"; do if [ -d "$dir" ]; then sudo chown -R www-data:www-data "$dir" 2>/dev/null || warn "Could not chown $dir" fi done ok "Permissions set to www-data:www-data" else warn "sudo not available, skipping chown" fi # ============================================================================= # 7. RESTART SERVICES # ============================================================================= step "Restart Services" RESTART_OK=false if [ "$HAD_UPDATES" = true ]; then if systemctl cat "$EMULATOR_SERVICE" &>/dev/null; then if command -v sudo &> /dev/null; then info "Restarting $EMULATOR_SERVICE..." sudo systemctl restart "$EMULATOR_SERVICE" || die "Failed to restart $EMULATOR_SERVICE" RESTART_OK=true fi elif command -v pm2 &> /dev/null; then info "Restarting PM2 processes..." pm2 restart all || warn "PM2 restart had issues" RESTART_OK=true else warn "No systemd or PM2 found — restart manually" fi if [ "$RESTART_OK" = true ]; then ok "Services restarted" fi else info "No updates applied, no restart needed" fi # ============================================================================= # 8. HEALTH CHECK & VALIDATION # ============================================================================= step "Health Check & Validation" ERRORS=0 # Build output check if [ "$NITRO_BUILT" = true ]; then if [ -d "$NITRO_CLIENT/dist/assets" ]; then ASSET_COUNT=$(find "$NITRO_CLIENT/dist/assets" -type f 2>/dev/null | wc -l) ok "Nitro-V3 built: $ASSET_COUNT assets in dist/assets" else fail "Nitro-V3 build assets missing!" ERRORS=$((ERRORS + 1)) fi else info "Skipping build check (no new commits)" fi # Health check with retries for emulator if [ "$HAD_UPDATES" = true ] && systemctl cat "$EMULATOR_SERVICE" &>/dev/null; then info "Waiting for $EMULATOR_SERVICE to become active..." HEALTHY=false for i in $(seq 1 "$HEALTH_RETRIES"); do sleep "$HEALTH_INTERVAL" STATUS=$(systemctl is-active "$EMULATOR_SERVICE" 2>/dev/null || echo "unknown") if [ "$STATUS" = "active" ]; then HEALTHY=true break fi info " Attempt $i/$HEALTH_RETRIES — status: $STATUS" done if [ "$HEALTHY" = true ]; then ok "$EMULATOR_SERVICE is active ($(( i * HEALTH_INTERVAL ))s to start)" else fail "$EMULATOR_SERVICE did not become active after ${HEALTH_RETRIES} retries" ERRORS=$((ERRORS + 1)) fi fi # Permission audit for dir in "$NITRO_CLIENT" "$NITRO_RENDERER" "$EMULATOR_DIR" "$GAMEDATA_CONF_DIR"; do if [ -d "$dir" ]; then OWNER=$(stat -c '%U:%G' "$dir" 2>/dev/null || echo "unknown") if [ "$OWNER" = "www-data:www-data" ] || [ "$(id -u)" -ne 0 ]; then ok "$(basename "$dir") ($OWNER)" else warn "$(basename "$dir") owner is $OWNER (should be www-data:www-data)" sudo chown -R www-data:www-data "$dir" 2>/dev/null || true fi fi done # ============================================================================= # SUMMARY # ============================================================================= ELAPSED=$(( $(date +%s) - START_TIME )) ELAPSED_FMT=$(printf '%dm %ds' $((ELAPSED/60)) $((ELAPSED%60))) echo "" echo "╔══════════════════════════════════════════════════════════════╗" if [ "$ERRORS" -eq 0 ]; then echo "║ ✅ UPDATE SUCCESSFULLY COMPLETED ║" else echo "║ ⚠️ UPDATE COMPLETED WITH $ERRORS WARNING(S) ║" fi echo "╠══════════════════════════════════════════════════════════════╣" printf "║ Duration: %-46s║\n" "$ELAPSED_FMT" printf "║ Updates: %-46s║\n" "$([ "$HAD_UPDATES" = true ] && echo "Yes" || echo "No")" printf "║ Nitro build: %-46s║\n" "$([ "$NITRO_BUILT" = true ] && echo "Yes" || echo "No")" printf "║ Log file: %-46s║\n" "$LOG_FILE" echo "╚══════════════════════════════════════════════════════════════╝" echo "" notify "SUCCESS" "Completed in $ELAPSED_FMT (${ERRORS} warnings)"