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
+257
View File
@@ -0,0 +1,257 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
fail() {
echo "FAIL: $*" >&2
exit 1
}
assert_file_contains() {
local file="$1"
local pattern="$2"
grep -Fq "$pattern" "$file" || fail "Expected '$pattern' in $file"
}
assert_not_exists() {
local path="$1"
[ ! -e "$path" ] || fail "Path should not exist: $path"
}
setup_fixture() {
local fixture_root repo_root bin_dir
fixture_root="$(mktemp -d /tmp/dotfiles-install-test.XXXXXX)"
repo_root="$fixture_root/repo"
bin_dir="$fixture_root/bin"
mkdir -p "$repo_root" "$bin_dir"
cp -a \
"$REPO_ROOT/install.sh" \
"$REPO_ROOT/bash" \
"$REPO_ROOT/tmux" \
"$REPO_ROOT/vim" \
"$REPO_ROOT/zed" \
"$REPO_ROOT/vscode" \
"$REPO_ROOT/agents" \
"$REPO_ROOT/opencode" \
"$REPO_ROOT/codex" \
"$REPO_ROOT/gemini" \
"$REPO_ROOT/claude" \
"$REPO_ROOT/scripts" \
"$repo_root/"
cat > "$bin_dir/fake-git" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
if [ "${1:-}" = "-C" ]; then
shift 2
fi
case "${1:-}" in
rev-parse)
exit 0
;;
show)
printf '%s' "${FAKE_GIT_SHOW_CONTENT:-}"
;;
*)
echo "unexpected git invocation: $*" >&2
exit 1
;;
esac
EOF
cat > "$bin_dir/fake-sudo" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
exec "$@"
EOF
chmod +x "$bin_dir/fake-git" "$bin_dir/fake-sudo"
printf '%s\n' "$fixture_root"
}
run_install() {
local repo_root="$1"
local home_dir="$2"
shift 2
HOME="$home_dir" \
GIT_BIN="$repo_root/../bin/fake-git" \
SUDO_BIN="$repo_root/../bin/fake-sudo" \
"$@" \
"$repo_root/install.sh"
}
test_fresh_install_uses_home_target() {
local fixture repo_root home_dir
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
mkdir -p "$home_dir"
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >/tmp/test-install.out
[ -L "$home_dir/.bash_custom" ] || fail "Expected .bash_custom symlink in HOME"
[ -L "$home_dir/.claude/skills" ] || fail "Expected ~/.claude/skills symlink"
assert_file_contains "$home_dir/.bashrc" "[ -f ~/.bash_custom ] && source ~/.bash_custom"
assert_not_exists "$fixture/.bash_custom"
}
test_dirty_repo_settings_copy_uses_committed_version() {
local fixture repo_root home_dir
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
mkdir -p "$home_dir"
chmod u+w "$repo_root/claude/.claude/settings.json"
cat > "$repo_root/claude/.claude/settings.json" <<'EOF'
{"source":"working-tree"}
EOF
FAKE_GIT_SHOW_CONTENT='{"source":"head"}' \
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >/tmp/test-install.out
assert_file_contains "$home_dir/.claude/settings.json" '"source":"head"'
assert_file_contains "$repo_root/claude/.claude/settings.json" '"source":"working-tree"'
}
test_rerun_does_not_duplicate_bashrc_line() {
local fixture repo_root home_dir count
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
mkdir -p "$home_dir"
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >/tmp/test-install.out
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >/tmp/test-install.out
count="$(grep -Fxc '[ -f ~/.bash_custom ] && source ~/.bash_custom' "$home_dir/.bashrc")"
[ "$count" -eq 1 ] || fail "Expected bash_custom line once, got $count"
}
test_repairs_nested_skills_directory_bug() {
local fixture repo_root home_dir
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
mkdir -p "$home_dir/.claude/skills"
ln -s "$repo_root/agents/.agents/skills" "$home_dir/.claude/skills/skills"
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >/tmp/test-install.out
[ -L "$home_dir/.claude/skills" ] || fail "Expected repaired ~/.claude/skills symlink"
assert_not_exists "$home_dir/.claude/skills/skills"
}
test_preserves_nonempty_skills_directory() {
local fixture repo_root home_dir
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
mkdir -p "$home_dir/.claude/skills"
echo local > "$home_dir/.claude/skills/local.txt"
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >/tmp/test-install.out
[ -d "$home_dir/.claude/skills" ] || fail "Expected ~/.claude/skills directory to be preserved"
[ -f "$home_dir/.claude/skills/local.txt" ] || fail "Expected local skill data to be preserved"
}
test_broken_claude_symlink_does_not_fail() {
local fixture repo_root home_dir
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
mkdir -p "$home_dir"
ln -s "$fixture/missing-claude-dir" "$home_dir/.claude"
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >/tmp/test-install.out
[ -d "$home_dir/.claude" ] || fail "Expected ~/.claude directory after migration"
[ ! -L "$home_dir/.claude" ] || fail "Expected ~/.claude to stop being a symlink"
}
test_wsl_noninteractive_skips_file_changes() {
local fixture repo_root home_dir wsl_conf output
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
wsl_conf="$fixture/wsl.conf"
mkdir -p "$home_dir"
printf '[automount]\n' > "$wsl_conf"
output="$(
run_install "$repo_root" "$home_dir" \
env WSL_DISTRO_NAME=Ubuntu INSTALL_INTERACTIVE_MODE=never WSL_CONF_PATH="$wsl_conf"
)"
assert_file_contains "$wsl_conf" "[automount]"
if grep -Fq '[boot]' "$wsl_conf"; then
fail "WSL config should not be modified in non-interactive mode"
fi
printf '%s' "$output" | grep -Fq 'Добавьте вручную:' || fail "Expected manual WSL hint"
}
test_wsl_interactive_adds_boot_command() {
local fixture repo_root home_dir wsl_conf command_line
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
wsl_conf="$fixture/wsl.conf"
mkdir -p "$home_dir"
printf '[automount]\n' > "$wsl_conf"
command_line="command = \"$repo_root/scripts/setup-hproxy.sh\""
printf 'y\n' | run_install "$repo_root" "$home_dir" \
env WSL_DISTRO_NAME=Ubuntu INSTALL_INTERACTIVE_MODE=always WSL_CONF_PATH="$wsl_conf" >/tmp/test-install.out
assert_file_contains "$wsl_conf" "[boot]"
assert_file_contains "$wsl_conf" "$command_line"
}
test_wsl_existing_boot_command_is_preserved() {
local fixture repo_root home_dir wsl_conf output
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
wsl_conf="$fixture/wsl.conf"
mkdir -p "$home_dir"
cat > "$wsl_conf" <<'EOF'
[boot]
command = "/usr/local/bin/existing.sh"
EOF
output="$(
printf 'y\n' | run_install "$repo_root" "$home_dir" \
env WSL_DISTRO_NAME=Ubuntu INSTALL_INTERACTIVE_MODE=always WSL_CONF_PATH="$wsl_conf"
)"
assert_file_contains "$wsl_conf" 'command = "/usr/local/bin/existing.sh"'
if grep -Fq 'setup-hproxy.sh' "$wsl_conf"; then
fail "Expected existing boot command to stay untouched"
fi
printf '%s' "$output" | grep -Fq 'уже содержит [boot] command' || fail "Expected warning about existing boot command"
}
main() {
command -v stow >/dev/null 2>&1 || fail "stow is required for tests"
test_fresh_install_uses_home_target
test_dirty_repo_settings_copy_uses_committed_version
test_rerun_does_not_duplicate_bashrc_line
test_repairs_nested_skills_directory_bug
test_preserves_nonempty_skills_directory
test_broken_claude_symlink_does_not_fail
test_wsl_noninteractive_skips_file_changes
test_wsl_interactive_adds_boot_command
test_wsl_existing_boot_command_is_preserved
echo "All install tests passed"
}
main "$@"