diff --git a/sysupdate.sh b/sysupdate.sh new file mode 100644 index 0000000..945151b --- /dev/null +++ b/sysupdate.sh @@ -0,0 +1,684 @@ +#!/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- + [[ "$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 +- AUR one-by-one: yay -S --noconfirm --answerdiff None --answerclean All --removemake +- 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 "$@"