MrMasterbay 18d03f0476 feat(update): install-method-aware self-update (apt / docker / source)
The in-app updater and update.sh are a git-tree + pip file update that only fits
the source/deploy.sh layout. On an apt/dpkg install the correct path is apt
upgrade (deps are system python3-* packages; a pip run diverges from dpkg and
can't lift the dpkg-owned crypto libs -> fail-closed TLS on restart); on Docker
a file update is discarded at the next image pull.

- settings.py: _detect_install_method() (docker via /.dockerenv + cgroup, apt via
  dpkg -S, else source); perform_pegaprox_update refuses on apt/docker with the
  right guidance + copy-paste command (409, allow_managed override); check-update
  reports install_method / in_app_update_supported / managed_update_{hint,command}.
- update.sh: same detection + guard before touching anything (--force override).
- settings_modal.js: the Install button becomes a package-manager / image hint
  with a copyable command on apt/docker; performUpdate handles the 409.
- tests: 5 guard/reporting tests.
2026-08-11 19:02:44 +02:00

518 lines
21 KiB
Bash
Executable File

#!/bin/bash
# ============================================================================
# PegaProx Update Script (Archive-based)
# ============================================================================
#
# Downloads the latest release as a tar.gz archive from GitHub.
# This scales to any number of files without needing to list them individually.
#
# Usage:
# ./update.sh # Normal update
# ./update.sh --force # Force update (skip version check)
#
# Note: Safe to run as root - automatically preserves original file ownership
#
# NS: Rewritten feb 2026 for archive-based updates (code split support)
# ============================================================================
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# MK May 2026 (#417 follow-up, elektronen): allow updating from a specific
# branch via `PEGAPROX_BRANCH=Testing sudo ./update.sh`. Default still main.
GITHUB_BRANCH="${PEGAPROX_BRANCH:-main}"
# GitHub URLs
GITHUB_RAW="https://raw.githubusercontent.com/PegaProx/project-pegaprox/${GITHUB_BRANCH}"
# NS: auto-generated by GitHub, no manual release needed
GITHUB_ARCHIVE="https://github.com/PegaProx/project-pegaprox/archive/refs/heads/${GITHUB_BRANCH}.tar.gz"
# Mirror (fallback if GitHub is down) — note: mirror only serves main, so
# falling back to mirror when PEGAPROX_BRANCH≠main will land you on main content.
# Acceptable trade-off since mirror is the offline-network fallback path.
MIRROR_URL="https://updates.pegaprox.com"
MIRROR_ARCHIVE="https://updates.pegaprox.com/archive/main.tar.gz"
# Find script directory (where PegaProx is installed)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# Figure out who owns this installation
ORIGINAL_OWNER=""
if [ -d "config" ]; then
ORIGINAL_OWNER=$(stat -c '%U:%G' config 2>/dev/null || stat -f '%Su:%Sg' config 2>/dev/null)
elif [ -f "cert.pem" ]; then
ORIGINAL_OWNER=$(stat -c '%U:%G' cert.pem 2>/dev/null || stat -f '%Su:%Sg' cert.pem 2>/dev/null)
elif [ -d "ssl" ]; then
ORIGINAL_OWNER=$(stat -c '%U:%G' ssl 2>/dev/null || stat -f '%Su:%Sg' ssl 2>/dev/null)
fi
echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ PegaProx Update Script ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}"
echo ""
# Check if running as root
if [ "$EUID" -eq 0 ]; then
echo -e "${BLUE}Running as root${NC}"
if [ -n "$ORIGINAL_OWNER" ]; then
echo -e " Will restore ownership to: ${GREEN}$ORIGINAL_OWNER${NC}"
fi
echo ""
else
echo -e "${YELLOW}Tip: sudo ./update.sh for auto service restart${NC}"
echo ""
fi
# NS 2026-08-11 — install-method guard. This script does a git-tree download + pip and only
# fits a source/deploy.sh layout. On an apt/dpkg-managed install the right update is
# `apt upgrade` (a git+pip run diverges from dpkg and can't lift the dpkg-owned crypto libs, so
# the service fail-closes on TLS at restart). Inside a container a file update is discarded on
# the next image pull. Detect + refuse unless --force is passed.
_pgx_install_method() {
if [ -f /.dockerenv ] || grep -qE 'docker|containerd|kubepods' /proc/1/cgroup 2>/dev/null; then
echo docker; return
fi
if command -v dpkg >/dev/null 2>&1 \
&& dpkg -S "$SCRIPT_DIR/pegaprox_multi_cluster.py" 2>/dev/null | grep -q '^pegaprox:'; then
echo apt; return
fi
echo source
}
_PGX_FORCE=0
for _a in "$@"; do
case "$_a" in --force|--allow-managed) _PGX_FORCE=1 ;; esac
done
_PGX_METHOD="$(_pgx_install_method)"
if [ "$_PGX_METHOD" != "source" ] && [ "$_PGX_FORCE" -eq 0 ]; then
echo -e "${YELLOW}This PegaProx instance was installed via: ${_PGX_METHOD}.${NC}"
if [ "$_PGX_METHOD" = "apt" ]; then
echo -e "${YELLOW}Update it through the package manager, not this script:${NC}"
echo " sudo apt update && sudo apt upgrade pegaprox"
echo -e "${YELLOW}(A git+pip update would diverge from dpkg and can break dependency handling.)${NC}"
else
echo -e "${YELLOW}Update it by pulling a fresh image and recreating the container:${NC}"
echo " docker pull ghcr.io/pegaprox/pegaprox:latest # then recreate: compose up -d / docker run"
echo -e "${YELLOW}(An in-place update here is discarded on the next image pull.)${NC}"
fi
echo ""
echo -e " Override at your own risk with: ${BLUE}./update.sh --force${NC}"
exit 0
fi
# Check current version
CURRENT_VERSION="unknown"
if [ -f "version.json" ]; then
CURRENT_VERSION=$(grep -o '"version": *"[^"]*"' version.json | cut -d'"' -f4)
fi
echo -e "Current version: ${BLUE}$CURRENT_VERSION${NC}"
# Get latest version (try GitHub first, then mirror)
echo -n "Checking for updates... "
LATEST_VERSION=$(curl -s "$GITHUB_RAW/version.json" 2>/dev/null | grep -o '"version": *"[^"]*"' | cut -d'"' -f4)
if [ -z "$LATEST_VERSION" ]; then
# GitHub down? try mirror
LATEST_VERSION=$(curl -s "$MIRROR_URL/version.json" 2>/dev/null | grep -o '"version": *"[^"]*"' | cut -d'"' -f4)
fi
if [ -z "$LATEST_VERSION" ]; then
echo -e "${RED}Failed${NC}"
echo "Could not reach GitHub or mirror. Check your internet connection."
exit 1
fi
echo -e "${GREEN}OK${NC}"
echo -e "Latest version: ${GREEN}$LATEST_VERSION${NC}"
echo ""
# MK 2026-06-07: never skip on version-equality. A prior interrupted/partial
# update can leave version.json bumped while some code files stayed stale — and
# the old "already on latest → exit" path then meant `./update.sh` could NEVER
# heal it (you had to know about --force). We now ALWAYS download the archive and
# re-apply the FULL tree, so every run guarantees every file is actually in sync.
RESYNC=0
if [ "$CURRENT_VERSION" == "$LATEST_VERSION" ]; then
RESYNC=1
echo -e "${GREEN}✓ Already on $LATEST_VERSION${NC} — re-syncing all files anyway so nothing can be left stale."
echo ""
fi
# Confirm only for an actual version change. A same-version re-sync just proceeds
# (you explicitly ran the updater and re-applying the full tree is idempotent).
if [ "$RESYNC" -eq 0 ]; then
echo -e "${YELLOW}Ready to update from $CURRENT_VERSION to $LATEST_VERSION${NC}"
echo ""
read -p "Continue? [y/N] " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Update cancelled."
exit 0
fi
fi
echo ""
echo -e "${YELLOW}Updating...${NC}"
# Create backup
BACKUP_DIR="${SCRIPT_DIR}/backups/backup_${CURRENT_VERSION}_$(date +%Y%m%d_%H%M%S)"
echo -n "Creating backup in $BACKUP_DIR... "
mkdir -p "$BACKUP_DIR"
# Backup important files (not config - that stays)
[ -f "pegaprox_multi_cluster.py" ] && cp pegaprox_multi_cluster.py "$BACKUP_DIR/"
[ -d "pegaprox" ] && cp -r pegaprox "$BACKUP_DIR/"
[ -f "web/index.html" ] && mkdir -p "$BACKUP_DIR/web" && cp web/index.html "$BACKUP_DIR/web/"
[ -f "web/index.html.original" ] && cp web/index.html.original "$BACKUP_DIR/web/"
[ -f "version.json" ] && cp version.json "$BACKUP_DIR/"
[ -f "requirements.txt" ] && cp requirements.txt "$BACKUP_DIR/"
echo -e "${GREEN}OK${NC}"
# Download release archive
echo ""
echo -n "Downloading release archive... "
TMPDIR=$(mktemp -d)
ARCHIVE="$TMPDIR/pegaprox.tar.gz"
if curl -sfL "$GITHUB_ARCHIVE" -o "$ARCHIVE" 2>/dev/null; then
echo -e "${GREEN}OK (GitHub)${NC}"
elif curl -sfL "$MIRROR_ARCHIVE" -o "$ARCHIVE" 2>/dev/null; then
echo -e "${GREEN}OK (Mirror)${NC}"
else
echo -e "${YELLOW}Archive not found, falling back to individual files...${NC}"
# Fallback: download individual files (for repos without releases)
# NS: try GitHub first, fall back to mirror
download_file() {
local file=$1
# never overwrite user data / secrets, even if they show up in the tree
case "$file" in
config/*|ssl/*|logs/*|backups/*|.git/*|*.db|*.pem|*.key|*.crt|*.enc) return 0 ;;
esac
local dir=$(dirname "$file")
[ "$dir" != "." ] && mkdir -p "$dir"
echo -n " $file... "
if curl -sfL "$GITHUB_RAW/$file" -o "$file.tmp" 2>/dev/null; then
mv "$file.tmp" "$file"
echo -e "${GREEN}OK (GitHub)${NC}"
return 0
elif curl -sfL "$MIRROR_URL/$file" -o "$file.tmp" 2>/dev/null; then
mv "$file.tmp" "$file"
echo -e "${GREEN}OK (mirror)${NC}"
return 0
else
rm -f "$file.tmp"
echo -e "${RED}FAILED${NC}"
return 1
fi
}
# MK 2026-06-07: fetch the FULL repo tree (GitHub Trees API) so the fallback
# misses nothing — same completeness as the archive path. version.json's
# hand-maintained update_files list (no globs) used to drop any file not on
# it (e.g. a freshly-added sponsor logo). update_files is now only the
# degraded-degraded path when the Trees API itself is unreachable.
echo "Fetching file list (full tree)..."
PACKAGE_FILES=$(curl -s "https://api.github.com/repos/PegaProx/project-pegaprox/git/trees/${GITHUB_BRANCH}?recursive=1" 2>/dev/null | python3 -c "
import sys, json
try:
data = json.load(sys.stdin)
for it in data.get('tree', []):
if it.get('type') == 'blob':
print(it['path'])
except:
pass
" 2>/dev/null)
if [ -z "$PACKAGE_FILES" ]; then
# Trees API unreachable → fall back to version.json's update_files list
PACKAGE_FILES=$(curl -s "$GITHUB_RAW/version.json" 2>/dev/null | python3 -c "
import sys, json
try:
data = json.load(sys.stdin)
for f in data.get('update_files', []):
print(f)
except:
pass
" 2>/dev/null)
fi
if [ -z "$PACKAGE_FILES" ]; then
# Mirror fallback
PACKAGE_FILES=$(curl -s "$MIRROR_URL/version.json" 2>/dev/null | python3 -c "
import sys, json
try:
data = json.load(sys.stdin)
for f in data.get('update_files', []):
print(f)
except:
pass
" 2>/dev/null)
fi
# NS: track fallback-download failures so a partial/mixed update aborts and
# restores from the backup instead of silently leaving a half-written tree
# (#168, thanks @x86txt). Only the per-file fallback path — the rsync/tar
# archive path stays as-is (no --delete; it would wipe offline fonts + plugins).
DOWNLOAD_FAILURES=0
if [ -n "$PACKAGE_FILES" ]; then
echo "Downloading file list from manifest..."
while IFS= read -r pfile; do
[ -z "$pfile" ] && continue
if ! download_file "$pfile"; then
DOWNLOAD_FAILURES=$((DOWNLOAD_FAILURES + 1))
fi
done <<< "$PACKAGE_FILES"
else
# absolute fallback - at least get the essentials
echo "No file list found, downloading essentials..."
for _ess in pegaprox_multi_cluster.py version.json requirements.txt deploy.sh update.sh web/index.html web/index.html.original; do
if ! download_file "$_ess"; then
DOWNLOAD_FAILURES=$((DOWNLOAD_FAILURES + 1))
fi
done
fi
if [ "$DOWNLOAD_FAILURES" -gt 0 ]; then
echo -e "${RED}Update aborted: $DOWNLOAD_FAILURES file(s) failed to download.${NC}"
echo "Restoring from backup..."
[ -f "$BACKUP_DIR/pegaprox_multi_cluster.py" ] && cp "$BACKUP_DIR/pegaprox_multi_cluster.py" . 2>/dev/null || true
[ -d "$BACKUP_DIR/pegaprox" ] && { rm -rf pegaprox && cp -r "$BACKUP_DIR/pegaprox" . 2>/dev/null; } || true
[ -d "$BACKUP_DIR/web" ] && { mkdir -p web && cp "$BACKUP_DIR/web/"* web/ 2>/dev/null; } || true
[ -f "$BACKUP_DIR/version.json" ] && cp "$BACKUP_DIR/version.json" . 2>/dev/null || true
[ -f "$BACKUP_DIR/requirements.txt" ] && cp "$BACKUP_DIR/requirements.txt" . 2>/dev/null || true
rm -rf "$TMPDIR"
exit 1
fi
rm -rf "$TMPDIR"
# Skip to pip install
ARCHIVE=""
fi
# NS Feb 2026 - verify archive integrity via SHA256 checksum
if [ -n "$ARCHIVE" ] && [ -f "$ARCHIVE" ]; then
echo -n "Verifying archive integrity... "
SHA_FILE="$TMPDIR/SHA256SUMS"
SHA_VERIFIED=false
if curl -sfL "$GITHUB_RAW/SHA256SUMS" -o "$SHA_FILE" 2>/dev/null || \
curl -sfL "$MIRROR_URL/SHA256SUMS" -o "$SHA_FILE" 2>/dev/null; then
# SHA256SUMS contains lines like: <hash> <filename>
EXPECTED=$(grep -E "${GITHUB_BRANCH}\\.tar\\.gz\$" "$SHA_FILE" 2>/dev/null | awk '{print $1}')
if [ -n "$EXPECTED" ]; then
ACTUAL=$(sha256sum "$ARCHIVE" | awk '{print $1}')
if [ "$EXPECTED" = "$ACTUAL" ]; then
echo -e "${GREEN}OK (SHA256 verified)${NC}"
SHA_VERIFIED=true
else
echo -e "${RED}CHECKSUM MISMATCH${NC}"
echo -e "${RED}Expected: $EXPECTED${NC}"
echo -e "${RED}Got: $ACTUAL${NC}"
echo -e "${RED}Archive may be corrupted or tampered with. Aborting.${NC}"
rm -rf "$TMPDIR"
exit 1
fi
else
echo -e "${YELLOW}no matching entry in SHA256SUMS${NC}"
fi
else
echo -e "${YELLOW}SHA256SUMS not available (skipping verification)${NC}"
fi
fi
# Extract archive if we got one
if [ -n "$ARCHIVE" ] && [ -f "$ARCHIVE" ]; then
echo -n "Extracting archive... "
# Extract to temp dir first, then copy (safer)
EXTRACT_DIR="$TMPDIR/extracted"
mkdir -p "$EXTRACT_DIR"
tar xzf "$ARCHIVE" -C "$EXTRACT_DIR" 2>/dev/null
# Find the actual content (might be in a subdirectory)
CONTENT_DIR="$EXTRACT_DIR"
if [ ! -f "$CONTENT_DIR/pegaprox_multi_cluster.py" ]; then
# Check one level down (GitHub archives often have a subdirectory)
for subdir in "$EXTRACT_DIR"/*/; do
if [ -f "${subdir}pegaprox_multi_cluster.py" ]; then
CONTENT_DIR="$subdir"
break
fi
done
fi
if [ -f "$CONTENT_DIR/pegaprox_multi_cluster.py" ]; then
# Copy files, preserving directory structure
# Skip: config/, ssl/, logs/, backups/, cert.pem, key.pem, .git/
if command -v rsync &> /dev/null; then
rsync -a --exclude='config/' --exclude='ssl/' --exclude='logs/' \
--exclude='backups/' --exclude='cert.pem' --exclude='key.pem' \
--exclude='.git/' --exclude='.gitignore' \
"$CONTENT_DIR/" "$SCRIPT_DIR/"
else
# Fallback: cp + tar (works without rsync)
cd "$CONTENT_DIR"
tar cf - --exclude='config' --exclude='ssl' --exclude='logs' \
--exclude='backups' --exclude='cert.pem' --exclude='key.pem' \
--exclude='.git' --exclude='.gitignore' \
. | tar xf - -C "$SCRIPT_DIR"
cd "$SCRIPT_DIR"
fi
echo -e "${GREEN}OK${NC}"
else
echo -e "${RED}FAILED${NC}"
echo "Archive does not contain pegaprox_multi_cluster.py"
echo "Restoring from backup..."
cp "$BACKUP_DIR/pegaprox_multi_cluster.py" . 2>/dev/null || true
rm -rf "$TMPDIR"
exit 1
fi
rm -rf "$TMPDIR"
fi
# MK 2026-06-07: post-copy sanity check — confirm the new version.json actually
# landed on disk. Catches a half-applied copy AND a stale CDN tarball (GitHub can
# serve an old cached <branch>.tar.gz as a 200 right after a push).
APPLIED=$(grep -o '"version": *"[^"]*"' version.json 2>/dev/null | cut -d'"' -f4)
if [ -n "$LATEST_VERSION" ] && [ "$APPLIED" != "$LATEST_VERSION" ]; then
echo -e "${YELLOW}⚠ Post-update check: version.json says '$APPLIED' but expected '$LATEST_VERSION'.${NC}"
echo -e "${YELLOW} The download may be incomplete or a stale cache — re-run ./update.sh --force in a minute.${NC}"
fi
# Make scripts executable
chmod +x deploy.sh update.sh 2>/dev/null || true
chmod +x web/Dev/build.sh 2>/dev/null || true
# Fix ownership if running as root
if [ "$EUID" -eq 0 ] && [ -n "$ORIGINAL_OWNER" ] && [ "$ORIGINAL_OWNER" != "root:root" ]; then
echo -n "Fixing file ownership ($ORIGINAL_OWNER)... "
chown -R "$ORIGINAL_OWNER" pegaprox_multi_cluster.py version.json requirements.txt 2>/dev/null
chown -R "$ORIGINAL_OWNER" deploy.sh update.sh 2>/dev/null
chown -R "$ORIGINAL_OWNER" web/ 2>/dev/null
[ -d "pegaprox" ] && chown -R "$ORIGINAL_OWNER" pegaprox/ 2>/dev/null
chown -R "$ORIGINAL_OWNER" backups/ 2>/dev/null
# images/ was missing here - left root:root on a non-root install (#633)
[ -d "images" ] && chown -R "$ORIGINAL_OWNER" images/ 2>/dev/null
# config/ too: we chmod 700 it further down, so a single root-owned file in
# there (a root-run import can create config/ssl/cert.pem) locks the service
# user out of its own certs. ORIGINAL_OWNER is read from config/ itself, so
# this only ever repairs children (#633).
[ -d "config" ] && chown -R "$ORIGINAL_OWNER" config/ 2>/dev/null
echo -e "${GREEN}OK${NC}"
fi
# Restore restrictive permissions on config and ssl directories.
# These must be 0700 so that only the service user can read the encrypted
# database and SSL private keys. An update that runs as root via sudo can
# inadvertently leave them world-readable if umask is permissive.
if [ -d "config" ]; then
chmod 700 config 2>/dev/null || true
fi
if [ -d "config/ssl" ]; then
chmod 700 config/ssl 2>/dev/null || true
elif [ -d "ssl" ]; then
chmod 700 ssl 2>/dev/null || true
fi
# Install/update Python packages
echo ""
echo -n "Installing Python packages... "
PIP_SUCCESS=false
if [ -f "venv/bin/python" ] && [ "$PIP_SUCCESS" = false ]; then
./venv/bin/python -m pip install -q -r requirements.txt 2>/dev/null && PIP_SUCCESS=true
fi
if [ -f "venv/bin/pip" ] && [ "$PIP_SUCCESS" = false ]; then
./venv/bin/pip install -q -r requirements.txt 2>/dev/null && PIP_SUCCESS=true
fi
if [ "$EUID" -eq 0 ] && command -v pip3 &> /dev/null && [ "$PIP_SUCCESS" = false ]; then
pip3 install -q -r requirements.txt 2>/dev/null && PIP_SUCCESS=true
fi
if command -v pip3 &> /dev/null && [ "$PIP_SUCCESS" = false ]; then
pip3 install -q --user -r requirements.txt 2>/dev/null && PIP_SUCCESS=true
fi
if command -v python3 &> /dev/null && [ "$PIP_SUCCESS" = false ]; then
python3 -m pip install -q --user -r requirements.txt 2>/dev/null && PIP_SUCCESS=true
fi
if [ "$PIP_SUCCESS" = true ]; then
echo -e "${GREEN}OK${NC}"
else
echo -e "${YELLOW}Couldn't install - run: pip install -r requirements.txt${NC}"
fi
# MK 2026-08-11 — preflight the crypto/TLS stack BEFORE bouncing the service. Startup is
# fail-closed (#633): if the dependency step above didn't land a loadable cryptography/
# pyOpenSSL pair (offline host, a source build that timed out, or a venv that kept the old
# version), a restart takes the service down and it won't come back. Verify a self-signed
# cert can actually be generated first; if not, leave the running service untouched.
PY_BIN="python3"
[ -x "venv/bin/python" ] && PY_BIN="venv/bin/python"
CRYPTO_OK=true
"$PY_BIN" - <<'PYEOF' >/dev/null 2>&1 || CRYPTO_OK=false
from OpenSSL import crypto
k = crypto.PKey(); k.generate_key(crypto.TYPE_RSA, 2048)
c = crypto.X509(); c.set_pubkey(k); c.sign(k, 'sha256')
PYEOF
if [ "$CRYPTO_OK" = false ]; then
echo ""
echo -e "${YELLOW}⚠ Dependency/crypto preflight FAILED — NOT restarting the service.${NC}"
echo -e "${YELLOW} The new version needs an updated cryptography/pyOpenSSL that isn't installed yet.${NC}"
echo -e "${YELLOW} The service is still running the previous version. To finish the upgrade:${NC}"
echo " 1) $PY_BIN -m pip install -r requirements.txt (needs PyPI access; watch for build errors)"
echo " 2) sudo systemctl restart pegaprox"
echo ""
echo -e " Files are on disk at version ${GREEN}$LATEST_VERSION${NC}; it goes live once deps install and the service restarts."
exit 0
fi
# Restart service
echo ""
echo -n "Restarting PegaProx service... "
if systemctl is-active --quiet pegaprox 2>/dev/null; then
if systemctl restart pegaprox 2>/dev/null; then
echo -e "${GREEN}OK${NC}"
else
echo -e "${YELLOW}Failed - restart manually${NC}"
fi
elif systemctl is-active --quiet pegaprox.service 2>/dev/null; then
if systemctl restart pegaprox.service 2>/dev/null; then
echo -e "${GREEN}OK${NC}"
else
echo -e "${YELLOW}Failed - restart manually${NC}"
fi
else
echo -e "${YELLOW}No systemd service found${NC}"
echo " If running manually, restart with: python3 pegaprox_multi_cluster.py"
fi
# Done!
echo ""
echo -e "${GREEN}╔════════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ Update Complete! ✓ ║${NC}"
echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e " Updated to version: ${GREEN}$LATEST_VERSION${NC}"
echo -e " Backup saved to: ${BLUE}$BACKUP_DIR${NC}"
echo ""
echo "If something went wrong, restore with:"
echo " cp -r $BACKUP_DIR/* ."
echo ""