Files
dotfiles/install.sh
T
ddadminandClaude Opus 4.6 8567b2262c refactor(install): причёсан стиль и структура install.sh
- Зачем:
  - скрипт, написанный Codex, отличался по стилю от остального репозитория,
    трудно читался из-за вложенных условий и отсутствия визуальной структуры.
- Что:
  - добавлены секционные заголовки в стиле profile.ps1 (# ── Name ──────).
  - извлечён ask_yes_no() — убрано дублирование промптов.
  - WSL-логика вынесена в setup_wsl_hproxy() с flat early returns.
  - миграция stow-симлинка вынесена в migrate_claude_stow_symlink().
  - добавлены однострочные комментарии к хелперам и нетривиальным веткам.
- Проверка:
  - bash tests/test_install.sh
  - ./install.sh (идемпотентный повторный прогон)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:03:12 +03:00

334 lines
13 KiB
Bash
Executable File

#!/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.json|skills) continue ;; esac
mv "$item" "$HOME/.claude/" 2>/dev/null || true
done
info "Рантайм-данные перенесены в ~/.claude/"
else
warn "Не удалось прочитать источник старого симлинка ~/.claude, продолжаю без миграции данных"
fi
}
# Настроить 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 zed vscode agents codex gemini)
AVAILABLE_PACKAGES=()
info "Применяю stow пакеты..."
cd "$DOTFILES_DIR"
for pkg in "${PACKAGES[@]}"; do
if [ -d "$pkg" ]; then
AVAILABLE_PACKAGES+=("$pkg")
else
warn "Пакет $pkg не найден, пропускаю"
fi
done
if [ "${#AVAILABLE_PACKAGES[@]}" -gt 0 ]; then
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.json КОПИРУЕТСЯ (не линкуется), чтобы Claude Code не модифицировал
# файл в репозитории. Локальные настройки — в settings.local.json.
migrate_claude_stow_symlink
mkdir -p "$HOME/.claude"
# Skills (симлинк)
if [ -d "$SKILLS_SRC" ]; then
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
chmod 444 "$CLAUDE_SETTINGS_SRC"
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
# settings.local.json — локальные переопределения (не в git)
CLAUDE_LOCAL="$HOME/.claude/settings.local.json"
if [ ! -f "$CLAUDE_LOCAL" ]; then
info "Создаю $CLAUDE_LOCAL (локальные настройки Claude)..."
cat > "$CLAUDE_LOCAL" <<'EOF'
{
"model": "opus",
"effortLevel": "high"
}
EOF
else
warn "$CLAUDE_LOCAL уже существует, пропускаю"
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 "Готово!"