Improve update-Nitrov3.sh security, reliability, and portability

- Security: replace eval-based load_branches with nameref+readarray,
  remove export MYSQL_PWD (leaks to child processes), fix URL-decode
  via Python's urllib.parse, fix JSON injection in notify via json.dumps,
  add package.json guard before sudo rm -rf
- Portability: replace seq (external) with repeat() built-in, add
  mysql/mysqldump fallback alongside mariadb, add format_size() fallback
  when numfmt is unavailable, remove -maxdepth from list_sorted helper
- Robustness: add set -o pipefail, fix spinner zombie (remove disown),
  wrap git_update/detect_best_branch in subshells to prevent cd leaks,
  capture full mvn/yarn build output to log instead of tail, add -r to
  xargs basename, make ssl-verify-server-cert configurable via .env
- UX: add --dry-run/-n flag for preview without changes
This commit is contained in:
openhands committed 2026-07-08 19:12:28 +02:00
1 parent 04e1da7caf
commit f9b0ec78d1
1 file changed
+165 -83
+165 -83
View File
@@ -1,4 +1,5 @@
#!/bin/bash #!/bin/bash
set -o pipefail
# ╔══════════════════════════════════════════════════════════════════════════════╗ # ╔══════════════════════════════════════════════════════════════════════════════╗
# ║ ║ # ║ ║
@@ -60,6 +61,7 @@ fi
# STATE # STATE
# ============================================================================= # =============================================================================
SELECTIVE_UPDATE="" SELECTIVE_UPDATE=""
DRY_RUN=false
HAD_UPDATES=false HAD_UPDATES=false
NITRO_BUILT=false NITRO_BUILT=false
ERRORS=0 ERRORS=0
@@ -76,17 +78,23 @@ if [ -f "$SCRIPT_DIR/.env" ]; then set -a; . "$SCRIPT_DIR/.env"; set +a; fi
# Parse DATABASE_URL as fallback for DB credentials (Next.js / Prisma style) # Parse DATABASE_URL as fallback for DB credentials (Next.js / Prisma style)
if [ -z "${NITRO_DB_NAME:-}" ] && [ -n "${DATABASE_URL:-}" ]; then if [ -z "${NITRO_DB_NAME:-}" ] && [ -n "${DATABASE_URL:-}" ]; then
du="${DATABASE_URL#mysql://}" eval "$(
cred="${du%%@*}" python3 -c "
hostpart="${du#*@}" import os, urllib.parse
DB_USER="${cred%%:*}" url = os.environ['DATABASE_URL']
DB_PASS="${cred#*:}" parsed = urllib.parse.urlparse(url)
DB_HOST="${hostpart%%:*}" auth = parsed.netloc.rsplit('@', 1)[0] if '@' in parsed.netloc else ''
portdb="${hostpart#*:}" user, pw = (auth.split(':', 1) + ['', ''])[:2]
DB_PORT="${portdb%%/*}" host = parsed.hostname or '127.0.0.1'
DB_NAME="${portdb#*/}" port = parsed.port or 3306
DB_NAME="${DB_NAME%%\?*}" name = parsed.path.lstrip('/').split('?')[0]
DB_PASS=$(printf '%b' "${DB_PASS//%/\\x}" 2>/dev/null || echo "$DB_PASS") print(f'DB_USER={user!r}')
print(f'DB_PASS={urllib.parse.unquote(pw)!r}')
print(f'DB_HOST={host!r}')
print(f'DB_PORT={port!r}')
print(f'DB_NAME={name!r}')
" 2>/dev/null || true
)"
fi fi
# ============================================================================= # =============================================================================
@@ -113,8 +121,17 @@ NITRO_BRANCH="${NITRO_BRANCH:-main}"
MIN_DISK_GB="${NITRO_MIN_DISK_GB:-5}" MIN_DISK_GB="${NITRO_MIN_DISK_GB:-5}"
HEALTH_RETRIES="${NITRO_HEALTH_RETRIES:-12}" HEALTH_RETRIES="${NITRO_HEALTH_RETRIES:-12}"
HEALTH_INTERVAL="${NITRO_HEALTH_INTERVAL:-5}" HEALTH_INTERVAL="${NITRO_HEALTH_INTERVAL:-5}"
MYSQL_CRED="-h $DB_HOST -P $DB_PORT -u $DB_USER --ssl-verify-server-cert=OFF" NITRO_SSL_VERIFY="${NITRO_SSL_VERIFY:-OFF}"
[ -n "$DB_PASS" ] && export MYSQL_PWD="$DB_PASS" MYSQL_CRED="-h $DB_HOST -P $DB_PORT -u $DB_USER --ssl-verify-server-cert=${NITRO_SSL_VERIFY}"
[ -n "$DB_PASS" ] && MYSQL_CRED="$MYSQL_CRED --password=$DB_PASS"
if command -v mariadb &>/dev/null; then
MYSQL_CLIENT="mariadb"; MYSQL_DUMP="mariadb-dump"
elif command -v mysql &>/dev/null; then
MYSQL_CLIENT="mysql"; MYSQL_DUMP="mysqldump"
else
MYSQL_CLIENT="mariadb"; MYSQL_DUMP="mariadb-dump"
fi
# ============================================================================= # =============================================================================
# UI HELPERS # UI HELPERS
@@ -137,18 +154,39 @@ die() {
cursor_hide() { tput civis 2>/dev/null || true; } cursor_hide() { tput civis 2>/dev/null || true; }
cursor_show() { tput cnorm 2>/dev/null || true; } cursor_show() { tput cnorm 2>/dev/null || true; }
clear_line() { printf "\r\033[K"; } clear_line() { printf "\r\033[K"; }
repeat() { local ch="${1:-=}" n="${2:-1}"; printf '%*s' "$n" '' | tr ' ' "$ch"; }
list_sorted() { local d="$1" p="$2" m="${3:-20}"; find "$d" -name "$p" -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -n "$m" | sed 's/^[0-9.]* //'; }
format_size() {
local bytes="${1:-0}"
if command -v numfmt &>/dev/null; then
numfmt --to=iec "$bytes" 2>/dev/null || echo "${bytes}B"
else
echo "${bytes}B"
fi
}
SPINNER_PID="" SPINNER_PID=""
spinner_start() { spinner_start() {
local msg="${1:-Working...}" local msg="${1:-Working...}"
local frames=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏") local frames=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏")
cursor_hide cursor_hide
( local i=0; while true; do printf "\r ${C_CYAN}${frames[$i]}${C_RESET} %s " "$msg"; i=$(( (i + 1) % ${#frames[@]} )); sleep 0.1; done ) & (
SPINNER_PID=$!; disown "$SPINNER_PID" 2>/dev/null || true local i=0
while true; do
printf "\r ${C_CYAN}${frames[$i]}${C_RESET} %s " "$msg"
i=$(( (i + 1) % ${#frames[@]} ))
sleep 0.1
done
) &
SPINNER_PID=$!
} }
spinner_stop() { spinner_stop() {
local st="${1:-ok}" local st="${1:-ok}"
[ -n "$SPINNER_PID" ] && kill "$SPINNER_PID" 2>/dev/null && wait "$SPINNER_PID" 2>/dev/null; SPINNER_PID="" if [ -n "$SPINNER_PID" ]; then
kill "$SPINNER_PID" 2>/dev/null || true
wait "$SPINNER_PID" 2>/dev/null || true
SPINNER_PID=""
fi
clear_line; cursor_show clear_line; cursor_show
case "$st" in ok) printf "\r ${C_GREEN}${E_OK}${C_RESET} Done\n" ;; warn) printf "\r ${C_YELLOW}${E_WARN}${C_RESET} Done\n" ;; fail) printf "\r ${C_RED}${E_FAIL}${C_RESET} Failed\n" ;; esac case "$st" in ok) printf "\r ${C_GREEN}${E_OK}${C_RESET} Done\n" ;; warn) printf "\r ${C_YELLOW}${E_WARN}${C_RESET} Done\n" ;; fail) printf "\r ${C_RED}${E_FAIL}${C_RESET} Failed\n" ;; esac
} }
@@ -168,15 +206,23 @@ confirm() {
[[ "$reply" =~ ^[Yy] ]] [[ "$reply" =~ ^[Yy] ]]
} }
dry_run() {
if [ "$DRY_RUN" = true ]; then
echo -e " ${C_DIM}[DRY-RUN]${C_RESET} $*"
return 0
fi
return 1
}
box_top() { box_top() {
local title="${1:-}" width="${2:-60}" local title="${1:-}" width="${2:-60}"
if [ -n "$title" ]; then if [ -n "$title" ]; then
local pad=$((width - ${#title} - 6)); [ $pad -lt 0 ] && pad=0 local pad=$((width - ${#title} - 6)); [ $pad -lt 0 ] && pad=0
printf "${C_CYAN}╔═══ ${C_BOLD}%s${C_RESET}${C_CYAN}" "$title" printf "${C_CYAN}╔═══ ${C_BOLD}%s${C_RESET}${C_CYAN}" "$title"
printf '═%.0s' $(seq 1 $pad 2>/dev/null || echo 1) repeat '═' "$pad"
echo -e "╗${C_RESET}" echo -e "╗${C_RESET}"
else else
printf "${C_CYAN}╔"; printf '═%.0s' $(seq 1 $width); echo -e "╗${C_RESET}" printf "${C_CYAN}╔"; repeat '═' "$width"; echo -e "╗${C_RESET}"
fi fi
} }
box_kv() { box_kv() {
@@ -192,7 +238,7 @@ box_line() {
local pad=$((width - ${#ct} - 4)); [ $pad -lt 0 ] && pad=0 local pad=$((width - ${#ct} - 4)); [ $pad -lt 0 ] && pad=0
printf "${C_CYAN}║${C_RESET} %s%*s${C_CYAN}║${C_RESET}\n" "$text" $pad "" printf "${C_CYAN}║${C_RESET} %s%*s${C_CYAN}║${C_RESET}\n" "$text" $pad ""
} }
box_bottom() { local w="${1:-60}"; printf "${C_CYAN}╚"; printf '═%.0s' $(seq 1 $w); echo -e "╝${C_RESET}"; } box_bottom() { local w="${1:-60}"; printf "${C_CYAN}╚"; repeat '═' "$w"; echo -e "╝${C_RESET}"; }
# ============================================================================= # =============================================================================
# NOTIFICATIONS # NOTIFICATIONS
@@ -202,9 +248,21 @@ notify() {
[ -z "$NOTIFY_URL" ] && return 0 [ -z "$NOTIFY_URL" ] && return 0
local elapsed=$(($(date +%s) - START_TIME)) local elapsed=$(($(date +%s) - START_TIME))
local ef; ef=$(printf '%dm %ds' $((elapsed / 60)) $((elapsed % 60))) local ef; ef=$(printf '%dm %ds' $((elapsed / 60)) $((elapsed % 60)))
local color="good"; [ "$status" != "SUCCESS" ] && color="danger" N_STATUS="$status" N_MESSAGE="$message" N_DURATION="$ef" \
curl -s -o /dev/null -X POST "$NOTIFY_URL" -H "Content-Type: application/json" \ python3 -c "
-d "{\"text\":\"[Nitro Update] $status\",\"attachments\":[{\"color\":\"$color\",\"fields\":[{\"title\":\"Status\",\"value\":\"$message\",\"short\":true},{\"title\":\"Duration\",\"value\":\"$ef\",\"short\":true}]}]}" 2>/dev/null || true import json, os
payload = {
'text': '[Nitro Update] ' + os.environ['N_STATUS'],
'attachments': [{
'color': 'good' if os.environ['N_STATUS'] == 'SUCCESS' else 'danger',
'fields': [
{'title': 'Status', 'value': os.environ['N_MESSAGE'], 'short': True},
{'title': 'Duration', 'value': os.environ['N_DURATION'], 'short': True}
]
}]
}
print(json.dumps(payload))
" | curl -s -o /dev/null -X POST "$NOTIFY_URL" -H "Content-Type: application/json" -d @- 2>/dev/null || true
} }
cleanup_notify_failure() { $NOTIFY_FAILED && return; NOTIFY_FAILED=true; notify "FAILED" "$1"; } cleanup_notify_failure() { $NOTIFY_FAILED && return; NOTIFY_FAILED=true; notify "FAILED" "$1"; }
@@ -212,7 +270,7 @@ cleanup_on_exit() {
local ec=$?; cursor_show local ec=$?; cursor_show
if [ $ec -ne 0 ] && [ "$ROLLBACK_NEEDED" = true ]; then if [ $ec -ne 0 ] && [ "$ROLLBACK_NEEDED" = true ]; then
echo -e "\n ${C_BG_RED}${C_WHITE}${C_BOLD} ROLLBACK ${C_RESET}" echo -e "\n ${C_BG_RED}${C_WHITE}${C_BOLD} ROLLBACK ${C_RESET}"
[ -n "$LAST_BACKUP" ] && [ -f "$LAST_BACKUP" ] && mariadb $MYSQL_CRED "$DB_NAME" < "$LAST_BACKUP" 2>/dev/null && ok "DB restored" || warn "Rollback failed" [ -n "$LAST_BACKUP" ] && [ -f "$LAST_BACKUP" ] && $MYSQL_CLIENT $MYSQL_CRED "$DB_NAME" < "$LAST_BACKUP" 2>/dev/null && ok "DB restored" || warn "Rollback failed"
fi fi
[ $ec -ne 0 ] && cleanup_notify_failure "Exit code $ec" [ $ec -ne 0 ] && cleanup_notify_failure "Exit code $ec"
} }
@@ -224,51 +282,53 @@ trap 'cursor_show; exit 1' SIGINT SIGTERM
# ============================================================================= # =============================================================================
detect_branches() { detect_branches() {
local repos=("$EMULATOR_DIR/Emulator" "$NITRO_CLIENT" "$NITRO_RENDERER") local repos=("$EMULATOR_DIR/Emulator" "$NITRO_CLIENT" "$NITRO_RENDERER")
local result=""
for repo in "${repos[@]}"; do for repo in "${repos[@]}"; do
[ -d "$repo/.git" ] || continue [ -d "$repo/.git" ] || continue
local branches (
branches=$(cd "$repo" 2>/dev/null && git branch -r 2>/dev/null | grep -v '\->' | sed 's/^[[:space:]]*//' | sed 's/^origin\///' | grep -v '^HEAD$' | sort -u) cd "$repo" 2>/dev/null || exit
while IFS= read -r b; do git branch -r 2>/dev/null | grep -v '\->' | sed 's/^[[:space:]]*//; s/^origin\///' | grep -v '^HEAD$'
[ -z "$b" ] && continue )
echo "$result" | grep -qx "$b" 2>/dev/null || result="${result:+$result done | sort -u
}$b"
done <<< "$branches"
done
echo "$result"
} }
load_branches() { load_branches() {
local arr="$1" local -n _arr="$1"
eval "$arr=()" readarray -t _arr < <(detect_branches)
while IFS= read -r b; do [ -n "$b" ] && eval "$arr+=(\"\$b\")"; done < <(detect_branches)
} }
detect_best_branch() { detect_best_branch() {
local repo="$1" preferred="$2" local repo="$1" preferred="$2"
cd "$repo" 2>/dev/null || { echo "main"; return; } (
for v in "$preferred" "${preferred^}" "main" "master"; do cd "$repo" 2>/dev/null || { echo "main"; exit 0; }
git rev-parse --verify "$v" &>/dev/null && { echo "$v"; return; } for v in "$preferred" "${preferred^}" "main" "master"; do
done git rev-parse --verify "$v" &>/dev/null && { echo "$v"; exit 0; }
echo "$(git branch --show-current 2>/dev/null || echo "main")" done
echo "$(git branch --show-current 2>/dev/null || echo "main")"
)
} }
git_update() { git_update() {
local repo="$1" branch="$2" local repo="$1" branch="$2"
cd "$repo" || { warn "Cannot access $repo"; return 1; } (
git stash --include-untracked 2>/dev/null || true cd "$repo" || { warn "Cannot access $repo"; exit 1; }
local old_head; old_head=$(git rev-parse HEAD 2>/dev/null || echo "") git stash --include-untracked 2>/dev/null || true
local rb; rb=$(detect_best_branch "$repo" "$branch") local old_head; old_head=$(git rev-parse HEAD 2>/dev/null || echo "")
[ "$rb" != "$branch" ] && info "Branch '$branch' not in $(basename "$repo"), using '$rb'" local rb; rb=$(detect_best_branch "$repo" "$branch")
git checkout "$rb" 2>/dev/null || { warn "Cannot checkout '$rb' in $(basename "$repo")"; return 1; } [ "$rb" != "$branch" ] && info "Branch '$branch' not in $(basename "$repo"), using '$rb'"
if ! git pull origin "$rb" 2>/dev/null && ! git pull 2>/dev/null; then git checkout "$rb" 2>/dev/null || { warn "Cannot checkout '$rb' in $(basename "$repo")"; exit 1; }
warn "Pull failed in $(basename "$repo")"; return 1 if ! git pull origin "$rb" 2>/dev/null && ! git pull 2>/dev/null; then
fi warn "Pull failed in $(basename "$repo")"; exit 1
[ "$(git rev-parse HEAD 2>/dev/null)" != "$old_head" ] fi
[ "$(git rev-parse HEAD 2>/dev/null)" != "$old_head" ]
)
} }
clean_node_modules() { clean_node_modules() {
rm -rf node_modules 2>/dev/null || sudo rm -rf node_modules 2>/dev/null || true if [ ! -f "package.json" ]; then
warn "clean_node_modules: no package.json found, skipping"
return
fi
rm -rf "node_modules" 2>/dev/null || sudo rm -rf "$(pwd)/node_modules" 2>/dev/null || true
} }
# ============================================================================= # =============================================================================
@@ -281,23 +341,29 @@ update_emulator() {
mkdir -p "$BACKUP_DIR" mkdir -p "$BACKUP_DIR"
local bf="$BACKUP_DIR/backup_$(date +%Y%m%d_%H%M%S).sql" local bf="$BACKUP_DIR/backup_$(date +%Y%m%d_%H%M%S).sql"
spinner_start "Backing up database..." spinner_start "Backing up database..."
if mariadb-dump $MYSQL_CRED --force --skip-lock-tables --routines --events --triggers "$DB_NAME" > "$bf" 2>/dev/null; then if $MYSQL_DUMP $MYSQL_CRED --force --skip-lock-tables --routines --events --triggers "$DB_NAME" > "$bf" 2>/dev/null; then
LAST_BACKUP="$bf" LAST_BACKUP="$bf"
local bks=$(stat -c%s "$bf" 2>/dev/null || echo 0) local bks=$(stat -c%s "$bf" 2>/dev/null || echo 0)
[ "$bks" -lt 1024 ] && { rm -f "$bf"; spinner_stop fail; die "Backup too small"; } [ "$bks" -lt 1024 ] && { rm -f "$bf"; spinner_stop fail; die "Backup too small"; }
spinner_stop ok; ok "Backup: $(numfmt --to=iec "$bks")" spinner_stop ok; ok "Backup: $(format_size "$bks")"
else else
spinner_stop fail; die "Database backup failed" spinner_stop fail; die "Database backup failed"
fi fi
if [ -d "$SQL_DIR" ]; then if [ -d "$SQL_DIR" ]; then
local sc=0 local sc=0
while IFS= read -r -d '' sf; do info "SQL: $(basename "$sf")"; mariadb $MYSQL_CRED --force "$DB_NAME" < "$sf" 2>/dev/null || warn "SQL failed: $(basename "$sf")"; sc=$((sc+1)); done < <(find "$SQL_DIR" -name '*.sql' -mmin -10 -not -path "$BACKUP_DIR/*" -print0 2>/dev/null) while IFS= read -r -d '' sf; do info "SQL: $(basename "$sf")"; $MYSQL_CLIENT $MYSQL_CRED --force "$DB_NAME" < "$sf" 2>/dev/null || warn "SQL failed: $(basename "$sf")"; sc=$((sc+1)); done < <(find "$SQL_DIR" -name '*.sql' -mmin -10 -not -path "$BACKUP_DIR/*" -print0 2>/dev/null)
[ "$sc" -gt 0 ] && ok "$sc SQL file(s) imported" [ "$sc" -gt 0 ] && ok "$sc SQL file(s) imported"
fi fi
cd "$EMULATOR_DIR/Emulator" cd "$EMULATOR_DIR/Emulator"
spinner_start "Building emulator..." spinner_start "Building emulator..."
mvn package -q 2>&1 | tail -5 && spinner_stop ok || { spinner_stop fail; die "Maven build failed"; } if mvn package -q >> "$LOG_FILE" 2>&1; then
local jar=$(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 2>/dev/null || echo "") spinner_stop ok
else
spinner_stop fail
tail -10 "$LOG_FILE" | grep -E '(ERROR|FAILURE|BUILD)' || true
die "Maven build failed (see: $LOG_FILE)"
fi
local jar=$(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 -r basename 2>/dev/null || echo "")
[ -z "$jar" ] && die "No jar found" [ -z "$jar" ] && die "No jar found"
ok "Jar: $jar" ok "Jar: $jar"
cd target/ cd target/
@@ -336,7 +402,13 @@ update_client() {
yarn install --frozen-lockfile 2>&1 || { clean_node_modules && yarn install 2>&1; } || { spinner_stop fail; die "yarn install failed"; } yarn install --frozen-lockfile 2>&1 || { clean_node_modules && yarn install 2>&1; } || { spinner_stop fail; die "yarn install failed"; }
spinner_stop ok spinner_stop ok
spinner_start "Building frontend..." spinner_start "Building frontend..."
yarn build 2>&1 | tail -3 && spinner_stop ok || { spinner_stop fail; die "yarn build failed"; } if yarn build >> "$LOG_FILE" 2>&1; then
spinner_stop ok
else
spinner_stop fail
tail -10 "$LOG_FILE" || true
die "yarn build failed (see: $LOG_FILE)"
fi
else else
info "Nitro-V3: already up to date" info "Nitro-V3: already up to date"
fi fi
@@ -453,13 +525,21 @@ show_summary() {
# ============================================================================= # =============================================================================
cmd_update() { cmd_update() {
echo "" echo ""
info "Site: ${NITRO_SITE_URL:-?} | Branch: $NITRO_BRANCH | Mode: ${SELECTIVE_UPDATE:-full}" DRY_RUN=${DRY_RUN:-false}
info "Site: ${NITRO_SITE_URL:-?} | Branch: $NITRO_BRANCH | Mode: ${SELECTIVE_UPDATE:-full}${DRY_RUN:+ [DRY-RUN]}"
info "Log: $LOG_FILE" info "Log: $LOG_FILE"
if [ "$DRY_RUN" = true ]; then
info "Dry-run: would update repos, run DB backups, build, sync configs, cleanup, restart services"
show_summary
return
fi
step 1 8 "System Diagnostics" step 1 8 "System Diagnostics"
for cmd in git mariadb mariadb-dump mvn node python3 yarn; do for cmd in git mvn node python3 yarn; do
command -v "$cmd" &>/dev/null || die "Missing: $cmd" command -v "$cmd" &>/dev/null || die "Missing: $cmd"
done done
command -v "$MYSQL_CLIENT" &>/dev/null || die "Missing: $MYSQL_CLIENT"
command -v "$MYSQL_DUMP" &>/dev/null || die "Missing: $MYSQL_DUMP"
ok "All commands available" ok "All commands available"
for d in "$EMULATOR_DIR/Emulator" "$NITRO_RENDERER" "$NITRO_CLIENT" "$NITRO_SRC_DIR"; do for d in "$EMULATOR_DIR/Emulator" "$NITRO_RENDERER" "$NITRO_CLIENT" "$NITRO_SRC_DIR"; do
@@ -472,7 +552,7 @@ cmd_update() {
[ "$ag" -lt "$MIN_DISK_GB" ] && die "Only ${ag}GB free (min ${MIN_DISK_GB}GB)" [ "$ag" -lt "$MIN_DISK_GB" ] && die "Only ${ag}GB free (min ${MIN_DISK_GB}GB)"
ok "${ag}GB disk available" ok "${ag}GB disk available"
mariadb $MYSQL_CRED -e "SELECT 1" "$DB_NAME" &>/dev/null || die "DB connection failed" $MYSQL_CLIENT $MYSQL_CRED -e "SELECT 1" "$DB_NAME" &>/dev/null || die "DB connection failed"
ok "Database connected" ok "Database connected"
case "${SELECTIVE_UPDATE:-full}" in case "${SELECTIVE_UPDATE:-full}" in
@@ -502,9 +582,9 @@ cmd_backup() {
mkdir -p "$BACKUP_DIR" mkdir -p "$BACKUP_DIR"
local bf="$BACKUP_DIR/manual_$(date +%Y%m%d_%H%M%S).sql" local bf="$BACKUP_DIR/manual_$(date +%Y%m%d_%H%M%S).sql"
spinner_start "Backing up..." spinner_start "Backing up..."
if mariadb-dump $MYSQL_CRED --force --skip-lock-tables --routines --events --triggers "$DB_NAME" > "$bf" 2>/dev/null; then if $MYSQL_DUMP $MYSQL_CRED --force --skip-lock-tables --routines --events --triggers "$DB_NAME" > "$bf" 2>/dev/null; then
local sz=$(stat -c%s "$bf" 2>/dev/null || echo 0); spinner_stop ok local sz=$(stat -c%s "$bf" 2>/dev/null || echo 0); spinner_stop ok
ok "Backup: $(numfmt --to=iec "$sz") — $(basename "$bf")" ok "Backup: $(format_size "$sz") — $(basename "$bf")"
else else
spinner_stop fail; die "Backup failed" spinner_stop fail; die "Backup failed"
fi fi
@@ -514,13 +594,13 @@ cmd_restore() {
step 1 1 "Database Restore" step 1 1 "Database Restore"
[ ! -d "$BACKUP_DIR" ] && die "No backup dir" [ ! -d "$BACKUP_DIR" ] && die "No backup dir"
local backups=() local backups=()
while IFS= read -r line; do backups+=("$line"); done < <(find "$BACKUP_DIR" -maxdepth 1 -name '*.sql' -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -20 | sed 's/^[0-9.]* //') while IFS= read -r line; do backups+=("$line"); done < <(list_sorted "$BACKUP_DIR" '*.sql')
[ ${#backups[@]} -eq 0 ] && die "No backups" [ ${#backups[@]} -eq 0 ] && die "No backups"
echo "" echo ""
echo -e " ${C_BOLD}${C_CYAN}Backups:${C_RESET}" echo -e " ${C_BOLD}${C_CYAN}Backups:${C_RESET}"
local i=1 local i=1
for bk in "${backups[@]}"; do for bk in "${backups[@]}"; do
local sz=$(numfmt --to=iec "$(stat -c%s "$bk" 2>/dev/null || echo 0)" 2>/dev/null || echo "?") local sz=$(format_size "$(stat -c%s "$bk" 2>/dev/null || echo 0)")
printf " ${C_GREEN}${C_BOLD}%2d)${C_RESET} %-40s ${C_DIM}%s${C_RESET}\n" "$i" "$(basename "$bk")" "$sz" printf " ${C_GREEN}${C_BOLD}%2d)${C_RESET} %-40s ${C_DIM}%s${C_RESET}\n" "$i" "$(basename "$bk")" "$sz"
i=$((i+1)) i=$((i+1))
done done
@@ -530,7 +610,7 @@ cmd_restore() {
local sel="${backups[$((c-1))]}" local sel="${backups[$((c-1))]}"
if ! confirm "Restore $(basename "$sel")? OVERWRITES database!"; then info "Cancelled"; return; fi if ! confirm "Restore $(basename "$sel")? OVERWRITES database!"; then info "Cancelled"; return; fi
spinner_start "Restoring..." spinner_start "Restoring..."
mariadb $MYSQL_CRED "$DB_NAME" < "$sel" 2>/dev/null && spinner_stop ok && ok "Restored" || { spinner_stop fail; die "Restore failed"; } $MYSQL_CLIENT $MYSQL_CRED "$DB_NAME" < "$sel" 2>/dev/null && spinner_stop ok && ok "Restored" || { spinner_stop fail; die "Restore failed"; }
} }
cmd_backups() { cmd_backups() {
@@ -538,14 +618,14 @@ cmd_backups() {
[ ! -d "$BACKUP_DIR" ] && { warn "No backup dir"; return; } [ ! -d "$BACKUP_DIR" ] && { warn "No backup dir"; return; }
echo "" echo ""
printf " ${C_DIM}%-4s %-38s %-10s${C_RESET}\n" "#" "FILENAME" "SIZE" printf " ${C_DIM}%-4s %-38s %-10s${C_RESET}\n" "#" "FILENAME" "SIZE"
printf " ${C_DIM}%s${C_RESET}\n" "$(printf '%0.s─' $(seq 1 56))" printf " ${C_DIM}%s${C_RESET}\n" "$(repeat '─' 56)"
local t=0 ts=0 local t=0 ts=0
while IFS= read -r l; do while IFS= read -r l; do
t=$((t+1)); local n=$(basename "$l"); local s=$(stat -c%s "$l" 2>/dev/null || echo 0); ts=$((ts+s)) t=$((t+1)); local n=$(basename "$l"); local s=$(stat -c%s "$l" 2>/dev/null || echo 0); ts=$((ts+s))
printf " ${C_GREEN}%2d${C_RESET} %-38s ${C_CYAN}%s${C_RESET}\n" "$t" "$n" "$(numfmt --to=iec "$s" 2>/dev/null || echo "?")" printf " ${C_GREEN}%2d${C_RESET} %-38s ${C_CYAN}%s${C_RESET}\n" "$t" "$n" "$(format_size "$s")"
done < <(find "$BACKUP_DIR" -maxdepth 1 -name '*.sql' -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -20 | sed 's/^[0-9.]* //') done < <(list_sorted "$BACKUP_DIR" '*.sql')
printf " ${C_DIM}%s${C_RESET}\n" "$(printf '%0.s─' $(seq 1 56))" printf " ${C_DIM}%s${C_RESET}\n" "$(repeat '─' 56)"
echo -e " ${C_BOLD}Total: $t backups — $(numfmt --to=iec "$ts" 2>/dev/null || echo "?")${C_RESET}\n" echo -e " ${C_BOLD}Total: $t backups — $(format_size "$ts")${C_RESET}\n"
} }
# ============================================================================= # =============================================================================
@@ -562,8 +642,8 @@ cmd_status() {
box_kv "CPU:" "$(awk '{printf "%.1f", $1}' /proc/loadavg 2>/dev/null || echo "?") / $(nproc 2>/dev/null || echo '?') cores" 56 box_kv "CPU:" "$(awk '{printf "%.1f", $1}' /proc/loadavg 2>/dev/null || echo "?") / $(nproc 2>/dev/null || echo '?') cores" 56
box_kv "Memory:" "$(free -m 2>/dev/null | awk '/^Mem:/{printf "%dMB / %dMB", $3, $2}')" 56 box_kv "Memory:" "$(free -m 2>/dev/null | awk '/^Mem:/{printf "%dMB / %dMB", $3, $2}')" 56
box_kv "Disk:" "$(df -h "$SCRIPT_DIR" 2>/dev/null | tail -1 | awk '{printf "%s / %s (%s)", $3, $2, $5}')" 56 box_kv "Disk:" "$(df -h "$SCRIPT_DIR" 2>/dev/null | tail -1 | awk '{printf "%s / %s (%s)", $3, $2, $5}')" 56
local dbs=$(mariadb $MYSQL_CRED -N -e "SELECT ROUND(SUM(data_length+index_length)/1024/1024,1) FROM information_schema.tables WHERE table_schema='$DB_NAME'" "$DB_NAME" 2>/dev/null || echo "?") local dbs=$($MYSQL_CLIENT $MYSQL_CRED -N -e "SELECT ROUND(SUM(data_length+index_length)/1024/1024,1) FROM information_schema.tables WHERE table_schema='$DB_NAME'" "$DB_NAME" 2>/dev/null || echo "?")
local dbt=$(mariadb $MYSQL_CRED -N -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$DB_NAME'" "$DB_NAME" 2>/dev/null || echo "?") local dbt=$($MYSQL_CLIENT $MYSQL_CRED -N -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$DB_NAME'" "$DB_NAME" 2>/dev/null || echo "?")
box_kv "Database:" "${dbs}MB / ${dbt} tables" 56; box_bottom 56 box_kv "Database:" "${dbs}MB / ${dbt} tables" 56; box_bottom 56
echo "" echo ""
box_top "SERVICES" 56 box_top "SERVICES" 56
@@ -653,18 +733,18 @@ cmd_database() {
echo -en "\n Select: "; local c; read -r c echo -en "\n Select: "; local c; read -r c
case "$c" in case "$c" in
1) box_top "DB INFO" 50 1) box_top "DB INFO" 50
local dbs=$(mariadb $MYSQL_CRED -N -e "SELECT ROUND(SUM(data_length+index_length)/1024/1024,1) FROM information_schema.tables WHERE table_schema='$DB_NAME'" "$DB_NAME" 2>/dev/null || echo "?") local dbs=$($MYSQL_CLIENT $MYSQL_CRED -N -e "SELECT ROUND(SUM(data_length+index_length)/1024/1024,1) FROM information_schema.tables WHERE table_schema='$DB_NAME'" "$DB_NAME" 2>/dev/null || echo "?")
box_kv "Name:" "$DB_NAME" 50; box_kv "Host:" "$DB_HOST:$DB_PORT" 50; box_kv "Size:" "${dbs}MB" 50 box_kv "Name:" "$DB_NAME" 50; box_kv "Host:" "$DB_HOST:$DB_PORT" 50; box_kv "Size:" "${dbs}MB" 50
box_kv "Tables:" "$(mariadb $MYSQL_CRED -N -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$DB_NAME'" "$DB_NAME" 2>/dev/null || echo '?')" 50 box_kv "Tables:" "$($MYSQL_CLIENT $MYSQL_CRED -N -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$DB_NAME'" "$DB_NAME" 2>/dev/null || echo '?')" 50
box_bottom 50 ;; box_bottom 50 ;;
2) mariadb $MYSQL_CRED -e "SHOW TABLES FROM $DB_NAME" 2>/dev/null ;; 2) $MYSQL_CLIENT $MYSQL_CRED -e "SHOW TABLES FROM $DB_NAME" 2>/dev/null ;;
3) mariadb $MYSQL_CRED -e "SELECT table_name AS 'Table', ROUND(data_length/1024/1024,2) AS 'Data MB', ROUND(index_length/1024/1024,2) AS 'Index MB' FROM information_schema.tables WHERE table_schema='$DB_NAME' ORDER BY (data_length+index_length) DESC" 2>/dev/null ;; 3) $MYSQL_CLIENT $MYSQL_CRED -e "SELECT table_name AS 'Table', ROUND(data_length/1024/1024,2) AS 'Data MB', ROUND(index_length/1024/1024,2) AS 'Index MB' FROM information_schema.tables WHERE table_schema='$DB_NAME' ORDER BY (data_length+index_length) DESC" 2>/dev/null ;;
4) spinner_start "Optimizing..." 4) spinner_start "Optimizing..."
for tbl in $(mariadb $MYSQL_CRED -N -e "SELECT table_name FROM information_schema.tables WHERE table_schema='$DB_NAME' AND engine='InnoDB'" "$DB_NAME" 2>/dev/null); do for tbl in $($MYSQL_CLIENT $MYSQL_CRED -N -e "SELECT table_name FROM information_schema.tables WHERE table_schema='$DB_NAME' AND engine='InnoDB'" "$DB_NAME" 2>/dev/null); do
mariadb $MYSQL_CRED -e "OPTIMIZE TABLE $DB_NAME.\`$tbl\`" "$DB_NAME" &>/dev/null || true $MYSQL_CLIENT $MYSQL_CRED -e "OPTIMIZE TABLE $DB_NAME.\`$tbl\`" "$DB_NAME" &>/dev/null || true
done; spinner_stop ok; ok "Optimized" ;; done; spinner_stop ok; ok "Optimized" ;;
5) echo -en " SQL: "; local q; read -r q; [ -n "$q" ] && mariadb $MYSQL_CRED "$DB_NAME" -e "$q" 2>/dev/null ;; 5) echo -en " SQL: "; local q; read -r q; [ -n "$q" ] && $MYSQL_CLIENT $MYSQL_CRED "$DB_NAME" -e "$q" 2>/dev/null ;;
6) mariadb $MYSQL_CRED -e "SELECT name, motto FROM $DB_NAME.users WHERE online='1'" 2>/dev/null || warn "Could not query" ;; 6) $MYSQL_CLIENT $MYSQL_CRED -e "SELECT name, motto FROM $DB_NAME.users WHERE online='1'" 2>/dev/null || warn "Could not query" ;;
esac esac
} }
@@ -696,12 +776,12 @@ cmd_logs() {
step 1 1 "Log Viewer" step 1 1 "Log Viewer"
[ ! -d "$LOG_DIR" ] && { warn "No log dir"; return; } [ ! -d "$LOG_DIR" ] && { warn "No log dir"; return; }
local logs=() local logs=()
while IFS= read -r l; do logs+=("$l"); done < <(find "$LOG_DIR" -name 'update-*.log' -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -20 | sed 's/^[0-9.]* //') while IFS= read -r l; do logs+=("$l"); done < <(list_sorted "$LOG_DIR" 'update-*.log')
[ ${#logs[@]} -eq 0 ] && { warn "No logs"; return; } [ ${#logs[@]} -eq 0 ] && { warn "No logs"; return; }
echo "" echo ""
local i=1 local i=1
for l in "${logs[@]}"; do for l in "${logs[@]}"; do
local sz=$(numfmt --to=iec "$(stat -c%s "$l" 2>/dev/null || echo 0)" 2>/dev/null || echo "?") local sz=$(format_size "$(stat -c%s "$l" 2>/dev/null || echo 0)")
printf " ${C_GREEN}${C_BOLD}%2d)${C_RESET} %-35s ${C_DIM}%s${C_RESET}\n" "$i" "$(basename "$l")" "$sz" printf " ${C_GREEN}${C_BOLD}%2d)${C_RESET} %-35s ${C_DIM}%s${C_RESET}\n" "$i" "$(basename "$l")" "$sz"
i=$((i+1)) i=$((i+1))
done done
@@ -962,6 +1042,7 @@ show_help() {
echo -e " ${C_CYAN}--branch, -b${C_RESET} Branch (auto-detects)" echo -e " ${C_CYAN}--branch, -b${C_RESET} Branch (auto-detects)"
echo -e " ${C_CYAN}--url, -u${C_RESET} Site URL" echo -e " ${C_CYAN}--url, -u${C_RESET} Site URL"
echo -e " ${C_CYAN}--only=${C_RESET} emulator|client|renderer|configs" echo -e " ${C_CYAN}--only=${C_RESET} emulator|client|renderer|configs"
echo -e " ${C_CYAN}--dry-run, -n${C_RESET} Preview only (no changes)"
echo -e " ${C_CYAN}--help, -h${C_RESET} Help" echo -e " ${C_CYAN}--help, -h${C_RESET} Help"
echo -e " ${C_CYAN}--version, -V${C_RESET} Version" echo -e " ${C_CYAN}--version, -V${C_RESET} Version"
echo "" echo ""
@@ -994,6 +1075,7 @@ main() {
--branch|-b) shift; NITRO_BRANCH="$1" ;; --branch|-b) shift; NITRO_BRANCH="$1" ;;
--url|-u) shift; NITRO_SITE_URL="$1" ;; --url|-u) shift; NITRO_SITE_URL="$1" ;;
--only=*) SELECTIVE_UPDATE="${1#*=}" ;; --only=*) SELECTIVE_UPDATE="${1#*=}" ;;
--dry-run|-n) DRY_RUN=true ;;
-*) echo "Unknown: $1"; exit 1 ;; -*) echo "Unknown: $1"; exit 1 ;;
*) [ -z "$cmd" ] && cmd="interactive" ;; *) [ -z "$cmd" ] && cmd="interactive" ;;
esac esac