Files
harness/scripts/cleanup-packages.sh
T
eka 710dfdfd2d feat: скрипт чистки orphan-пакетов и старых бэкендов LM Studio
cleanup-packages.sh с подкомандами check/plan/apply/rollback.
Дефолт — ничего не делать. apply: план → подтверждение 'yes' → бэкап
в ~/.local/state/cleanup-packages/<ts>/ → удаление.
Защищённый список: linux-meta, linux-nvidia-meta, linux618-nvidia,
python-pkg_resources, boost. Бэкенды — по фиксированному белому списку,
vendor/ и cuda12-avx2-2.67.0 не трогаются.
2026-10-30 17:19:13 +05:00

634 lines
26 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <<EOF
Использование: $SCRIPT_NAME <команда>
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-пакета в байтах: "имя<TAB>байты".
# 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 "$@"