From 710dfdfd2d37fdf80480ea574d50017f137dbc05 Mon Sep 17 00:00:00 2001 From: eka Date: Fri, 30 Oct 2026 17:19:13 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=81=D0=BA=D1=80=D0=B8=D0=BF=D1=82=20?= =?UTF-8?q?=D1=87=D0=B8=D1=81=D1=82=D0=BA=D0=B8=20orphan-=D0=BF=D0=B0?= =?UTF-8?q?=D0=BA=D0=B5=D1=82=D0=BE=D0=B2=20=D0=B8=20=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=80=D1=8B=D1=85=20=D0=B1=D1=8D=D0=BA=D0=B5=D0=BD=D0=B4=D0=BE?= =?UTF-8?q?=D0=B2=20LM=20Studio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cleanup-packages.sh с подкомандами check/plan/apply/rollback. Дефолт — ничего не делать. apply: план → подтверждение 'yes' → бэкап в ~/.local/state/cleanup-packages// → удаление. Защищённый список: linux-meta, linux-nvidia-meta, linux618-nvidia, python-pkg_resources, boost. Бэкенды — по фиксированному белому списку, vendor/ и cuda12-avx2-2.67.0 не трогаются. --- scripts/cleanup-packages.sh | 633 ++++++++++++++++++++++++++++++++++++ 1 file changed, 633 insertions(+) create mode 100755 scripts/cleanup-packages.sh diff --git a/scripts/cleanup-packages.sh b/scripts/cleanup-packages.sh new file mode 100755 index 0000000..113b473 --- /dev/null +++ b/scripts/cleanup-packages.sh @@ -0,0 +1,633 @@ +#!/usr/bin/env bash +# ============================================================ +# cleanup-packages.sh — удаление orphan-пакетов pacman и старых +# бэкендов LM Studio (Manjaro/Arch) +# +# Подкоманды: +# check — состояние: что найдено, что исключено, прогноз (read-only) +# plan — точный список того, что будет удалено (read-only) +# apply — бэкап → план → подтверждение → удаление +# rollback — переустановить пакеты, удалённые последним apply +# +# Дефолтное поведение — НИЧЕГО НЕ ДЕЛАТЬ. +# Без аргументов скрипт печатает usage и выходит с кодом 2. +# +# Скрипт НЕ трогает: кэши huggingface/npm/Code, journal, перенос файлов. +# Только две группы: +# A — orphan-пакеты pacman (минус защищённый список из 5 пакетов) +# B — 13 старых бэкендов LM Studio из фиксированного белого списка +# +# Требования к pacman (проверено на pacman 7.1.0): +# * `pacman -R --print` печатает цели в stdout; stderr — только ошибки. +# Перехват через 2>&1 работает в обоих случаях. +# * `-n` (=--nosave) несовместим с `--print` → в плане только `-R` +# * реальное удаление: `sudo pacman -Rns` (конфиги уходят в .pacsave) +# ============================================================ +set -euo pipefail + +SCRIPT_NAME="${0##*/}" + +# Каталог с бэкапами apply (используется и в rollback). +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/cleanup-packages" + +# Корневой каталог бэкендов LM Studio. +BACKENDS_ROOT="$HOME/.lmstudio/extensions/backends" + +# ============================================================ +# Константы предметной области +# ============================================================ + +# Пакеты-исключения: orphan'ы, которые удалять НЕЛЬЗЯ. +# linux-meta, linux-nvidia-meta — переключатели ядра по умолчанию (0 байт) +# linux618-nvidia — модули nvidia для LTS-ядра 6.18.55 +# (иначе чёрный экран при загрузке с 6.18) +# python-pkg_resources — AUR-пакет, нужен pip +# boost — нужен для сборки AUR-пакетов +PKG_EXCLUDE_RE='^(linux-meta|linux-nvidia-meta|linux618-nvidia|python-pkg_resources|boost)$' + +# ГРУППА B — белый список бэкендов LM Studio к удалению. +# Зашит именно список (безопаснее, чем «оставить N самых свежих»). +# НЕ удаляются: vendor/ (CUDA-рантайм ~1.5G) и +# llama.cpp-linux-x86_64-nvidia-cuda12-avx2-2.67.0 (свежая рабочая). +LM_BACKEND_NAMES=( + "llama.cpp-linux-x86_64-nvidia-cuda-avx2-2.13.0" + "llama.cpp-linux-x86_64-nvidia-cuda-avx2-2.14.4" + "llama.cpp-linux-x86_64-nvidia-cuda-avx2-2.20.1" + "llama.cpp-linux-x86_64-avx2-2.13.0" + "llama.cpp-linux-x86_64-avx2-2.14.4" + "llama.cpp-linux-x86_64-avx2-2.20.1" + "llama.cpp-linux-x86_64-vulkan-avx2-2.13.0" + "llama.cpp-linux-x86_64-vulkan-avx2-2.14.4" + "llama.cpp-linux-x86_64-vulkan-avx2-2.20.1" + "llama.cpp-linux-x86_64-nvidia-cuda12-avx2-2.37.0" + "llama.cpp-linux-x86_64-nvidia-cuda12-avx2-2.51.0" + "llama.cpp-linux-x86_64-nvidia-cuda12-avx2-2.55.0" + "llama.cpp-linux-x86_64-nvidia-cuda12-avx2-2.64.0" +) + +# ============================================================ +# Вывод и утилиты +# ============================================================ + +if [ -t 1 ]; then + C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'; C_DIM=$'\033[2m' + C_RED=$'\033[31m'; C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m' +else + C_RESET=''; C_BOLD=''; C_DIM=''; C_RED=''; C_GREEN=''; C_YELLOW='' +fi + +section() { printf '\n%s%s%s\n' "$C_BOLD" "$1" "$C_RESET"; } +ok() { printf '%s✔%s %s\n' "$C_GREEN" "$C_RESET" "$1"; } +warn() { printf '%s! %s%s\n' "$C_YELLOW" "$1" "$C_RESET"; } +fail() { printf '%s✖ %s%s\n' "$C_RED" "$1" "$C_RESET"; } +skip() { printf '%s— %s%s\n' "$C_DIM" "$1" "$C_RESET"; } +note() { printf ' %s%s%s\n' "$C_DIM" "$1" "$C_RESET"; } + +usage() { + cat >&2 < + + check Состояние: найденные кандидаты, исключения, прогноз (read-only) + plan Что ИМЕННО будет удалено + прогноз освобождения (read-only) + apply Бэкап → план → подтверждение → удаление + rollback Переустановить пакеты, удалённые последним apply + +По умолчанию ничего не выполняется. +EOF + exit 2 +} + +# Байты → человекочитаемый размер. +human_size() { + local b="${1:-0}" + awk -v b="$b" 'BEGIN{ + split("B KiB MiB GiB TiB", u, " "); i = 1 + while (b >= 1024 && i < 5) { b = b / 1024; i = i + 1 } + if (i == 1) printf "%d %s", b, u[i]; else printf "%.2f %s", b, u[i] + }' +} + +# Размер каталога/файла в байтах (реально занятое место, без перехода по ФС). +path_bytes() { + local p="$1" out + out="$(du -sx -B1 -- "$p" 2>/dev/null | awk '{print $1}' | head -n1 || true)" + [[ "$out" =~ ^[0-9]+$ ]] || out=0 + printf '%s' "$out" +} + +# Свободное место на корневом разделе в байтах. +root_free_bytes() { + local out + out="$(df -B1 --output=avail / 2>/dev/null | tail -n1 | tr -d ' ' || true)" + [[ "$out" =~ ^[0-9]+$ ]] || out=0 + printf '%s' "$out" +} + +# Причина, по которой пакет исключён из удаления. +exclusion_reason() { + case "$1" in + linux-meta) printf '%s' "переключатель ядра по умолчанию (0 байт)" ;; + linux-nvidia-meta) printf '%s' "переключатель ядра по умолчанию (0 байт)" ;; + linux618-nvidia) printf '%s' "драйвер nvidia для LTS-ядра 6.18 (без него чёрный экран)" ;; + python-pkg_resources) printf '%s' "нужен для pip (AUR-пакет)" ;; + boost) printf '%s' "нужен для сборки AUR-пакетов" ;; + *) printf '%s' "исключён защитным списком" ;; + esac +} + +# ============================================================ +# Сбор данных (read-only) +# ============================================================ + +# Все orphan-пакеты (как выдаёт pacman) — для отчёта. +ALL_ORPHANS=() +# Orphan'ы к удалению (минус защищённый список). +ORPHAN_PKGS=() +# Исключённые пакеты. +EXCLUDED_PKGS=() + +collect_orphan_packages() { + ALL_ORPHANS=(); ORPHAN_PKGS=(); EXCLUDED_PKGS=() + local line + while IFS= read -r line; do + [[ -z "$line" ]] && continue + ALL_ORPHANS+=("$line") + if [[ "$line" =~ $PKG_EXCLUDE_RE ]]; then + EXCLUDED_PKGS+=("$line") + else + ORPHAN_PKGS+=("$line") + fi + done < <(pacman -Qdtq 2>/dev/null || true) +} + +# Реальный вывод `pacman -R --print` (цели в формате имя-версия). +# Внимание: pacman печатает цели в stdout, а в stderr — ошибки. +# Перехват через 2>&1 корректен для обоих потоков. +# `-n`/--nosave с --print несовместимы, поэтому тут только `-R`. +print_targets() { + ((${#ORPHAN_PKGS[@]} == 0)) && return 0 + LC_ALL=C pacman -R --print "${ORPHAN_PKGS[@]}" 2>&1 || true +} + +# Размер каждого orphan-пакета в байтах: "имябайты". +# LC_ALL=C → английское поле "Installed Size" и точка как десятичный разделитель +# (в русской локали там «Установленный размер : 151,16 MiB» с запятой). +package_sizes() { + ((${#ORPHAN_PKGS[@]} == 0)) && return 0 + LC_ALL=C pacman -Qi "${ORPHAN_PKGS[@]}" 2>/dev/null | awk ' + /^Name[ \t]*:/ { n = $3 } + /^Installed Size/ { + u = $5; v = $4 + 0 + if (u == "KiB") b = v * 1024 + else if (u == "MiB") b = v * 1048576 + else if (u == "GiB") b = v * 1073741824 + else if (u == "B") b = v + else b = 0 + printf "%s\t%d\n", n, b + }' +} + +# Суммарный установленный размер orphan-пакетов (байты). +packages_bytes() { + package_sizes | awk '{ t += $2 } END { printf "%d", t + 0 }' +} + +# Бэкенды LM Studio к удалению (полные пути) и их суммарный размер. +LM_BACKEND_PATHS=() +LM_BACKEND_BYTES=0 +LM_BACKEND_MISSING=() + +collect_lmstudio_backends() { + LM_BACKEND_PATHS=(); LM_BACKEND_BYTES=0; LM_BACKEND_MISSING=() + local name path b + for name in "${LM_BACKEND_NAMES[@]}"; do + path="$BACKENDS_ROOT/$name" + if [[ ! -e "$path" ]]; then + LM_BACKEND_MISSING+=("$name") + continue + fi + if [[ ! -d "$path" || -L "$path" ]]; then + warn "пропущен (не каталог или symlink): $path" + LM_BACKEND_MISSING+=("$name") + continue + fi + b="$(path_bytes "$path")" + LM_BACKEND_PATHS+=("$path") + LM_BACKEND_BYTES=$((LM_BACKEND_BYTES + b)) + done +} + +# ------------------------------------------------------------ +# Проверки безопасности перед rm -rf +# ------------------------------------------------------------ + +# Путь разрешён к удалению, только если: +# 1) basename входит в белый список LM_BACKEND_NAMES; +# 2) путь — каталог (не symlink, не файл); +# 3) родитель — ровно $BACKENDS_ROOT (никаких вложенных путей). +backend_allowed() { + local path="$1" base parent name + [[ -e "$path" ]] || return 1 + [[ -d "$path" ]] || return 1 + [[ ! -L "$path" ]] || return 1 + + base="$(basename -- "$path")" + parent="$(dirname -- "$path")" + [[ "$parent" == "$BACKENDS_ROOT" ]] || return 1 + + for name in "${LM_BACKEND_NAMES[@]}"; do + if [[ "$base" == "$name" ]]; then + return 0 + fi + done + return 1 +} + +# PPID процесса по /proc (comm может содержать пробелы — режем по последней ')'). +proc_ppid() { + local pid="$1" stat rest + [[ -r "/proc/$pid/stat" ]] || return 1 + stat="$(cat "/proc/$pid/stat" 2>/dev/null)" || return 1 + rest="${stat#*) }" + awk '{ print $2 }' <<< "$rest" +} + +# Запущен ли LM Studio? (процесс не убиваем — только предупреждаем) +# Отсекаем: сам скрипт, его предков и любой процесс, чей cmdline содержит +# имя этого скрипта — иначе pgrep ловит сам вызов. +lmstudio_running() { + local pid ppid cmdline comm guard=0 + local -a ancestors=() candidates=() extra=() + + ppid="$PPID" + while [[ -n "$ppid" && "$ppid" != "0" && "$ppid" != "1" && $guard -lt 32 ]]; do + ancestors+=("$ppid") + ppid="$(proc_ppid "$ppid" || true)" + [[ -z "$ppid" ]] && break + guard=$((guard + 1)) + done + + # Точное имя процесса + полный cmdline (вспомогательные процессы LM Studio). + mapfile -t candidates < <(pgrep -x 'LM-Studio' 2>/dev/null || true) + mapfile -t extra < <(pgrep -f 'lm-studio|LM-Studio' 2>/dev/null || true) + candidates+=("${extra[@]:-}") + + for pid in "${candidates[@]:-}"; do + [[ -z "$pid" ]] && continue + [[ "$pid" == "$$" ]] && continue + + local is_ancestor=0 a + for a in "${ancestors[@]:-}"; do + if [[ -n "$a" && "$pid" == "$a" ]]; then is_ancestor=1; break; fi + done + [[ "$is_ancestor" == "1" ]] && continue + + cmdline="$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null || true)" + [[ "$cmdline" == *"$SCRIPT_NAME"* ]] && continue + + comm="$(cat "/proc/$pid/comm" 2>/dev/null || true)" + case "$comm" in + LM-Studio*|lm-studio*|LMStudio*|lmstudio*) + printf '%s\n' "$pid"; return 0 ;; + esac + case "$cmdline" in + *LM-Studio*|*lm-studio*) + printf '%s\n' "$pid"; return 0 ;; + esac + done + return 1 +} + +# Подтверждение: только точное слово "yes". +confirm() { + local prompt="$1" reply="" + printf '%s' "$prompt" + if ! IFS= read -r reply; then reply=""; fi + [[ "$reply" == "yes" ]] +} + +# ============================================================ +# Отчёт: check / plan +# ============================================================ + +print_excluded() { + ((${#EXCLUDED_PKGS[@]} == 0)) && return 0 + printf ' %sИсключено (не удаляем):%s\n' "$C_BOLD" "$C_RESET" + local p + for p in "${EXCLUDED_PKGS[@]}"; do + printf ' %-24s %s%s%s\n' "$p" "$C_DIM" "$(exclusion_reason "$p")" "$C_RESET" + done +} + +print_plan_section() { + local total_bytes="$1" + + section "A. Orphan-пакеты pacman (${#ORPHAN_PKGS[@]} шт.)" + if ((${#ORPHAN_PKGS[@]} == 0)); then + skip "orphan-пакетов к удалению нет" + else + printf ' %sЦели (реальный вывод `pacman -R --print`):%s\n' "$C_BOLD" "$C_RESET" + print_targets | sed 's/^/ /' + printf '\n %sРазмеры:%s\n' "$C_BOLD" "$C_RESET" + package_sizes | sort -t$'\t' -k2 -rn | awk -F'\t' ' + { printf " %-42s %10.2f MiB\n", $1, $2 / 1048576 }' + fi + print_excluded + + section "B. Старые бэкенды LM Studio (${#LM_BACKEND_PATHS[@]} шт.)" + if ((${#LM_BACKEND_PATHS[@]} == 0)); then + skip "бэкендов к удалению нет (уже удалены или не установлены)" + else + local path + for path in "${LM_BACKEND_PATHS[@]}"; do + printf ' %s\n' "$path" + printf ' %s%s%s\n' "$C_DIM" "$(human_size "$(path_bytes "$path")")" "$C_RESET" + done + fi + if ((${#LM_BACKEND_MISSING[@]} > 0)); then + printf '\n %sОтсутствуют (пропущены, идемпотентность):%s\n' "$C_DIM" "$C_RESET" + local m + for m in "${LM_BACKEND_MISSING[@]}"; do + printf ' %s%s%s\n' "$C_DIM" "$m" "$C_RESET" + done + fi + printf '\n %sНе трогаем:%s vendor/ (CUDA-рантайм) и ...-nvidia-cuda12-avx2-2.67.0 (свежая)\n' \ + "$C_DIM" "$C_RESET" + + section "Прогноз" + printf ' пакеты pacman : %s\n' "$(human_size "$PACKAGES_BYTES")" + printf ' бэкенды LM : %s\n' "$(human_size "$LM_BACKEND_BYTES")" + printf ' %sИТОГО : %s%s\n' "$C_BOLD" "$(human_size "$total_bytes")" "$C_RESET" + printf ' %sСвободно на / : %s (было %s)%s\n' \ + "$C_DIM" "$(human_size "$(root_free_bytes)")" "$(human_size "$FREE_BEFORE")" "$C_RESET" +} + +# ============================================================ +# Команды +# ============================================================ + +cmd_check() { + section "Проверка (read-only)" + + printf ' orphan-пакетов найдено всего : %s\n' "${#ALL_ORPHANS[@]}" + printf ' из них исключено : %s\n' "${#EXCLUDED_PKGS[@]}" + printf ' к удалению (группа A) : %s\n' "${#ORPHAN_PKGS[@]}" + print_excluded + printf '\n старых бэкендов LM Studio : %s (из %s в белом списке)\n' \ + "${#LM_BACKEND_PATHS[@]}" "${#LM_BACKEND_NAMES[@]}" + printf ' бэкендов отсутствует (ок) : %s\n' "${#LM_BACKEND_MISSING[@]}" + + section "Прогноз освобождения" + printf ' пакеты pacman : %s\n' "$(human_size "$PACKAGES_BYTES")" + printf ' бэкенды LM : %s\n' "$(human_size "$LM_BACKEND_BYTES")" + printf ' %sИТОГО : %s%s\n' "$C_BOLD" "$(human_size "$TOTAL_BYTES")" "$C_RESET" + printf ' %sСвободно на / : %s%s\n' "$C_DIM" "$(human_size "$(root_free_bytes)")" "$C_RESET" + printf '\n %sНичего не изменено.%s Подробности: %s plan\n' "$C_DIM" "$C_RESET" "$SCRIPT_NAME" +} + +cmd_plan() { + section "План удаления (read-only)" + print_plan_section "$TOTAL_BYTES" +} + +# ------------------------------------------------------------ +# apply +# ------------------------------------------------------------ + +cmd_apply() { + section "APPLY — удаление orphan-пакетов и старых бэкендов LM Studio" + + if ((${#ORPHAN_PKGS[@]} == 0 && ${#LM_BACKEND_PATHS[@]} == 0)); then + skip "нечего удалять — всё уже чисто" + return 0 + fi + + # 1) Полный план. + print_plan_section "$TOTAL_BYTES" + + # 2) Предупреждение про запущенный LM Studio (не убиваем процесс). + if lmstudio_running >/dev/null 2>&1; then + warn "LM Studio запущен — закройте его перед удалением бэкендов." + note "Процесс LM Studio не будет убит этим скриптом." + fi + + # 3) Подтверждение. + printf '\n' + if ! confirm "Удалить перечисленное? Введите 'yes' для подтверждения: "; then + printf '\n' + fail "Отменено пользователем. Ничего не удалено." + return 0 + fi + printf '\n' + + # 4) Бэкап. + local stamp backup_dir + stamp="$(date +%Y%m%d-%H%M%S)" + backup_dir="$STATE_DIR/$stamp" + local n=1 + while [[ -e "$backup_dir" ]]; do + backup_dir="$STATE_DIR/$stamp-$n" + n=$((n + 1)) + done + mkdir -p -- "$backup_dir" + + printf '%s\n' "${ORPHAN_PKGS[@]:-}" > "$backup_dir/packages-removed.txt" + local bnames=() + local path + for path in "${LM_BACKEND_PATHS[@]:-}"; do + bnames+=("$(basename -- "$path")") + done + printf '%s\n' "${bnames[@]:-}" > "$backup_dir/lmstudio-backends-removed.txt" + # Пустые строки от "${arr[@]:-}" на пустом массиве — убираем. + sed -i '/^$/d' "$backup_dir/packages-removed.txt" "$backup_dir/lmstudio-backends-removed.txt" + + { + printf 'cleanup-packages.sh — отчёт об удалении\n' + printf 'Дата (UTC): %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S')" + printf 'Дата (лок): %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" + printf 'Хост: %s\n' "$(hostname)" + printf '\n=== df ДО ===\n' + df -h / 2>/dev/null || true + printf '\n=== пакеты (имя, установленный размер) ===\n' + package_sizes | awk -F'\t' '{ printf "%-42s %s\n", $1, $2 }' + printf '\n=== бэкапы ===\n' + printf 'packages-removed.txt — %s пакет(ов)\n' "${#ORPHAN_PKGS[@]}" + printf 'lmstudio-backends-removed.txt — %s каталог(ов)\n' "${#LM_BACKEND_PATHS[@]}" + printf '\n=== df ПОСЛЕ ===\n' + } > "$backup_dir/manifest.txt" + chmod 600 -- "$backup_dir"/* 2>/dev/null || true + + ok "бэкап создан: $backup_dir" + + # 5) Удаление бэкендов LM Studio (от пользователя, sudo не нужен). + local removed_backends=0 + local -a removed_backend_names=() + if ((${#LM_BACKEND_PATHS[@]} > 0)); then + section "Удаление бэкендов LM Studio" + for path in "${LM_BACKEND_PATHS[@]}"; do + if backend_allowed "$path"; then + if rm -rf -- "$path"; then + ok "удалён: $path" + removed_backends=$((removed_backends + 1)) + removed_backend_names+=("$(basename -- "$path")") + else + fail "не удалось удалить: $path" + fi + else + fail "ПРОПУЩЕН (не прошёл проверку безопасности): $path" + fi + done + fi + + # 6) Удаление пакетов pacman (нужен sudo). + if ((${#ORPHAN_PKGS[@]} > 0)); then + section "Удаление orphan-пакетов pacman" + if ! command -v sudo >/dev/null 2>&1; then + warn "sudo не найден — пакеты пропущены (бэкап сохранён, откат возможен)." + elif ! sudo -v; then + warn "sudo недоступен (не удалось получить права) — пакеты пропущены." + note "Бэкап: $backup_dir — откат возможен командой: $SCRIPT_NAME rollback" + else + # -n (--nosave) оставляет конфиги в .pacsave; --noconfirm — мы уже спросили. + if sudo pacman -Rns --noconfirm "${ORPHAN_PKGS[@]}"; then + ok "pacman завершил удаление пакетов" + else + fail "pacman завершился с ошибкой — часть пакетов могла остаться" + fi + fi + fi + + # 7) Итог: df после + уточнение бэкапа (только реально удалённое). + local free_after + free_after="$(root_free_bytes)" + + # Уточняем списки: оставляем только то, что действительно исчезло с системы. + local -a removed_pkg_names=() + local p + for p in "${ORPHAN_PKGS[@]:-}"; do + [[ -z "$p" ]] && continue + if ! pacman -Q "$p" >/dev/null 2>&1; then + removed_pkg_names+=("$p") + fi + done + printf '%s\n' "${removed_pkg_names[@]:-}" > "$backup_dir/packages-removed.txt" + printf '%s\n' "${removed_backend_names[@]:-}" > "$backup_dir/lmstudio-backends-removed.txt" + sed -i '/^$/d' "$backup_dir/packages-removed.txt" "$backup_dir/lmstudio-backends-removed.txt" + + { + printf '\n=== df ПОСЛЕ (факт) ===\n' + df -h / 2>/dev/null || true + printf '\nУдалено пакетов: %s\n' "${#removed_pkg_names[@]}" + printf 'Удалено бэкендов: %s\n' "${#removed_backend_names[@]}" + printf 'Свободно до: %s\n' "$(human_size "$FREE_BEFORE")" + printf 'Свободно после: %s\n' "$(human_size "$free_after")" + printf '\n=== фактически удалённые пакеты ===\n' + printf '%s\n' "${removed_pkg_names[@]:-}" | sed '/^$/d' + printf '\n=== фактически удалённые бэкенды ===\n' + printf '%s\n' "${removed_backend_names[@]:-}" | sed '/^$/d' + } >> "$backup_dir/manifest.txt" + + section "Готово" + printf ' удалено пакетов : %s\n' "${#removed_pkg_names[@]}" + printf ' удалено бэкендов : %s\n' "${#removed_backend_names[@]}" + printf ' свободно на / : %s → %s\n' \ + "$(human_size "$FREE_BEFORE")" "$(human_size "$free_after")" + printf ' бэкап : %s\n' "$backup_dir" + printf '\n %sОткат: %s rollback%s\n' "$C_DIM" "$SCRIPT_NAME" "$C_RESET" +} + +# ------------------------------------------------------------ +# rollback +# ------------------------------------------------------------ + +cmd_rollback() { + section "ROLLBACK — восстановление пакетов из последнего apply" + + local latest + latest="$(find "$STATE_DIR" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort | tail -n1 || true)" + if [[ -z "$latest" ]]; then + fail "бэкапов не найдено в $STATE_DIR" + return 1 + fi + + ok "последний бэкап: $latest" + + local pkgs=() + if [[ -f "$latest/packages-removed.txt" ]]; then + mapfile -t pkgs < "$latest/packages-removed.txt" + fi + local backends=() + if [[ -f "$latest/lmstudio-backends-removed.txt" ]]; then + mapfile -t backends < "$latest/lmstudio-backends-removed.txt" + fi + + if ((${#pkgs[@]} == 0)); then + skip "в бэкапе нет списка пакетов" + else + printf ' %sПакеты к переустановке (%s шт.):%s\n' "$C_BOLD" "${#pkgs[@]}" "$C_RESET" + local p + for p in "${pkgs[@]}"; do + [[ -z "$p" ]] && continue + printf ' %s\n' "$p" + done + printf '\n %sКоманда восстановления:%s\n' "$C_BOLD" "$C_RESET" + printf ' sudo pacman -S %s\n' "${pkgs[*]}" + + printf '\n' + if confirm "Выполнить восстановление пакетов? Введите 'yes': "; then + if sudo -v && sudo pacman -S --noconfirm "${pkgs[@]}"; then + ok "пакеты переустановлены" + else + fail "восстановление не удалось" + fi + else + skip "восстановление не выполнялось (команда выше — скопируйте вручную)" + fi + fi + + if ((${#backends[@]} > 0)); then + printf '\n %sБэкенды LM Studio (%s шт.) — перекачиваются автоматически:%s\n' \ + "$C_BOLD" "${#backends[@]}" "$C_RESET" + local b + for b in "${backends[@]}"; do + [[ -z "$b" ]] && continue + printf ' %s\n' "$b" + done + note "LM Studio сам скачает нужную версию при следующем запуске." + fi +} + +# ============================================================ +# Точка входа +# ============================================================ + +main() { + local cmd="${1:-}" + case "$cmd" in + check|plan|apply|rollback) ;; + -h|--help|help) usage ;; + "") usage ;; + *) printf 'Неизвестная команда: %s\n\n' "$cmd" >&2; usage ;; + esac + + if ! command -v pacman >/dev/null 2>&1; then + fail "pacman не найден — этот скрипт рассчитан на Manjaro/Arch" + exit 1 + fi + + collect_orphan_packages + collect_lmstudio_backends + + PACKAGES_BYTES="$(packages_bytes)" + TOTAL_BYTES=$((PACKAGES_BYTES + LM_BACKEND_BYTES)) + FREE_BEFORE="$(root_free_bytes)" + + case "$cmd" in + check) cmd_check ;; + plan) cmd_plan ;; + apply) cmd_apply ;; + rollback) cmd_rollback ;; + esac +} + +main "$@"