#!/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-ссылку ~/.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 zed 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 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.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 # ── 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 "Готово!"