- Зачем:
- Claude Code перезаписывает settings.json при каждом «always allow», что создавало
конфликт с managed-копией из dotfiles при повторном запуске install.sh.
- Что:
- settings.json удалён из репозитория, Claude Code владеет им полностью.
- создан settings.local.json (базовые permissions + statusLine), деплоится через симлинк.
- install.sh: копирование + шаблон заменены на ln -sf с бэкапом и guard для edge cases.
- check.sh: проверка обновлена под симлинк вместо «файл + chmod 444».
- тесты: старый тест копирования заменён на 4 новых (симлинк, бэкап, директория, stow-миграция).
- Проверка:
- bash tests/test_install.sh (15 тестов).
- bash agents/.agents/skills/dotfiles-check/check.sh.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
404 lines
14 KiB
Bash
Executable File
404 lines
14 KiB
Bash
Executable File
#!/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_settings_local_is_symlinked_to_repo() {
|
|
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/.claude/settings.local.json" ] || fail "Expected settings.local.json to be a symlink"
|
|
local target
|
|
target="$(readlink -f "$home_dir/.claude/settings.local.json")"
|
|
local expected
|
|
expected="$(readlink -f "$repo_root/claude/.claude/settings.local.json")"
|
|
[ "$target" = "$expected" ] || fail "settings.local.json points to $target, expected $expected"
|
|
}
|
|
|
|
test_settings_local_backup_on_existing_file() {
|
|
local fixture repo_root home_dir
|
|
fixture="$(setup_fixture)"
|
|
repo_root="$fixture/repo"
|
|
home_dir="$fixture/home"
|
|
mkdir -p "$home_dir/.claude"
|
|
echo '{"model":"sonnet"}' > "$home_dir/.claude/settings.local.json"
|
|
|
|
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >/tmp/test-install.out
|
|
|
|
[ -L "$home_dir/.claude/settings.local.json" ] || fail "Expected settings.local.json to become a symlink"
|
|
local backup
|
|
backup="$(find "$home_dir/.claude" -maxdepth 1 -name 'settings.local.json.bak.*' -print -quit)"
|
|
[ -n "$backup" ] || fail "Expected backup for existing settings.local.json"
|
|
assert_file_contains "$backup" '"model":"sonnet"'
|
|
}
|
|
|
|
test_settings_local_dir_is_skipped() {
|
|
local fixture repo_root home_dir
|
|
fixture="$(setup_fixture)"
|
|
repo_root="$fixture/repo"
|
|
home_dir="$fixture/home"
|
|
mkdir -p "$home_dir/.claude/settings.local.json"
|
|
|
|
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >/tmp/test-install.out
|
|
|
|
[ -d "$home_dir/.claude/settings.local.json" ] || fail "Expected directory to be preserved (not replaced)"
|
|
}
|
|
|
|
test_stow_migration_preserves_legacy_settings_local() {
|
|
local fixture repo_root home_dir stow_target
|
|
fixture="$(setup_fixture)"
|
|
repo_root="$fixture/repo"
|
|
home_dir="$fixture/home"
|
|
mkdir -p "$home_dir"
|
|
|
|
# Simulate old stow layout: ~/.claude → repo dir
|
|
stow_target="$repo_root/claude/.claude"
|
|
ln -s "$stow_target" "$home_dir/.claude"
|
|
# Put a legacy settings.local.json in the stow target (would have been gitignored before)
|
|
echo '{"model":"opus"}' > "$stow_target/legacy-local.json"
|
|
|
|
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 to become a directory"
|
|
[ ! -L "$home_dir/.claude" ] || fail "Expected ~/.claude to stop being a symlink"
|
|
[ -L "$home_dir/.claude/settings.local.json" ] || fail "Expected settings.local.json symlink after migration"
|
|
# legacy-local.json should have been moved from stow dir to real ~/.claude
|
|
[ -f "$home_dir/.claude/legacy-local.json" ] || fail "Expected legacy file to be migrated"
|
|
}
|
|
|
|
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_opencode_runtime_dir_is_preserved_and_config_is_managed() {
|
|
local fixture repo_root home_dir backup_path
|
|
fixture="$(setup_fixture)"
|
|
repo_root="$fixture/repo"
|
|
home_dir="$fixture/home"
|
|
mkdir -p "$home_dir/.config/opencode"
|
|
echo runtime > "$home_dir/.config/opencode/package.json"
|
|
cat > "$home_dir/.config/opencode/opencode.json" <<'EOF'
|
|
{"source":"local"}
|
|
EOF
|
|
|
|
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >/tmp/test-install.out
|
|
|
|
[ -f "$home_dir/.config/opencode/package.json" ] || fail "Expected opencode runtime files to be preserved"
|
|
[ -L "$home_dir/.config/opencode/agents" ] || fail "Expected ~/.config/opencode/agents symlink"
|
|
[ -L "$home_dir/.config/opencode/skills" ] || fail "Expected ~/.config/opencode/skills symlink"
|
|
assert_file_contains "$home_dir/.config/opencode/opencode.json" '"$schema": "https://opencode.ai/config.json"'
|
|
|
|
backup_path="$(find "$home_dir/.config/opencode" -maxdepth 1 -name 'opencode.json.bak.*' -print -quit)"
|
|
[ -n "$backup_path" ] || fail "Expected backup for existing opencode.json"
|
|
assert_file_contains "$backup_path" '"source":"local"'
|
|
}
|
|
|
|
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_absolute_symlinks_in_home_do_not_break_install() {
|
|
local fixture repo_root home_dir
|
|
fixture="$(setup_fixture)"
|
|
repo_root="$fixture/repo"
|
|
home_dir="$fixture/home"
|
|
mkdir -p "$home_dir" "$fixture/windows/.aws" "$fixture/windows/.azure"
|
|
ln -s "$fixture/windows/.aws" "$home_dir/.aws"
|
|
ln -s "$fixture/windows/.azure" "$home_dir/.azure"
|
|
|
|
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
|
|
|
|
[ -L "$home_dir/.aws" ] || fail "Expected ~/.aws symlink to survive install"
|
|
[ -L "$home_dir/.azure" ] || fail "Expected ~/.azure symlink to survive install"
|
|
}
|
|
|
|
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_noninteractive_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="$(
|
|
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" '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"
|
|
if printf '%s' "$output" | grep -Fq 'Добавьте вручную:'; then
|
|
fail "Did not expect manual add hint when [boot] command already exists"
|
|
fi
|
|
}
|
|
|
|
test_wsl_legacy_boot_command_suggests_migration() {
|
|
local fixture repo_root home_dir wsl_conf output command_line
|
|
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 = "ip route show default | awk '{print $3 \" hproxy.local\"}' >> /etc/hosts"
|
|
EOF
|
|
command_line="command = \"$repo_root/scripts/setup-hproxy.sh\""
|
|
|
|
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" 'command = "ip route show default | awk '\''{print $3 \" hproxy.local\"}'\'' >> /etc/hosts"'
|
|
printf '%s' "$output" | grep -Fq 'legacy boot command' || fail "Expected legacy boot command warning"
|
|
printf '%s' "$output" | grep -Fq 'Рекомендуется заменить' || fail "Expected migration hint to replace legacy command"
|
|
printf '%s' "$output" | grep -Fq "$command_line" || fail "Expected suggested setup-hproxy.sh command"
|
|
}
|
|
|
|
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_settings_local_is_symlinked_to_repo
|
|
test_settings_local_backup_on_existing_file
|
|
test_settings_local_dir_is_skipped
|
|
test_stow_migration_preserves_legacy_settings_local
|
|
test_rerun_does_not_duplicate_bashrc_line
|
|
test_repairs_nested_skills_directory_bug
|
|
test_preserves_nonempty_skills_directory
|
|
test_opencode_runtime_dir_is_preserved_and_config_is_managed
|
|
test_broken_claude_symlink_does_not_fail
|
|
test_absolute_symlinks_in_home_do_not_break_install
|
|
test_wsl_noninteractive_skips_file_changes
|
|
test_wsl_noninteractive_existing_boot_command_is_preserved
|
|
test_wsl_legacy_boot_command_suggests_migration
|
|
test_wsl_interactive_adds_boot_command
|
|
test_wsl_existing_boot_command_is_preserved
|
|
|
|
echo "All install tests passed"
|
|
}
|
|
|
|
main "$@"
|