Files
ServerKit/scripts/update.sh
T
Juan Denis d951a20264 fix(install/update/uninstall): robust lifecycle + masthead + schedule_card guard
- update.sh: default to pulling latest main source; clone release installs to git
- update.sh: auto-rollback on failed release swap, tarball layout detection,
  backend/instance mkdir, executable-bit restore, frontend dist build,
  force-recreate frontend container after dist rebuild
- install.sh: normalize serverkit/ vs opt/serverkit/ tarball layouts
- serverkit/uninstall.sh: install-style masthead + tagline
- ScheduleCard.jsx: guard against undefined policy to prevent dashboard crash
2026-06-22 23:24:24 -04:00

514 lines
21 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/bash
#
# ServerKit updater.
#
# Pulls the newest code (or release tarball), rebuilds, and restarts the
# stack while preserving the database, generated secrets, and .env. A failed
# health check rolls the previous tree back into place.
#
# bash /opt/serverkit/scripts/update.sh
# INSTALL_FROM_RELEASE=1 bash /opt/serverkit/scripts/update.sh
# SERVERKIT_VERSION=v1.7.0 bash /opt/serverkit/scripts/update.sh
#
set -euo pipefail
# ---------------------------------------------------------------------------
# Automatic rollback on release swap failure
# ---------------------------------------------------------------------------
cleanup_on_failure() {
local rc=$?
[ "$rc" -eq 0 ] && return 0
if [ ! -d "$INSTALL_DIR" ] && [ -d "$INSTALL_DIR.old" ]; then
warn "Update failed (exit $rc) — restoring previous installation..."
mv "$INSTALL_DIR.old" "$INSTALL_DIR"
systemctl daemon-reload 2>/dev/null || true
systemctl start "$BACKEND_SERVICE" 2>/dev/null || true
systemctl start nginx 2>/dev/null || true
warn "Rollback complete. Inspect logs: journalctl -u serverkit -n 50"
fi
return "$rc"
}
trap cleanup_on_failure EXIT
# ---------------------------------------------------------------------------
# Terminal styling (violet ServerKit identity, degrades to plain text)
# ---------------------------------------------------------------------------
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-dumb}" != "dumb" ]; then
ESC=$'\033'
RST="${ESC}[0m"; BLD="${ESC}[1m"
paint() { printf '%s[38;2;%d;%d;%dm' "$ESC" "$1" "$2" "$3"; }
else
RST=''; BLD=''
paint() { :; }
fi
V3="$(paint 139 92 246)"; V4="$(paint 124 58 237)"
PAPER="$(paint 237 233 254)"; FOG="$(paint 113 108 140)"
HUE_OK="$(paint 52 211 153)"; HUE_WARN="$(paint 250 204 21)"
HUE_ERR="$(paint 248 113 113)"; HUE_LINK="$(paint 103 232 249)"
good() { printf ' %s✔%s %s\n' "$HUE_OK" "$RST" "$1"; }
warn() { printf ' %s▴%s %s\n' "$HUE_WARN" "$RST" "$1"; }
halt() { printf ' %s✘%s %s\n' "$HUE_ERR" "$RST" "$1" >&2; exit 1; }
step() { printf ' %s❯%s %s\n' "$HUE_LINK" "$RST" "$1"; }
STARTED_AT=0
PHASE_N=0
clock() {
[ "$STARTED_AT" -gt 0 ] || { printf ''; return; }
local secs=$(( $(date +%s) - STARTED_AT ))
printf '%dm %02ds' "$((secs / 60))" "$((secs % 60))"
}
phase() {
PHASE_N=$((PHASE_N + 1))
printf '\n %s%s%02d%s %s%s%s %s%s%s\n' \
"$BLD" "$V3" "$PHASE_N" "$RST" "$BLD" "$1" "$RST" "$FOG" "$(clock)" "$RST"
printf ' %s%s%s\n\n' "$V4" "──────────────────────────────────────" "$RST"
}
# ---------------------------------------------------------------------------
# Settings
# ---------------------------------------------------------------------------
INSTALL_DIR="/opt/serverkit"
VENV_DIR="$INSTALL_DIR/venv"
BACKUP_DIR="/var/backups/serverkit"
GITHUB_REPO="${GITHUB_REPO:-jhd3197/ServerKit}"
INSTALL_FROM_RELEASE="${INSTALL_FROM_RELEASE:-0}"
BUILD_FROM_SOURCE="${BUILD_FROM_SOURCE:-0}"
SERVERKIT_VERSION="${SERVERKIT_VERSION:-}"
BACKEND_SERVICE="serverkit"
# ---------------------------------------------------------------------------
# Sanity checks
# ---------------------------------------------------------------------------
[ "$EUID" -eq 0 ] || halt "Run as root."
[ -d "$INSTALL_DIR" ] || halt "ServerKit is not installed at $INSTALL_DIR"
# ---------------------------------------------------------------------------
# Pick install mode: release tarball vs. source tree
# ---------------------------------------------------------------------------
# Default workflow: dev branch -> PR to main -> `serverkit update` on server.
# So unless the operator explicitly asks for a release tarball, we always pull
# the latest main source and rebuild. If the install came from a release
# tarball (no .git), we convert it to a shallow git checkout so future updates
# track main directly.
[ "$BUILD_FROM_SOURCE" = "1" ] && INSTALL_FROM_RELEASE=0
if [ "$INSTALL_FROM_RELEASE" != "1" ] && [ ! -d "$INSTALL_DIR/.git" ]; then
step "No source tree found — converting install to a git checkout from main..."
tmp_env=$(mktemp)
tmp_db=$(mktemp)
cp "$INSTALL_DIR/.env" "$tmp_env" 2>/dev/null || true
cp "$INSTALL_DIR/backend/instance/serverkit.db" "$tmp_db" 2>/dev/null || true
rm -rf "$INSTALL_DIR"
git clone --depth 1 --branch main "https://github.com/${GITHUB_REPO}.git" "$INSTALL_DIR" \
|| halt "Failed to clone ${GITHUB_REPO}.git"
cp "$tmp_env" "$INSTALL_DIR/.env" 2>/dev/null || true
mkdir -p "$INSTALL_DIR/backend/instance"
cp "$tmp_db" "$INSTALL_DIR/backend/instance/serverkit.db" 2>/dev/null || true
rm -f "$tmp_env" "$tmp_db"
step "Source tree cloned from main."
fi
# ---------------------------------------------------------------------------
# Self-refresh: replace this script with the release copy and re-exec, so an
# old updater can't drive a new release. Runs once (guarded by SELF_REFRESHED)
# and only in release mode. Kept inline so $@ / BASH_SOURCE resolve correctly.
# ---------------------------------------------------------------------------
if [ "${SELF_REFRESHED:-0}" != "1" ] && [ "$INSTALL_FROM_RELEASE" = "1" ] \
&& [ "${SERVERKIT_NO_SELF_REFRESH:-0}" != "1" ]; then
newest_tag=$(curl -fsSL "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" 2>/dev/null \
| grep -m1 '"tag_name"' | cut -d'"' -f4 || true)
if [ -n "$newest_tag" ]; then
upstream="https://raw.githubusercontent.com/${GITHUB_REPO}/${newest_tag}/scripts/update.sh"
tmp=$(mktemp)
if curl -fsSL "$upstream" -o "$tmp" 2>/dev/null && [ -s "$tmp" ]; then
if ! cmp -s "$tmp" "${BASH_SOURCE[0]}"; then
step "Refreshing update.sh from $newest_tag..."
cp "$tmp" "${BASH_SOURCE[0]}" 2>/dev/null || true
rm -f "$tmp"
export SELF_REFRESHED=1
exec bash "${BASH_SOURCE[0]}" "$@"
fi
fi
rm -f "$tmp"
fi
fi
# ---------------------------------------------------------------------------
# Backfill the encryption key for installs that pre-date secrets-at-rest
# ---------------------------------------------------------------------------
backfill_encryption_key() {
local env_file="$INSTALL_DIR/.env"
if [ -f "$env_file" ] && ! grep -q '^SERVERKIT_ENCRYPTION_KEY=' "$env_file"; then
step "Generating SERVERKIT_ENCRYPTION_KEY..."
local key
key=$(python3 -c 'import base64, os; print(base64.urlsafe_b64encode(os.urandom(32)).decode())')
echo "SERVERKIT_ENCRYPTION_KEY=$key" >> "$env_file"
good "Encryption key added to $env_file"
fi
}
backfill_encryption_key
# ---------------------------------------------------------------------------
# Python virtual environment (release tarballs no longer ship a venv)
# ---------------------------------------------------------------------------
locate_python() {
local c v
for c in python3.12 python3.11 python3; do
if command -v "$c" &>/dev/null; then
v=$("$c" -c 'import sys;print(".".join(map(str,sys.version_info[:2])))' 2>/dev/null || true)
if printf '%s\n%s' "3.11" "$v" | sort -C -V && \
printf '%s\n%s' "$v" "3.12" | sort -C -V; then
printf '%s' "$c"
return 0
fi
fi
done
return 1
}
rebuild_virtualenv() {
phase "Python Environment"
local py_bin
py_bin=$(locate_python) || halt "ServerKit requires Python 3.11 or 3.12."
good "Using $py_bin"
step "Recreating the virtual environment locally..."
rm -rf "$VENV_DIR"
"$py_bin" -m venv "$VENV_DIR"
# shellcheck source=/dev/null
source "$VENV_DIR/bin/activate"
step "Installing Python dependencies..."
pip install --upgrade pip --quiet
pip install -r "$INSTALL_DIR/backend/requirements.txt" --quiet
pip install gunicorn gevent gevent-websocket --quiet
good "Python environment rebuilt."
}
# ---------------------------------------------------------------------------
# Header
# ---------------------------------------------------------------------------
printf '\n %s%sServerKit Updater%s\n' "$BLD" "$PAPER" "$RST"
STARTED_AT=$(date +%s)
CURRENT_VERSION=$(cat "$INSTALL_DIR/VERSION" 2>/dev/null | tr -d '\n\r ' || echo "unknown")
step "Current version: $CURRENT_VERSION"
# ---------------------------------------------------------------------------
# Pull latest source (source-mode only)
# ---------------------------------------------------------------------------
if [ "$INSTALL_FROM_RELEASE" != "1" ] && [ -d "$INSTALL_DIR/.git" ]; then
phase "Syncing Repository"
cd "$INSTALL_DIR"
git stash -q 2>/dev/null || true
if git fetch --depth=1 origin main 2>/dev/null; then
git reset --hard FETCH_HEAD 2>&1 | tail -1
good "Repository synced to origin/main"
else
warn "Repo sync failed — keeping the existing source."
fi
fi
# ---------------------------------------------------------------------------
# Back up database + install tree
# ---------------------------------------------------------------------------
phase "Database Backup"
mkdir -p "$BACKUP_DIR"
DB_FILE="$INSTALL_DIR/backend/instance/serverkit.db"
if [ -f "$DB_FILE" ]; then
BACKUP_FILE="$BACKUP_DIR/serverkit-pre-upgrade-$(date +%Y%m%d-%H%M%S).db"
if cp "$DB_FILE" "$BACKUP_FILE"; then
good "Database backed up to $BACKUP_FILE"
else
halt "Database backup failed — aborting upgrade."
fi
else
warn "No SQLite database at $DB_FILE — skipping DB backup."
fi
BACKUP_TREE="$BACKUP_DIR/serverkit-tree-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$BACKUP_TREE"
rsync -a --exclude=venv --exclude=backups --exclude=node_modules \
"$INSTALL_DIR/" "$BACKUP_TREE/" 2>/dev/null || \
cp -a "$INSTALL_DIR" "$BACKUP_TREE" 2>/dev/null || true
good "Install tree backed up."
# ---------------------------------------------------------------------------
# Stop the stack
# ---------------------------------------------------------------------------
phase "Stopping Services"
systemctl stop "$BACKEND_SERVICE" 2>/dev/null || true
systemctl stop nginx 2>/dev/null || true
# ---------------------------------------------------------------------------
# Deploy: download a release, or rebuild from source
# ---------------------------------------------------------------------------
if [ "$INSTALL_FROM_RELEASE" = "1" ]; then
phase "Downloading Release"
case "$(uname -m)" in
x86_64) DL_ARCH="amd64" ;;
aarch64|arm64) DL_ARCH="arm64" ;;
*) halt "Unsupported architecture: $(uname -m)" ;;
esac
if [ -n "$SERVERKIT_VERSION" ]; then
RELEASE_TAG="$SERVERKIT_VERSION"
step "Pinned release: $RELEASE_TAG"
else
step "Looking up the latest release..."
RELEASE_TAG=$(curl -sf "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" \
| grep '"tag_name"' | head -1 | cut -d'"' -f4)
[ -n "$RELEASE_TAG" ] || halt "Could not determine the latest release."
good "Latest release: $RELEASE_TAG"
fi
BASE_URL="https://github.com/${GITHUB_REPO}/releases/download/${RELEASE_TAG}"
TARBALL="/tmp/serverkit-${RELEASE_TAG}-linux-${DL_ARCH}.tar.gz"
step "Downloading release tarball (${DL_ARCH})..."
curl -sfL "${BASE_URL}/serverkit-${RELEASE_TAG}-linux-${DL_ARCH}.tar.gz" -o "$TARBALL" \
|| halt "Failed to download the release tarball."
step "Unpacking release..."
STAGE="/tmp/serverkit-release-$$"
rm -rf "$STAGE"
mkdir -p "$STAGE"
tar xzf "$TARBALL" -C "$STAGE"
rm -f "$TARBALL"
# Locate the unpacked tree. Older tarballs have a top-level `serverkit/`
# directory; newer ones use `opt/serverkit/` (matches the archive prefix).
UNPACKED_DIR="$STAGE/serverkit"
if [ ! -d "$UNPACKED_DIR" ]; then
UNPACKED_DIR="$STAGE/opt/serverkit"
fi
if [ ! -d "$UNPACKED_DIR" ]; then
UNPACKED_DIR=$(find "$STAGE" -maxdepth 2 -type d -name serverkit | head -n1)
fi
[ -d "$UNPACKED_DIR" ] || halt "Release tarball layout is unrecognized (expected serverkit/ or opt/serverkit/)."
# Ensure the instance directory exists before copying the live database.
mkdir -p "$UNPACKED_DIR/backend/instance"
# Carry the live .env and database across.
cp "$INSTALL_DIR/.env" "$UNPACKED_DIR/.env" 2>/dev/null || true
cp "$INSTALL_DIR/backend/instance/serverkit.db" \
"$UNPACKED_DIR/backend/instance/serverkit.db" 2>/dev/null || true
# Remove any stale backup from a previous failed/interrupted update so
# mv doesn't try to overwrite a file/non-directory with a directory.
if [ -e "$INSTALL_DIR.old" ]; then
rm -rf "$INSTALL_DIR.old"
fi
mv "$INSTALL_DIR" "$INSTALL_DIR.old"
mv "$UNPACKED_DIR" "$INSTALL_DIR"
rm -rf "$STAGE"
# Release tarballs don't always preserve executable bits.
chmod +x "$INSTALL_DIR/serverkit"
chmod +x "$INSTALL_DIR/scripts/"*.sh 2>/dev/null || true
good "Release deployed."
# Release tarballs no longer include a pre-built venv (the absolute paths
# break on the target). Rebuild it locally from requirements.txt.
rebuild_virtualenv
# Release tarballs also don't ship pre-built frontend assets, and the
# docker-compose volume mount requires the dist/ tree on the host.
step "Rebuilding the frontend..."
cd "$INSTALL_DIR/frontend"
npm ci --prefer-offline 2>&1 | tail -3
NODE_OPTIONS="--max-old-space-size=1024" npm run build 2>&1 | tail -5
good "Frontend built."
else
phase "Updating Source"
cd "$INSTALL_DIR"
step "Pulling latest main from GitHub..."
git fetch origin
git reset --hard origin/main
good "Source updated to origin/main."
chmod +x "$INSTALL_DIR/serverkit"
chmod +x "$INSTALL_DIR/scripts/"*.sh 2>/dev/null || true
step "Updating Python dependencies..."
source "$VENV_DIR/bin/activate"
pip install -r "$INSTALL_DIR/backend/requirements.txt" --quiet
step "Syncing app templates..."
mkdir -p /etc/serverkit/templates
cp -r "$INSTALL_DIR/backend/templates/"*.yaml /etc/serverkit/templates/ 2>/dev/null || true
cp -r "$INSTALL_DIR/backend/templates/"*.yml /etc/serverkit/templates/ 2>/dev/null || true
step "Rebuilding the frontend..."
cd "$INSTALL_DIR/frontend"
npm ci --prefer-offline 2>&1 | tail -3
NODE_OPTIONS="--max-old-space-size=1024" npm run build 2>&1 | tail -5
step "Rebuilding the frontend container..."
cd "$INSTALL_DIR"
docker compose build 2>&1 | tail -5
good "Source updated."
fi
# ---------------------------------------------------------------------------
# Refresh nginx + systemd from the new tree
# ---------------------------------------------------------------------------
phase "Refreshing Configuration"
# Recover the panel domain baked into the *current* live config before we
# overwrite it -- older installs predate /etc/serverkit/panel-domain.
PRIOR_PANEL_DOMAIN=$(grep -oE '/etc/letsencrypt/live/[^/]+/' \
/etc/nginx/sites-available/serverkit.conf 2>/dev/null | head -n1 | \
sed -E 's|.*/live/([^/]+)/|\1|')
[ "$PRIOR_PANEL_DOMAIN" = "YOUR_DOMAIN" ] && PRIOR_PANEL_DOMAIN=""
if [ -f "$INSTALL_DIR/nginx/sites-available/serverkit.conf" ]; then
cp "$INSTALL_DIR/nginx/sites-available/serverkit.conf" /etc/nginx/sites-available/
fi
if [ -f "$INSTALL_DIR/nginx/sites-available/serverkit-insecure.conf" ]; then
cp "$INSTALL_DIR/nginx/sites-available/serverkit-insecure.conf" /etc/nginx/sites-available/
fi
# Re-apply the server-wide TLS floor (TLS 1.2/1.3 + AEAD ciphers) so existing
# installs get hardened on update too, not just fresh installs.
if [ -f /etc/nginx/nginx.conf ]; then
SK_CIPHERS='ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384'
if grep -qE '^[[:space:]]*ssl_protocols[[:space:]]' /etc/nginx/nginx.conf; then
sed -i -E 's|^([[:space:]]*)ssl_protocols[[:space:]].*|\1ssl_protocols TLSv1.2 TLSv1.3;|' /etc/nginx/nginx.conf
else
sed -i '/http {/a \ ssl_protocols TLSv1.2 TLSv1.3;' /etc/nginx/nginx.conf
fi
if grep -qE '^[[:space:]]*ssl_ciphers[[:space:]]' /etc/nginx/nginx.conf; then
sed -i -E "s|^([[:space:]]*)ssl_ciphers[[:space:]].*|\1ssl_ciphers ${SK_CIPHERS};|" /etc/nginx/nginx.conf
else
sed -i "/http {/a \\ ssl_ciphers ${SK_CIPHERS};" /etc/nginx/nginx.conf
fi
fi
# Preserve the user's secure/insecure SSL choice across updates.
SSL_MODE="insecure"
if [ -f /etc/serverkit/ssl-mode ]; then
SSL_MODE=$(cat /etc/serverkit/ssl-mode)
fi
if [ "$SSL_MODE" = "secure" ] && [ -f /etc/nginx/sites-available/serverkit.conf ]; then
# Recover the panel domain: prefer the value install.sh persisted, then the
# one scraped from the prior live config above. (The old .env scrape was
# broken -- SERVERKIT_PUBLIC_URL is written commented out.)
PANEL_DOMAIN="${PANEL_DOMAIN:-}"
if [ -z "$PANEL_DOMAIN" ] && [ -f /etc/serverkit/panel-domain ]; then
PANEL_DOMAIN=$(cat /etc/serverkit/panel-domain 2>/dev/null || true)
fi
[ -z "$PANEL_DOMAIN" ] && PANEL_DOMAIN="$PRIOR_PANEL_DOMAIN"
if [ -n "$PANEL_DOMAIN" ] && [ -d "/etc/letsencrypt/live/$PANEL_DOMAIN" ]; then
sed -i "s|/etc/letsencrypt/live/YOUR_DOMAIN/|/etc/letsencrypt/live/$PANEL_DOMAIN/|g" \
/etc/nginx/sites-available/serverkit.conf
ln -sf /etc/nginx/sites-available/serverkit.conf /etc/nginx/sites-enabled/serverkit.conf
else
warn "SSL mode is 'secure' but no certificate was found for '${PANEL_DOMAIN:-unknown}'."
warn "Using the HTTP config so nginx still reloads. Re-run certbot to restore HTTPS."
ln -sf /etc/nginx/sites-available/serverkit-insecure.conf /etc/nginx/sites-enabled/serverkit.conf
fi
else
ln -sf /etc/nginx/sites-available/serverkit-insecure.conf /etc/nginx/sites-enabled/serverkit.conf
fi
mkdir -p /etc/nginx/serverkit-locations
for conf in /etc/nginx/sites-enabled/*; do
[ -f "$conf" ] || continue
name=$(basename "$conf")
case "$name" in serverkit-*) continue ;; esac
if grep -Eq 'server_name[[:space:]]+_;' "$conf" 2>/dev/null; then
warn "Removing conflicting catch-all config: $name"
rm -f "/etc/nginx/sites-enabled/$name" "/etc/nginx/sites-available/$name"
fi
done
if [ -f "$INSTALL_DIR/serverkit-backend.service" ]; then
cp "$INSTALL_DIR/serverkit-backend.service" /etc/systemd/system/serverkit.service
fi
systemctl daemon-reload
good "Configuration refreshed."
# ---------------------------------------------------------------------------
# Bring the stack back up
# ---------------------------------------------------------------------------
phase "Starting Services"
systemctl start "$BACKEND_SERVICE"
cd "$INSTALL_DIR"
# Recreate the frontend container so its bind mount follows the current
# frontend/dist inode (npm run build deletes and recreates the directory).
docker compose up -d --force-recreate frontend 2>&1 | tail -5
docker compose up -d backend 2>&1 | tail -5
systemctl start nginx
good "Services started."
# ---------------------------------------------------------------------------
# Health check; restore the previous tree on failure
# ---------------------------------------------------------------------------
phase "Health Check"
roll_back() {
warn "Health check failed — rolling back..."
systemctl stop "$BACKEND_SERVICE" 2>/dev/null || true
systemctl stop nginx 2>/dev/null || true
if [ -d "$INSTALL_DIR.old" ]; then
rm -rf "$INSTALL_DIR"
mv "$INSTALL_DIR.old" "$INSTALL_DIR"
warn "Restored the previous installation from $INSTALL_DIR.old"
fi
systemctl daemon-reload
systemctl start "$BACKEND_SERVICE" 2>/dev/null || true
systemctl start nginx 2>/dev/null || true
halt "Update rolled back. Inspect logs: journalctl -u serverkit -n 50"
}
step "Waiting for the backend to answer..."
WAITED=0
while [ "$WAITED" -lt 30 ]; do
if curl -sf --max-time 5 http://127.0.0.1:5000/api/v1/system/health >/dev/null 2>&1; then
good "Backend healthy."
break
fi
sleep 2
WAITED=$((WAITED + 2))
done
[ "$WAITED" -ge 30 ] && roll_back
# Second probe as a belt-and-suspenders gate before we discard the old tree.
curl -sf --max-time 5 http://127.0.0.1:5000/api/v1/system/health >/dev/null 2>&1 || roll_back
# ---------------------------------------------------------------------------
# Cleanup + telemetry
# ---------------------------------------------------------------------------
phase "Cleanup"
[ -d "$INSTALL_DIR.old" ] && rm -rf "$INSTALL_DIR.old"
# Keep only the 10 most recent backups of each kind.
ls -t "$BACKUP_DIR"/serverkit-tree-* 2>/dev/null | tail -n +11 | xargs -r rm -rf
ls -t "$BACKUP_DIR"/serverkit-pre-upgrade-*.db 2>/dev/null | tail -n +11 | xargs -r rm -f
NEW_VERSION=$(cat "$INSTALL_DIR/VERSION" 2>/dev/null | tr -d '\n\r ' || echo "unknown")
curl -s "https://serverkit.ai/track/update?v=${NEW_VERSION}" >/dev/null 2>&1 || true
good "Cleanup complete."
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
printf '\n %s%s✔ Update complete%s %s%s%s\n\n' \
"$BLD" "$HUE_OK" "$RST" "$FOG" "$(clock)" "$RST"
printf ' Version %s\n' "$NEW_VERSION"
printf ' Backend %s\n' "$(systemctl is-active serverkit)"
printf ' Nginx %s\n\n' "$(systemctl is-active nginx)"
printf ' %sCLI%s serverkit status\n\n' "$BLD" "$RST"