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