#!/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 "$@"