fix(install): исправлена установка dotfiles и добавлены тесты

- Зачем:
  - нужно было убрать баги install.sh и сделать регрессии воспроизводимыми.
- Что:
  - исправлена установка через stow в HOME, миграция ~/.claude и копирование settings.json без разрушения рабочей директории.
  - стабилизирована WSL-логика через проверки интерактивности и защиту от дублирования boot command.
  - добавлен tests/test_install.sh с regression-сценариями для install.sh.
- Проверка:
  - bash -n install.sh
  - bash -n tests/test_install.sh
  - ./tests/test_install.sh
This commit is contained in:
ddadmin committed 2026-03-22 01:21:56 +03:00
1 parent e00611015a
commit edeb8da3c2
2 files changed
+375 -35

No files matched your search

+118 -35
View File
@@ -2,6 +2,11 @@
set -euo pipefail
DOTFILES_DIR="$(cd "$(dirname "$0")" && pwd)"
STOW_BIN="${STOW_BIN:-stow}"
GIT_BIN="${GIT_BIN:-git}"
SUDO_BIN="${SUDO_BIN:-sudo}"
WSL_CONF_PATH="${WSL_CONF_PATH:-/etc/wsl.conf}"
INSTALL_INTERACTIVE_MODE="${INSTALL_INTERACTIVE_MODE:-auto}"
# --- Цвета ---
RED='\033[0;31m'
@@ -13,25 +18,80 @@ info() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
error() { echo -e "${RED}[✗]${NC} $*"; }
stow_package() {
"$STOW_BIN" --target="$HOME" -v --restow "$@" 2>&1 | sed "s/^/ /"
}
ensure_symlink() {
local src="$1"
local dst="$2"
if [ -L "$dst" ]; then
ln -sfnT "$src" "$dst"
return 0
fi
if [ -d "$dst" ]; then
if [ -L "$dst/skills" ] && [ "$(find "$dst" -mindepth 1 -maxdepth 1 | wc -l)" -eq 1 ]; then
rm -rf "$dst"
ln -s "$src" "$dst"
return 0
fi
if [ -z "$(find "$dst" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
rmdir "$dst"
ln -s "$src" "$dst"
return 0
fi
warn "$dst уже существует как директория, пропускаю замену"
return 0
fi
if [ -e "$dst" ]; then
warn "$dst уже существует и не является симлинком, пропускаю замену"
return 0
fi
ln -s "$src" "$dst"
}
is_interactive() {
case "$INSTALL_INTERACTIVE_MODE" in
always) return 0 ;;
never) return 1 ;;
auto) [ -t 0 ] ;;
*)
warn "Неизвестное значение INSTALL_INTERACTIVE_MODE=$INSTALL_INTERACTIVE_MODE, использую auto"
[ -t 0 ]
;;
esac
}
# --- Проверка stow ---
if ! command -v stow &>/dev/null; then
if ! command -v "$STOW_BIN" &>/dev/null; then
error "stow не найден. Установите: sudo apt install stow"
exit 1
fi
# --- Stow пакеты ---
PACKAGES=(bash tmux vim zed vscode agents opencode codex gemini)
AVAILABLE_PACKAGES=()
info "Применяю stow пакеты..."
cd "$DOTFILES_DIR"
for pkg in "${PACKAGES[@]}"; do
if [ -d "$pkg" ]; then
stow -v --restow "$pkg" 2>&1 | sed "s/^/ /"
AVAILABLE_PACKAGES+=("$pkg")
else
warn "Пакет $pkg не найден, пропускаю"
fi
done
if [ "${#AVAILABLE_PACKAGES[@]}" -gt 0 ]; then
stow_package "${AVAILABLE_PACKAGES[@]}"
fi
# --- Claude Code: установка без stow ---
# settings.json КОПИРУЕТСЯ (не линкуется), чтобы Claude Code не модифицировал
# файл в репозитории. Локальные настройки — в settings.local.json.
@@ -39,18 +99,22 @@ done
# Миграция: если ~/.claude — stow-симлинк, раскрываем в реальную директорию
if [ -L "$HOME/.claude" ]; then
info "Миграция: ~/.claude — симлинк, преобразую в директорию..."
CLAUDE_STOW_DIR=$(readlink -f "$HOME/.claude")
CLAUDE_STOW_DIR="$(readlink -f "$HOME/.claude" 2>/dev/null || true)"
rm "$HOME/.claude"
mkdir -p "$HOME/.claude"
# Переносим рантайм-данные из stow-директории
for item in "$CLAUDE_STOW_DIR"/* "$CLAUDE_STOW_DIR"/.[!.]*; do
[ -e "$item" ] || continue
base=$(basename "$item")
# Пропускаем git-tracked файлы — они останутся в репозитории
case "$base" in .gitignore|settings.json|skills) continue ;; esac
mv "$item" "$HOME/.claude/" 2>/dev/null || true
done
info "Рантайм-данные перенесены в ~/.claude/"
if [ -n "$CLAUDE_STOW_DIR" ] && [ -d "$CLAUDE_STOW_DIR" ]; then
# Переносим рантайм-данные из stow-директории
for item in "$CLAUDE_STOW_DIR"/* "$CLAUDE_STOW_DIR"/.[!.]*; do
[ -e "$item" ] || continue
base=$(basename "$item")
# Пропускаем git-tracked файлы — они останутся в репозитории
case "$base" in .gitignore|settings.json|skills) continue ;; esac
mv "$item" "$HOME/.claude/" 2>/dev/null || true
done
info "Рантайм-данные перенесены в ~/.claude/"
else
warn "Не удалось прочитать источник старого симлинка ~/.claude, продолжаю без миграции данных"
fi
fi
mkdir -p "$HOME/.claude"
@@ -58,22 +122,24 @@ mkdir -p "$HOME/.claude"
# Skills (симлинк)
SKILLS_SRC="$DOTFILES_DIR/agents/.agents/skills"
if [ -d "$SKILLS_SRC" ]; then
ln -sfn "$SKILLS_SRC" "$HOME/.claude/skills"
ensure_symlink "$SKILLS_SRC" "$HOME/.claude/skills"
fi
# settings.json — копия из репозитория (не симлинк!)
CLAUDE_SETTINGS_SRC="$DOTFILES_DIR/claude/.claude/settings.json"
CLAUDE_SETTINGS_DST="$HOME/.claude/settings.json"
if [ -f "$CLAUDE_SETTINGS_SRC" ]; then
# Если settings.json в репо был изменён (например, Claude Code писал через симлинк) — сбрасываем
if cd "$DOTFILES_DIR" && ! git diff --quiet claude/.claude/settings.json 2>/dev/null; then
warn "claude/.claude/settings.json имеет локальные изменения — сбрасываю"
git checkout claude/.claude/settings.json
fi
# Защищаем оригинал в репозитории
chmod 444 "$CLAUDE_SETTINGS_SRC"
# Копируем в ~/.claude/ (перезаписывает при повторном запуске)
cp -f "$CLAUDE_SETTINGS_SRC" "$CLAUDE_SETTINGS_DST"
# Если доступен git, копируем committed-версию без изменений рабочей директории.
if "$GIT_BIN" -C "$DOTFILES_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
if ! "$GIT_BIN" -C "$DOTFILES_DIR" show "HEAD:claude/.claude/settings.json" > "$CLAUDE_SETTINGS_DST"; then
warn "Не удалось прочитать committed settings.json, копирую текущий файл из репозитория"
cp -f "$CLAUDE_SETTINGS_SRC" "$CLAUDE_SETTINGS_DST"
fi
else
cp -f "$CLAUDE_SETTINGS_SRC" "$CLAUDE_SETTINGS_DST"
fi
chmod 644 "$CLAUDE_SETTINGS_DST"
info "settings.json скопирован (оригинал защищён chmod 444)"
fi
@@ -107,23 +173,40 @@ fi
# --- WSL: hproxy.local (автоматический резолв при старте) ---
if [ -n "${WSL_DISTRO_NAME:-}" ]; then
HPROXY_SCRIPT="$DOTFILES_DIR/scripts/setup-hproxy.sh"
if grep -q "hproxy.local" /etc/wsl.conf 2>/dev/null; then
warn "/etc/wsl.conf уже содержит hproxy.local, пропускаю"
WSL_BOOT_COMMAND="command = \"$HPROXY_SCRIPT\""
if grep -Fq "$WSL_BOOT_COMMAND" "$WSL_CONF_PATH" 2>/dev/null; then
warn "/etc/wsl.conf уже содержит boot command для setup-hproxy.sh, пропускаю"
elif ! is_interactive; then
warn "WSL обнаружен, но stdin не интерактивный. Пропускаю настройку /etc/wsl.conf"
info "Добавьте вручную: $WSL_BOOT_COMMAND"
else
echo ""
info "Обнаружен WSL. Скрипт $HPROXY_SCRIPT может автоматически"
info "прописывать hproxy.local в /etc/hosts при каждом старте WSL (NAT mode)."
echo -ne "${YELLOW}[?]${NC} Добавить boot command в /etc/wsl.conf? [y/N] "
read -r answer
if [[ "$answer" =~ ^[Yy]$ ]]; then
# Добавляем [boot] секцию, если её нет
if ! grep -q '^\[boot\]' /etc/wsl.conf 2>/dev/null; then
printf '\n[boot]\n' | sudo tee -a /etc/wsl.conf > /dev/null
fi
sudo sed -i "/^\[boot\]/a command = \"$HPROXY_SCRIPT\"" /etc/wsl.conf
info "Добавлено в /etc/wsl.conf. Перезапустите WSL: wsl --shutdown"
if awk '
/^\[boot\]$/ { in_boot=1; next }
/^\[/ { in_boot=0 }
in_boot && /^[[:space:]]*command[[:space:]]*=/ { found=1 }
END { exit !found }
' "$WSL_CONF_PATH" 2>/dev/null; then
warn "/etc/wsl.conf уже содержит [boot] command. Добавьте setup-hproxy.sh вручную"
info "Нужная строка: $WSL_BOOT_COMMAND"
else
warn "Пропущено. Можно настроить вручную — см. README.md"
echo ""
info "Обнаружен WSL. Скрипт $HPROXY_SCRIPT может автоматически"
info "прописывать hproxy.local в /etc/hosts при каждом старте WSL (NAT mode)."
echo -ne "${YELLOW}[?]${NC} Добавить boot command в /etc/wsl.conf? [y/N] "
answer=""
if ! read -r answer; then
answer=""
fi
if [[ "$answer" =~ ^[Yy]$ ]]; then
# Добавляем [boot] секцию, если её нет
if ! grep -q '^\[boot\]' "$WSL_CONF_PATH" 2>/dev/null; then
printf '\n[boot]\n' | "$SUDO_BIN" tee -a "$WSL_CONF_PATH" > /dev/null
fi
"$SUDO_BIN" sed -i "/^\[boot\]/a command = \"$HPROXY_SCRIPT\"" "$WSL_CONF_PATH"
info "Добавлено в /etc/wsl.conf. Перезапустите WSL: wsl --shutdown"
else
warn "Пропущено. Можно настроить вручную — см. README.md"
fi
fi
fi
fi