Files
dotfiles/install.sh
T
ddadmin c6d7e7eeb0 feat(install): конфликтующие файлы бэкапятся перед stow для всех пакетов
- Зачем:
  - конфликт с уже существующим конфигом приходилось разбирать руками:
    stow печатал "All operations aborted" и откатывал все пакеты сразу.
- Что:
  - backup_conflicting_kitty_files заменена на общую
    backup_conflicting_stow_targets по списку AVAILABLE_PACKAGES.
  - обход повторяет логику stow: отсутствующая цель и каталоги-симлинки
    пропускаются вместе с поддеревом, поэтому свёрнутые пакеты
    (~/.codex, ~/.vscode-server) не обходятся и не бэкапятся.
  - в фикстуру тестов добавлен пакет wezterm, добавлены тесты на конфликт
    в HOME и на неприкосновенность содержимого свёрнутого пакета.
- Проверка:
  - bash tests/test_install.sh
2026-07-26 17:53:30 +03:00

499 lines
20 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
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'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
# ── Утилиты ───────────────────────────────────────────────────────────
info() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
error() { echo -e "${RED}[✗]${NC} $*"; }
# Интерактивный yes/no промпт (принимает английскую y и русскую у).
ask_yes_no() {
local answer=""
echo -ne "${YELLOW}[?]${NC} $1 [y/N] "
read -r answer || answer=""
[[ "$answer" =~ ^[YyУу]$ ]]
}
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-лог с отступом.
stow_package() {
"$STOW_BIN" --target="$HOME" -v "$@" 2>&1 | sed "s/^/ /"
}
# Создать симлинк src → dst. Обрабатывает миграцию с директории на симлинк.
ensure_symlink() {
local src="$1"
local dst="$2"
if [ -L "$dst" ]; then
ln -sfnT "$src" "$dst"
return 0
fi
if [ -d "$dst" ]; then
# Миграция: директория с единственным симлинком skills → заменяем целиком
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"
}
# Сохранить бэкап с временной меткой (и PID при коллизии).
backup_existing_path() {
local path="$1"
local label="$2"
local backup_path
backup_path="${path}.bak.$(date +%Y%m%d%H%M%S)"
if [ -e "$backup_path" ] || [ -L "$backup_path" ]; then
backup_path="${backup_path}.$$"
fi
mv "$path" "$backup_path"
warn "$label уже существовал, сохранил резервную копию: $backup_path"
}
# Копировать файл с бэкапом. Не трогает директории и спецфайлы.
copy_managed_file() {
local src="$1"
local dst="$2"
local label="$3"
local src_real=""
local dst_real=""
mkdir -p "$(dirname "$dst")"
src_real="$(readlink -f "$src" 2>/dev/null || printf '%s' "$src")"
if [ -L "$dst" ]; then
# Симлинк на тот же источник → просто удаляем (заменим копией)
dst_real="$(readlink -f "$dst" 2>/dev/null || true)"
if [ "$dst_real" = "$src_real" ]; then
rm "$dst"
else
backup_existing_path "$dst" "$label"
fi
elif [ -f "$dst" ]; then
# Контент совпадает → ничего не делаем
if cmp -s "$src" "$dst"; then
return 0
fi
backup_existing_path "$dst" "$label"
elif [ -d "$dst" ]; then
warn "$dst уже существует как директория, пропускаю замену"
return 0
elif [ -e "$dst" ]; then
warn "$dst уже существует и не является обычным файлом, пропускаю замену"
return 0
fi
cp -f "$src" "$dst"
}
# Парсит [boot] секцию wsl.conf, проверяет наличие command = ...
wsl_conf_has_boot_command() {
awk '
/^\[boot\]$/ { in_boot=1; next }
/^\[/ { in_boot=0 }
in_boot && /^[[:space:]]*command[[:space:]]*=/ { found=1 }
END { exit !found }
' "$1" 2>/dev/null
}
# Миграция: если ~/.claude — stow-симлинк, раскрыть в директорию.
migrate_claude_stow_symlink() {
[ -L "$HOME/.claude" ] || return 0
info "Миграция: ~/.claude — симлинк, преобразую в директорию..."
local stow_dir
stow_dir="$(readlink -f "$HOME/.claude" 2>/dev/null || true)"
rm "$HOME/.claude"
mkdir -p "$HOME/.claude"
if [ -n "$stow_dir" ] && [ -d "$stow_dir" ]; then
for item in "$stow_dir"/* "$stow_dir"/.[!.]*; do
[ -e "$item" ] || continue
local base
base=$(basename "$item")
# git-tracked файлы остаются в репозитории
case "$base" in .gitignore|settings.local.json|skills) continue ;; esac
mv "$item" "$HOME/.claude/" 2>/dev/null || true
done
info "Рантайм-данные перенесены в ~/.claude/"
else
warn "Не удалось прочитать источник старого симлинка ~/.claude, продолжаю без миграции данных"
fi
}
# Убрать с пути чужие файлы, которые конфликтуют со stow-пакетами.
#
# Программы создают свои конфиги сами (kitty.conf, .wezterm.lua), и при
# конфликте stow не просто пропускает пакет, а печатает "All operations
# aborted" и откатывает весь набор: install.sh падает по pipefail, не дойдя
# до настройки claude, opencode и .bashrc. Раньше это чинилось руками.
#
# Обход повторяет логику stow, а не перебирает файлы подряд: если каталога в
# HOME нет, конфликта быть не может и всё поддерево пропускается. Это заодно
# отсекает свёрнутые пакеты (~/.codex, ~/.vscode-server) с их гигабайтами
# рантайм-данных.
backup_conflicting_stow_targets() {
local pkg=""
for pkg in "$@"; do
backup_conflicting_tree "$DOTFILES_DIR/$pkg" "$HOME" ""
done
}
# Рекурсивный обход одного уровня: src_dir — каталог пакета, dst_dir — его
# соответствие в HOME, rel — путь для сообщений.
backup_conflicting_tree() {
local src_dir="$1"
local dst_dir="$2"
local rel="$3"
local src=""
local dst=""
local base=""
local src_real=""
local dst_real=""
[ -d "$src_dir" ] || return 0
for src in "$src_dir"/* "$src_dir"/.[!.]*; do
[ -e "$src" ] || [ -L "$src" ] || continue
base="$(basename "$src")"
dst="$dst_dir/$base"
# Цели нет — stow создаст симлинк или свернёт каталог, конфликта нет.
[ -e "$dst" ] || [ -L "$dst" ] || continue
if [ -d "$src" ] && [ ! -L "$src" ]; then
# Каталог: симлинк в цели трогать нельзя — stow спускается внутрь,
# так работают и свёрнутые пакеты, и посторонний симлинк ~/.config.
[ ! -L "$dst" ] || continue
# Два настоящих каталога stow объединяет, конфликт может быть глубже.
if [ -d "$dst" ]; then
backup_conflicting_tree "$src" "$dst" "$rel/$base"
continue
fi
# Файл на месте каталога — stow каталог поверх него не создаст.
backup_existing_path "$dst" "~$rel/$base"
continue
fi
# Файл: симлинк на наш же файл — результат прошлого прогона.
if [ -L "$dst" ]; then
src_real="$(readlink -f "$src" 2>/dev/null || true)"
dst_real="$(readlink -f "$dst" 2>/dev/null || true)"
if [ -n "$src_real" ] && [ "$src_real" = "$dst_real" ]; then
continue
fi
fi
backup_existing_path "$dst" "~$rel/$base"
done
}
# Миграция: раскрыть старую stow-ссылку ~/.config -> zed/.config.
# В пакете zed остаётся только управляемый каталог zed; остальные настройки
# возвращаются в домашний каталог и больше не попадают в репозиторий.
migrate_zed_config_stow_symlink() {
local config_path="$HOME/.config"
local zed_config="$DOTFILES_DIR/zed/.config"
local config_real=""
local zed_config_real=""
local replacement=""
local items_file=""
local item=""
local base=""
local moved_items=()
[ -L "$config_path" ] || return 0
config_real="$(readlink -f "$config_path" 2>/dev/null || true)"
zed_config_real="$(readlink -f "$zed_config" 2>/dev/null || true)"
if [ -z "$zed_config_real" ] || [ "$config_real" != "$zed_config_real" ]; then
return 0
fi
info "Миграция: ~/.config больше не должен принадлежать пакету zed..."
replacement="$(mktemp -d "$HOME/.config.migrate.XXXXXX")"
chmod --reference="$zed_config_real" "$replacement"
items_file="$(mktemp "$HOME/.config.migrate-items.XXXXXX")"
if ! find "$zed_config_real" -mindepth 1 -maxdepth 1 ! -name zed -print0 > "$items_file"; then
error "Не удалось получить полный список старого ~/.config"
rm -f -- "$items_file"
rmdir "$replacement" 2>/dev/null || true
return 1
fi
while IFS= read -r -d '' item; do
base="${item##*/}"
if ! mv -- "$item" "$replacement/$base"; then
error "Не удалось перенести ~/.config/$base"
for base in "${moved_items[@]}"; do
mv -- "$replacement/$base" "$zed_config_real/$base" 2>/dev/null || true
done
rm -f -- "$items_file"
rmdir "$replacement" 2>/dev/null || true
return 1
fi
moved_items+=("$base")
done < "$items_file"
rm -f -- "$items_file"
if ! rm -- "$config_path"; then
error "Не удалось удалить старую ссылку ~/.config"
for base in "${moved_items[@]}"; do
mv -- "$replacement/$base" "$zed_config_real/$base" 2>/dev/null || true
done
rmdir "$replacement" 2>/dev/null || true
return 1
fi
if ! mv -- "$replacement" "$config_path"; then
error "Не удалось создать новый каталог ~/.config"
ln -s "$zed_config_real" "$config_path" 2>/dev/null || true
for base in "${moved_items[@]}"; do
mv -- "$replacement/$base" "$zed_config_real/$base" 2>/dev/null || true
done
rmdir "$replacement" 2>/dev/null || true
return 1
fi
info "Настройки перенесены из пакета zed в ~/.config"
}
# Настроить boot command для hproxy.local в /etc/wsl.conf.
setup_wsl_hproxy() {
[ -n "${WSL_DISTRO_NAME:-}" ] || return 0
local script="$DOTFILES_DIR/scripts/setup-hproxy.sh"
local boot_cmd="command = \"$script\""
local legacy_cmd="command = \"ip route show default | awk '{print \$3 \\\" hproxy.local\\\"}' >> /etc/hosts\""
# Уже прописан наш скрипт
if grep -Fq "$boot_cmd" "$WSL_CONF_PATH" 2>/dev/null; then
warn "/etc/wsl.conf уже содержит boot command для setup-hproxy.sh, пропускаю"
return 0
fi
# Legacy inline command → предложить замену
if grep -Fq "$legacy_cmd" "$WSL_CONF_PATH" 2>/dev/null; then
warn "/etc/wsl.conf содержит legacy boot command для hproxy.local"
info "Рекомендуется заменить на: $boot_cmd"
if is_interactive && ask_yes_no "Заменить legacy command на setup-hproxy.sh?"; then
"$SUDO_BIN" sed -i '/ip route.*hproxy\.local/d' "$WSL_CONF_PATH"
"$SUDO_BIN" sed -i "/^\[boot\]/a command = \"$script\"" "$WSL_CONF_PATH"
info "Заменено. Перезапустите WSL: wsl --shutdown"
fi
return 0
fi
# Есть какой-то другой boot command
if wsl_conf_has_boot_command "$WSL_CONF_PATH"; then
warn "/etc/wsl.conf уже содержит [boot] command. Добавьте setup-hproxy.sh вручную"
info "Нужная строка: $boot_cmd"
return 0
fi
# Не интерактивный режим → только подсказка
if ! is_interactive; then
warn "WSL обнаружен, но stdin не интерактивный. Пропускаю настройку /etc/wsl.conf"
info "Добавьте вручную: $boot_cmd"
return 0
fi
# Интерактивный режим → спросить
echo ""
info "Обнаружен WSL. Скрипт $script может автоматически"
info "прописывать hproxy.local в /etc/hosts при каждом старте WSL (NAT mode)."
if ask_yes_no "Добавить boot command в /etc/wsl.conf?"; then
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 = \"$script\"" "$WSL_CONF_PATH"
info "Добавлено в /etc/wsl.conf. Перезапустите WSL: wsl --shutdown"
else
warn "Пропущено. Можно настроить вручную — см. README.md"
fi
}
# ── Stow пакеты ──────────────────────────────────────────────────────
if ! command -v "$STOW_BIN" &>/dev/null; then
error "stow не найден. Установите: sudo apt install stow"
exit 1
fi
PACKAGES=(bash tmux vim wezterm kitty vscode agents codex gemini proxy-client)
AVAILABLE_PACKAGES=()
info "Применяю stow пакеты..."
cd "$DOTFILES_DIR"
for pkg in "${PACKAGES[@]}"; do
if [ -d "$pkg" ]; then
AVAILABLE_PACKAGES+=("$pkg")
else
warn "Пакет $pkg не найден, пропускаю"
fi
done
migrate_zed_config_stow_symlink
if [ ! -L "$HOME/.local" ]; then
mkdir -p "$HOME/.local"
fi
if [ -d "$HOME/.local" ] && [ ! -L "$HOME/.local" ] \
&& [ ! -L "$HOME/.local/bin" ]; then
mkdir -p "$HOME/.local/bin"
fi
if [ ! -L "$HOME/.config" ]; then
mkdir -p "$HOME/.config"
fi
if [ -d "$HOME/.config" ] && [ ! -L "$HOME/.config" ] \
&& [ ! -L "$HOME/.config/proxy-client" ]; then
mkdir -p "$HOME/.config/proxy-client"
fi
# ~/.config/kitty создаётся заранее настоящим каталогом, чтобы stow не свернул
# его в один симлинк: kitty пишет туда свои файлы (kitty.conf.bak от
# `kitten themes`), и при свёрнутом каталоге они попадали бы в репозиторий.
if [ -d "$HOME/.config" ] && [ ! -L "$HOME/.config" ] \
&& [ ! -L "$HOME/.config/kitty" ]; then
mkdir -p "$HOME/.config/kitty"
fi
if [ "${#AVAILABLE_PACKAGES[@]}" -gt 0 ]; then
backup_conflicting_stow_targets "${AVAILABLE_PACKAGES[@]}"
stow_package "${AVAILABLE_PACKAGES[@]}"
fi
SKILLS_SRC="$DOTFILES_DIR/agents/.agents/skills"
# ── Opencode ─────────────────────────────────────────────────────────
OPENCODE_DIR="$HOME/.config/opencode"
OPENCODE_SRC_DIR="$DOTFILES_DIR/opencode/.config/opencode"
OPENCODE_JSON_SRC="$OPENCODE_SRC_DIR/opencode.json"
mkdir -p "$OPENCODE_DIR"
if [ -d "$OPENCODE_SRC_DIR/agents" ]; then
ensure_symlink "$OPENCODE_SRC_DIR/agents" "$OPENCODE_DIR/agents"
fi
if [ -d "$SKILLS_SRC" ]; then
ensure_symlink "$SKILLS_SRC" "$OPENCODE_DIR/skills"
fi
if [ -f "$OPENCODE_JSON_SRC" ]; then
copy_managed_file "$OPENCODE_JSON_SRC" "$OPENCODE_DIR/opencode.json" "opencode.json"
fi
# ── Claude Code ──────────────────────────────────────────────────────
# settings.local.json ЛИНКУЕТСЯ из репозитория (базовые permissions + statusLine).
# Claude Code не модифицирует settings.local.json — только читает и мержит
# с settings.json, который Claude Code создаёт и обновляет самостоятельно.
# Машинно-специфичные настройки (model, effortLevel) — в settings.json.
migrate_claude_stow_symlink
mkdir -p "$HOME/.claude"
# Skills (симлинк)
if [ -d "$SKILLS_SRC" ]; then
ensure_symlink "$SKILLS_SRC" "$HOME/.claude/skills"
fi
# settings.local.json — базовые настройки из репозитория (симлинк)
CLAUDE_LOCAL_SRC="$DOTFILES_DIR/claude/.claude/settings.local.json"
CLAUDE_LOCAL_DST="$HOME/.claude/settings.local.json"
if [ -f "$CLAUDE_LOCAL_SRC" ]; then
if [ -L "$CLAUDE_LOCAL_DST" ]; then
ln -sfnT "$CLAUDE_LOCAL_SRC" "$CLAUDE_LOCAL_DST"
info "settings.local.json → репозиторий"
elif [ -f "$CLAUDE_LOCAL_DST" ]; then
backup_existing_path "$CLAUDE_LOCAL_DST" "settings.local.json"
ln -s "$CLAUDE_LOCAL_SRC" "$CLAUDE_LOCAL_DST"
info "settings.local.json → репозиторий"
info "Машинно-специфичные настройки (model, effortLevel) теперь задаются через Claude Code"
elif [ -d "$CLAUDE_LOCAL_DST" ] || [ -e "$CLAUDE_LOCAL_DST" ]; then
warn "$CLAUDE_LOCAL_DST уже существует (не файл и не симлинк), удалите вручную"
else
ln -s "$CLAUDE_LOCAL_SRC" "$CLAUDE_LOCAL_DST"
info "settings.local.json → репозиторий"
fi
fi
# statusline-command.sh — копия из репозитория (на Windows симлинки ненадёжны)
CLAUDE_STATUSLINE_SRC="$DOTFILES_DIR/claude/.claude/statusline-command.sh"
CLAUDE_STATUSLINE_DST="$HOME/.claude/statusline-command.sh"
if [ -f "$CLAUDE_STATUSLINE_SRC" ]; then
install -m 755 "$CLAUDE_STATUSLINE_SRC" "$CLAUDE_STATUSLINE_DST"
info "statusline-command.sh скопирован в ~/.claude/"
fi
# ── Bash: .bashrc ────────────────────────────────────────────────────
BASHRC="$HOME/.bashrc"
BASHRC_LINE='[ -f ~/.bash_custom ] && source ~/.bash_custom'
if grep -q 'bash_custom' "$BASHRC" 2>/dev/null; then
warn "~/.bashrc уже содержит bash_custom, пропускаю"
else
echo "" >> "$BASHRC"
echo "# dotfiles" >> "$BASHRC"
echo "$BASHRC_LINE" >> "$BASHRC"
info "Добавлено в ~/.bashrc: $BASHRC_LINE"
fi
# ── WSL: hproxy.local ────────────────────────────────────────────────
setup_wsl_hproxy
info "Готово!"