Dotfiles/sysupdate.sh

685 lines
27 KiB
Bash
Executable File

#!/usr/bin/env bash
# sysupdate — Arch Linux System Update TUI
# Usage: sysupdate [--AI]
# Deps: yay, pacman-contrib (checkupdates), curl, python3, dialog
# flatpak (optional) | claude CLI (required for --AI)
# State: /updatestate — ISO timestamp of last completed update
set -uo pipefail
# ═══════════════════════════════════════════════════════════════════════════════
# CONSTANTS & FLAGS
# ═══════════════════════════════════════════════════════════════════════════════
readonly STATE_FILE="/updatestate"
readonly SCRIPT_PREFIX="/updatescript"
readonly NEWS_FEED="https://archlinux.org/feeds/news/"
AI_MODE=false
for _arg in "$@"; do [[ "$_arg" == "--AI" ]] && AI_MODE=true; done
# ═══════════════════════════════════════════════════════════════════════════════
# TERMINAL / COLOR SETUP
# ═══════════════════════════════════════════════════════════════════════════════
if [[ -t 1 ]]; then
R=$'\033[0;31m' G=$'\033[0;32m' Y=$'\033[1;33m'
B=$'\033[0;34m' C=$'\033[0;36m' M=$'\033[0;35m'
BO=$'\033[1m' DI=$'\033[2m' RS=$'\033[0m'
else
R='' G='' Y='' B='' C='' M='' BO='' DI='' RS=''
fi
_cols() { tput cols 2>/dev/null || echo 80; }
hline() {
local ch="${1:-}" col="${2:-$DI}"
local n; n=$(_cols)
printf '%s' "$col"
printf '%*s' "$n" '' | tr ' ' "$ch"
printf '%s\n' "$RS"
}
header() {
tput clear 2>/dev/null || clear
hline "═" "$C"
local ai_tag=""; [[ "$AI_MODE" == true ]] && ai_tag=" ${M}[AI MODE]${C}"
printf " %s%s Arch Linux System Updater%s%s\n" "$C$BO" "⟨ SYSUPDATE ⟩" "$ai_tag" "$RS"
hline "═" "$C"
echo
}
section() {
echo
local title=" ┌─ $* "
local pad=$(( $(_cols) - ${#title} - 1 ))
printf '%s%s%s' "$BO$Y" "$title"
[[ $pad -gt 0 ]] && printf '─%.0s' $(seq 1 $pad)
printf '┐%s\n' "$RS"
}
log() { printf " ${B}${RS} %s\n" "$*"; }
ok() { printf " ${G}${RS} %s\n" "$*"; }
warn() { printf " ${Y}${RS} %s\n" "$*"; }
err() { printf " ${R}${RS} %s\n" "$*" >&2; }
step() { printf "\n ${C}${BO}[%d/%d]${RS} %s\n" "$1" "$2" "$3"; }
ask() {
printf " ${Y}?${RS} ${BO}%s${RS} ${DI}[Y/n]${RS} " "$*"
local r; read -r r
[[ "${r,,}" != "n" ]]
}
_spin_pid=""
spin_start() {
local msg="$*"
( local f=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') i=0
while true; do
printf "\r ${C}%s${RS} %s " "${f[$i]}" "$msg"
(( i = (i+1) % 10 )); sleep 0.08
done ) &
_spin_pid=$!
disown "$_spin_pid" 2>/dev/null
}
spin_stop() {
[[ -n "${_spin_pid:-}" ]] && { kill "$_spin_pid" 2>/dev/null; wait "$_spin_pid" 2>/dev/null || true; _spin_pid=""; }
printf '\r%*s\r' "$(_cols)" ''
}
trap 'spin_stop; tput cnorm 2>/dev/null || true' EXIT
# ═══════════════════════════════════════════════════════════════════════════════
# STATE FILE
# ═══════════════════════════════════════════════════════════════════════════════
read_state() {
if [[ -f "$STATE_FILE" ]]; then
cat "$STATE_FILE"
return
fi
# No state file — derive baseline from the running kernel's install date.
local uname_r; uname_r=$(uname -r)
local -a kpkgs=()
# 1. Discover kernel packages via /boot/vmlinuz ownership (covers all flavors)
for vmlinuz in /boot/vmlinuz-linux /boot/vmlinuz-linux-*; do
[[ -f "$vmlinuz" ]] || continue
local qo; qo=$(pacman -Qo "$vmlinuz" 2>/dev/null | awk '{print $NF}')
[[ -n "$qo" ]] && kpkgs+=("$qo")
done
# 2. Fallback: scan the pacman DB for installed linux kernel packages
if [[ ${#kpkgs[@]} -eq 0 ]]; then
mapfile -t kpkgs < <(
pacman -Qq 2>/dev/null \
| grep -E '^linux(-[a-z0-9]+(-[a-z0-9]+)*)?$' \
| grep -vE '(-headers|-docs|-firmware|-api|-utils|-tools)$' \
|| true
)
fi
# Deduplicate
mapfile -t kpkgs < <(printf '%s\n' "${kpkgs[@]:-}" | sort -u)
if [[ ${#kpkgs[@]} -eq 0 ]]; then
# No kernel package found at all — hard fallback
date -d "30 days ago" "+%Y-%m-%dT%H:%M:%S" 2>/dev/null || echo "1970-01-01T00:00:00"
return
fi
# 3. Match the running kernel's flavor to a package.
# uname -r examples:
# 6.14.5-arch1-1 → linux
# 6.6.88-1-lts → linux-lts
# 6.14.4-zen1-1-zen → linux-zen
# 6.14.4-hardened1-1-... → linux-hardened
# 6.14.5-cachyos-1 → linux-cachyos / linux-cachyos-*
local best_pkg="" best_epoch=0 found_running=false
for pkg in "${kpkgs[@]}"; do
# Extract flavor suffix: linux → "", linux-lts → "lts", linux-zen → "zen", etc.
local suffix="${pkg#linux}"; suffix="${suffix#-}"
local is_running=false
if [[ -z "$suffix" ]]; then
# Mainline: uname -r looks like N.N.N-arch<n>-<n>
[[ "$uname_r" =~ ^[0-9]+\.[0-9]+\.[0-9]+-arch ]] && is_running=true
else
# Any other flavor: check that the suffix appears in uname -r
[[ "${uname_r,,}" == *"${suffix,,}"* ]] && is_running=true
fi
local install_date; install_date=$(
pacman -Qi "$pkg" 2>/dev/null \
| awk -F': ' '/^Install Date/{sub(/^ +/,"",$2); print $2; exit}'
)
[[ -z "$install_date" ]] && continue
local epoch; epoch=$(date -d "$install_date" +%s 2>/dev/null || echo 0)
if $is_running; then
best_pkg="$pkg"; best_epoch=$epoch; found_running=true; break
fi
# Keep track of most recently installed as fallback
if [[ $epoch -gt $best_epoch ]]; then
best_pkg="$pkg"; best_epoch=$epoch
fi
done
if [[ -z "$best_pkg" ]]; then
date -d "30 days ago" "+%Y-%m-%dT%H:%M:%S" 2>/dev/null || echo "1970-01-01T00:00:00"
return
fi
local install_date; install_date=$(
pacman -Qi "$best_pkg" 2>/dev/null \
| awk -F': ' '/^Install Date/{sub(/^ +/,"",$2); print $2; exit}'
)
local iso; iso=$(date -d "$install_date" "+%Y-%m-%dT%H:%M:%S" 2>/dev/null || echo "")
if [[ -n "$iso" ]]; then
local label; $found_running && label="running kernel" || label="most recently installed kernel"
warn "No state file — using ${BO}${best_pkg}${RS} ($label) install date as baseline: $iso"
echo "$iso"
else
date -d "30 days ago" "+%Y-%m-%dT%H:%M:%S" 2>/dev/null || echo "1970-01-01T00:00:00"
fi
}
write_state() {
local ts; ts=$(date "+%Y-%m-%dT%H:%M:%S")
echo "$ts" | sudo tee "$STATE_FILE" > /dev/null
sudo chmod 644 "$STATE_FILE" 2>/dev/null || true
ok "State file updated → $STATE_FILE ($ts)"
}
# ═══════════════════════════════════════════════════════════════════════════════
# PACKAGE COLLECTION
# ═══════════════════════════════════════════════════════════════════════════════
get_pacman_updates() { checkupdates 2>/dev/null | awk '{print $1}' || true; }
get_aur_updates() { yay -Qua 2>/dev/null | awk '{print $1}' || true; }
get_flatpak_updates(){ flatpak remote-ls --updates 2>/dev/null | awk 'NR>0{print $1}' || true; }
# ═══════════════════════════════════════════════════════════════════════════════
# ARCH NEWS — Python helper written to a temp file once at startup
# ═══════════════════════════════════════════════════════════════════════════════
_NEWS_PY=$(mktemp /tmp/sysupdate-news.XXXXXX.py)
cat > "$_NEWS_PY" << 'PYEOF'
import sys, xml.etree.ElementTree as ET, email.utils, re, html, datetime, time
since_str = sys.argv[1] if len(sys.argv) > 1 else "1970-01-01T00:00:00"
try:
since_epoch = int(datetime.datetime.fromisoformat(since_str).timestamp())
except Exception:
since_epoch = 0
data = sys.stdin.read()
if not data.strip():
sys.exit(0)
try:
root = ET.fromstring(data)
except ET.ParseError:
sys.exit(0)
for item in root.findall('.//item'):
title = (item.findtext('title') or '').strip()
pubdate = (item.findtext('pubDate') or '').strip()
desc = (item.findtext('description') or '').strip()
link = (item.findtext('link') or '').strip()
try:
epoch = int(email.utils.mktime_tz(email.utils.parsedate_tz(pubdate)))
except Exception:
epoch = 0
if epoch < since_epoch:
continue
desc_clean = html.unescape(re.sub(r'<[^>]+>', ' ', desc))
desc_clean = re.sub(r'\s+', ' ', desc_clean).strip()[:1000]
try:
human = time.strftime('%Y-%m-%d', time.localtime(epoch))
except Exception:
human = pubdate
print(f"TITLE:{title}")
print(f"DATE:{human}")
print(f"LINK:{link}")
print(f"DESC:{desc_clean}")
print("---")
PYEOF
fetch_news() {
local since="$1"
curl -s --connect-timeout 10 --max-time 20 "$NEWS_FEED" 2>/dev/null \
| python3 "$_NEWS_PY" "$since" || true
}
# Fills global associative array PKG_NEWS[pkg]="formatted news text"
declare -A PKG_NEWS=()
parse_and_match_news() {
local news_raw="$1"; shift
local -a pkgs=("$@")
[[ -z "$news_raw" || ${#pkgs[@]} -eq 0 ]] && return 0
local title="" date="" link="" desc=""
while IFS= read -r line; do
if [[ "$line" == TITLE:* ]]; then title="${line#TITLE:}"
elif [[ "$line" == DATE:* ]]; then date="${line#DATE:}"
elif [[ "$line" == LINK:* ]]; then link="${line#LINK:}"
elif [[ "$line" == DESC:* ]]; then desc="${line#DESC:}"
elif [[ "$line" == "---" ]]; then
for pkg in "${pkgs[@]}"; do
[[ -z "$pkg" ]] && continue
local base="$pkg"
base="${base#lib32-}"; base="${base#python-}"; base="${base#perl-}"
base="${base%-git}"; base="${base%-bin}"; base="${base%-dev}"
local hay="${title,,} ${desc,,}"
if [[ "$hay" == *"${pkg,,}"* ]] || \
[[ ${#base} -gt 3 && "$hay" == *"${base,,}"* ]]; then
PKG_NEWS["$pkg"]+="$(printf '[%s] %s\n%s\n%s\n\n' \
"$date" "$title" "$desc" "$link")"
fi
done
title="" date="" link="" desc=""
fi
done <<< "$news_raw"
}
# ═══════════════════════════════════════════════════════════════════════════════
# PACKAGE SELECTOR — dialog checklist with fallback
# ═══════════════════════════════════════════════════════════════════════════════
declare -a SELECTED_PACMAN=()
declare -a SELECTED_AUR=()
select_packages() {
local -a p_pkgs=("${PACMAN_PKGS[@]:-}")
local -a a_pkgs=("${AUR_PKGS[@]:-}")
if ! command -v dialog &>/dev/null; then
warn "'dialog' not installed — all packages will be updated"
SELECTED_PACMAN=("${p_pkgs[@]:-}")
SELECTED_AUR=("${a_pkgs[@]:-}")
return
fi
local items=()
for pkg in "${p_pkgs[@]:-}"; do
[[ -n "$pkg" ]] && items+=("p:$pkg" "(pacman)" "on")
done
for pkg in "${a_pkgs[@]:-}"; do
[[ -n "$pkg" ]] && items+=("a:$pkg" "(AUR)" "on")
done
[[ ${#items[@]} -eq 0 ]] && return
local chosen
chosen=$(dialog --stdout --separate-output \
--title " Package Selection " \
--checklist " SPACE = toggle | ENTER = confirm | ESC = abort " \
0 60 0 "${items[@]}") || { warn "Cancelled."; exit 0; }
SELECTED_PACMAN=(); SELECTED_AUR=()
while IFS= read -r line; do
[[ -z "$line" ]] && continue
case "$line" in
p:*) SELECTED_PACMAN+=("${line#p:}") ;;
a:*) SELECTED_AUR+=("${line#a:}") ;;
esac
done <<< "$chosen"
}
# ═══════════════════════════════════════════════════════════════════════════════
# NEWS REVIEW — interactive per-package review (non-AI mode)
# ═══════════════════════════════════════════════════════════════════════════════
NEWS_DECISION="" # ok | skip | shell | quit
show_pkg_news() {
local pkg="$1" news_text="$2"
local w; w=$(_cols)
while true; do
tput clear 2>/dev/null || clear
hline "═" "$C"
printf " ${BO}NEWS FOR: ${Y}%s${RS}\n" "$pkg"
hline "═" "$C"
echo
echo "$news_text" | fold -s -w $(( w - 4 )) | sed 's/^/ /'
echo
hline "─" "$DI"
printf " ${BO}[c]${RS} Continue ${BO}[s]${RS} Skip package ${BO}[b]${RS} Open shell ${BO}[q]${RS} Quit all\n"
hline "─" "$DI"
printf " → "
local key; read -rn1 key; echo
case "${key,,}" in
c|"") NEWS_DECISION="ok"; return ;;
s) NEWS_DECISION="skip"; return ;;
b) NEWS_DECISION="shell"; return ;;
q) NEWS_DECISION="quit"; return ;;
esac
done
}
# ═══════════════════════════════════════════════════════════════════════════════
# ONE-BY-ONE UPDATERS
# ═══════════════════════════════════════════════════════════════════════════════
update_pacman_onebyone() {
local -a pkgs=("$@")
local total=${#pkgs[@]} n=0
local -a failed=()
for pkg in "${pkgs[@]}"; do
(( n++ ))
step "$n" "$total" "pacman: ${BO}$pkg${RS}"
if sudo pacman -S --noconfirm --needed "$pkg"; then
ok "$pkg"
else
err "$pkg FAILED"
failed+=("$pkg")
fi
done
[[ ${#failed[@]} -gt 0 ]] && warn "Pacman failures: ${failed[*]}"
return ${#failed[@]}
}
update_aur_onebyone() {
local -a pkgs=("$@")
local total=${#pkgs[@]} n=0
local -a failed=()
for pkg in "${pkgs[@]}"; do
(( n++ ))
step "$n" "$total" "yay: ${BO}$pkg${RS}"
if yay -S --noconfirm --answerdiff None --answerclean All --removemake "$pkg"; then
ok "$pkg"
else
err "$pkg FAILED"
failed+=("$pkg")
fi
done
[[ ${#failed[@]} -gt 0 ]] && warn "AUR failures: ${failed[*]}"
return ${#failed[@]}
}
update_flatpak() {
log "Updating flatpaks..."
if flatpak update -y; then
ok "Flatpak update complete"
else
warn "Flatpak update failed"
fi
}
# ═══════════════════════════════════════════════════════════════════════════════
# AI MODE — generate timestamped update script via Claude
# ═══════════════════════════════════════════════════════════════════════════════
ai_generate_script() {
local ts; ts=$(date "+%Y%m%d-%H%M%S")
local outfile="${SCRIPT_PREFIX}-${ts}.sh"
local pkg_list=""
for p in "${SELECTED_PACMAN[@]:-}"; do [[ -n "$p" ]] && pkg_list+=" [pacman] $p\n"; done
for p in "${SELECTED_AUR[@]:-}"; do [[ -n "$p" ]] && pkg_list+=" [aur] $p\n"; done
for p in "${FLATPAK_PKGS[@]:-}"; do [[ -n "$p" ]] && pkg_list+=" [flatpak] $p\n"; done
local news_section=""
for pkg in "${!PKG_NEWS[@]}"; do
news_section+="=== $pkg ===\n${PKG_NEWS[$pkg]}\n"
done
[[ -z "$news_section" ]] && news_section="(no relevant news items found for this update batch)"
read -r -d '' prompt << PROMPT_EOF || true
You are an Arch Linux sysadmin generating a safe one-by-one system update script.
TODAY: $(date)
LAST UPDATE: $(read_state)
OUTPUT FILE: $outfile
PACKAGES TO UPDATE:
$(printf '%b' "$pkg_list")
ARCH LINUX NEWS SINCE LAST UPDATE:
$(printf '%b' "$news_section")
RULES:
- Output ONLY valid bash starting with #!/usr/bin/env bash — zero prose.
- Track failures in a failed=() array; report at the end.
- Update order: flatpak first, then pacman packages, then AUR packages.
- Pacman one-by-one: sudo pacman -S --noconfirm --needed <pkg>
- AUR one-by-one: yay -S --noconfirm --answerdiff None --answerclean All --removemake <pkg>
- Flatpak: flatpak update -y
- If news mentions required manual steps for a package, insert them immediately before/after that package with a brief comment explaining WHY.
- On overall success, write the current ISO timestamp to ${STATE_FILE}:
echo "\$(date +%Y-%m-%dT%H:%M:%S)" | sudo tee "${STATE_FILE}" >/dev/null
PROMPT_EOF
section "AI Analysis"
spin_start "Claude is analysing news and generating update script..."
local result
if result=$(claude -p "$prompt" 2>/dev/null); then
spin_stop
# Strip markdown fences if Claude added them
result=$(printf '%s\n' "$result" | sed '/^```/d')
[[ "$result" != '#!/'* ]] && result="#!/usr/bin/env bash"$'\n'"$result"
echo "$result" | sudo tee "$outfile" > /dev/null
sudo chmod 755 "$outfile"
ok "Script saved → ${BO}$outfile${RS}"
echo
printf " ${DI}─── Preview (first 35 lines) ───${RS}\n\n"
head -35 "$outfile" | sed 's/^/ /'
echo
printf " ${DI}───────────────────────────────${RS}\n"
echo "$outfile"
else
spin_stop
err "Claude CLI unavailable or returned an error."
err "Make sure 'claude' is in PATH and authenticated."
exit 1
fi
}
# ═══════════════════════════════════════════════════════════════════════════════
# MAIN
# ═══════════════════════════════════════════════════════════════════════════════
declare -a PACMAN_PKGS=()
declare -a AUR_PKGS=()
declare -a FLATPAK_PKGS=()
main() {
header
# ── Read state ───────────────────────────────────────────────────────────
local last_update; last_update=$(read_state)
log "Last recorded update: ${BO}${last_update}${RS}"
echo
# ── Collect available updates ────────────────────────────────────────────
section "Collecting Updates"
spin_start "Checking pacman packages (checkupdates)..."
mapfile -t PACMAN_PKGS < <(get_pacman_updates)
spin_stop; log "Pacman: ${BO}${#PACMAN_PKGS[@]}${RS} package(s)"
spin_start "Checking AUR packages (yay -Qua)..."
mapfile -t AUR_PKGS < <(get_aur_updates)
spin_stop; log "AUR: ${BO}${#AUR_PKGS[@]}${RS} package(s)"
if command -v flatpak &>/dev/null; then
spin_start "Checking flatpak packages..."
mapfile -t FLATPAK_PKGS < <(get_flatpak_updates)
spin_stop; log "Flatpak: ${BO}${#FLATPAK_PKGS[@]}${RS} package(s)"
else
log "Flatpak: (not installed)"
fi
local total=$(( ${#PACMAN_PKGS[@]} + ${#AUR_PKGS[@]} + ${#FLATPAK_PKGS[@]} ))
if [[ $total -eq 0 ]]; then
echo
ok "${BO}System is up to date.${RS}"
write_state
rm -f "$_NEWS_PY"
exit 0
fi
echo; ok "${BO}$total${RS} package(s) available"
# ── Package selection via dialog checklist ───────────────────────────────
section "Package Selection"
select_packages
local total_sel=$(( ${#SELECTED_PACMAN[@]} + ${#SELECTED_AUR[@]} + ${#FLATPAK_PKGS[@]} ))
if [[ $total_sel -eq 0 ]]; then
warn "No packages selected. Exiting."; exit 0
fi
log "Will update: ${#SELECTED_PACMAN[@]} pacman | ${#SELECTED_AUR[@]} AUR | ${#FLATPAK_PKGS[@]} flatpak"
# ── Fetch and match Arch Linux news ─────────────────────────────────────
section "Arch Linux News"
local all_sel=("${SELECTED_PACMAN[@]:-}" "${SELECTED_AUR[@]:-}")
local news_raw=""
spin_start "Fetching $NEWS_FEED ..."
news_raw=$(fetch_news "$last_update")
spin_stop
if [[ -z "$news_raw" ]]; then
warn "No news fetched (no items in range, or network unavailable)"
else
parse_and_match_news "$news_raw" "${all_sel[@]:-}"
if [[ ${#PKG_NEWS[@]} -gt 0 ]]; then
warn "${BO}${#PKG_NEWS[@]}${RS} package(s) have relevant news: ${!PKG_NEWS[*]}"
else
ok "No news matched for the selected packages"
fi
fi
# ── AI mode: generate script ─────────────────────────────────────────────
if [[ "$AI_MODE" == true ]]; then
local script_path
script_path=$(ai_generate_script)
echo
printf " ${G}${BO}Script:${RS} ${BO}%s${RS}\n" "$script_path"
printf " Run it with: ${C}sudo bash %s${RS}\n\n" "$script_path"
if ask "Run the generated script now?"; then
echo
sudo bash "$script_path"
fi
rm -f "$_NEWS_PY"
exit 0
fi
# ── Non-AI mode: per-package news review ────────────────────────────────
if [[ ${#PKG_NEWS[@]} -gt 0 ]]; then
section "News Review"
log "Review news for each affected package before proceeding."
echo
local -a skip_pkgs=()
for pkg in "${!PKG_NEWS[@]}"; do
show_pkg_news "$pkg" "${PKG_NEWS[$pkg]}"
case "$NEWS_DECISION" in
skip)
skip_pkgs+=("$pkg")
ok "Will skip: $pkg"
;;
shell)
printf "\n ${Y}Opening adjustment shell — type 'exit' to return.${RS}\n\n"
PS1="${Y}[sysupdate-shell \w]\$${RS} " bash --norc --noprofile
# Ask again after returning from shell
show_pkg_news "$pkg" "${PKG_NEWS[$pkg]}"
[[ "$NEWS_DECISION" == "skip" ]] && skip_pkgs+=("$pkg") && ok "Will skip: $pkg"
[[ "$NEWS_DECISION" == "quit" ]] && { warn "Aborted."; exit 0; }
;;
quit)
warn "Aborted by user."; exit 0 ;;
esac
done
# Remove skipped packages from selection
if [[ ${#skip_pkgs[@]} -gt 0 ]]; then
local -a fp=() fa=()
for pkg in "${SELECTED_PACMAN[@]:-}"; do
printf '%s\n' "${skip_pkgs[@]}" | grep -qxF "$pkg" || fp+=("$pkg")
done
for pkg in "${SELECTED_AUR[@]:-}"; do
printf '%s\n' "${skip_pkgs[@]}" | grep -qxF "$pkg" || fa+=("$pkg")
done
SELECTED_PACMAN=("${fp[@]:-}"); SELECTED_AUR=("${fa[@]:-}")
fi
fi
# ── Final confirmation ───────────────────────────────────────────────────
section "Ready to Update"
echo
if [[ ${#SELECTED_PACMAN[@]} -gt 0 ]]; then
printf " ${BO}pacman (%d):${RS}\n" "${#SELECTED_PACMAN[@]}"
printf ' %s• %s%s\n' "$M" "$RS" "${SELECTED_PACMAN[@]}"
echo
fi
if [[ ${#SELECTED_AUR[@]} -gt 0 ]]; then
printf " ${BO}AUR (%d):${RS}\n" "${#SELECTED_AUR[@]}"
printf ' %s• %s%s\n' "$M" "$RS" "${SELECTED_AUR[@]}"
echo
fi
if [[ ${#FLATPAK_PKGS[@]} -gt 0 ]]; then
printf " ${BO}flatpak (%d):${RS}\n" "${#FLATPAK_PKGS[@]}"
printf ' %s• %s%s\n' "$M" "$RS" "${FLATPAK_PKGS[@]}"
echo
fi
ask "Proceed with update?" || { warn "Aborted."; exit 0; }
# ── Execute updates ──────────────────────────────────────────────────────
section "Updating System"
local exit_code=0
if [[ ${#FLATPAK_PKGS[@]} -gt 0 ]]; then
echo
printf " ${BO}── Flatpak ──${RS}\n"
update_flatpak || (( exit_code++ )) || true
fi
if [[ ${#SELECTED_PACMAN[@]} -gt 0 ]]; then
echo
printf " ${BO}── Pacman ──${RS}\n"
update_pacman_onebyone "${SELECTED_PACMAN[@]}" || (( exit_code++ )) || true
fi
if [[ ${#SELECTED_AUR[@]} -gt 0 ]]; then
echo
printf " ${BO}── AUR ──${RS}\n"
update_aur_onebyone "${SELECTED_AUR[@]}" || (( exit_code++ )) || true
fi
# ── Write state and finish ───────────────────────────────────────────────
echo
if [[ $exit_code -eq 0 ]]; then
write_state
hline "═" "$G"
printf " ${G}${BO}✓ Update complete!${RS}\n"
hline "═" "$G"
else
warn "Some packages failed — state file NOT updated."
hline "═" "$Y"
printf " ${Y}${BO}⚠ Update finished with errors.${RS}\n"
hline "═" "$Y"
fi
echo
rm -f "$_NEWS_PY"
}
main "$@"