diff --git a/install.sh b/install.sh index 4fd911f..07bd4d6 100755 --- a/install.sh +++ b/install.sh @@ -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 diff --git a/tests/test_install.sh b/tests/test_install.sh new file mode 100755 index 0000000..87bb6e6 --- /dev/null +++ b/tests/test_install.sh @@ -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 "$@"