diff --git a/.scratch/.gitkeep b/.scratch/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/docs/specs/2026-07-15-proxy-client-design.md b/docs/specs/2026-07-15-proxy-client-design.md index 0345263..e4b3a4a 100644 --- a/docs/specs/2026-07-15-proxy-client-design.md +++ b/docs/specs/2026-07-15-proxy-client-design.md @@ -2,7 +2,8 @@ Дата: 2026-07-15 -Статус: реализация готова к ручной приёмке; публичное имя уточнено 2026-07-21 +Статус: основная реализация принята вручную; самостоятельный установщик +спроектирован 2026-07-21, но ещё не реализован ## Проблема @@ -48,8 +49,9 @@ proxy-переменных совместима с локальным Xray бе JSON Xray. - Сохранить совместимость Bash-функций с внешним Hiddify. - Позволить настраивать машинный `NO_PROXY` без правки файлов репозитория. -- Поддержать выборочную установку после `git clone`: через stow, ручные - симлинки или копирование. +- Поддержать самостоятельную установку из отдельно скопированного каталога + `proxy-client/` без зависимости от общего установщика dotfiles и без + обязательного stow. ## Не входит в объём @@ -89,6 +91,22 @@ Bash нужен только там, где необходимо изменит требует кода, но не решает основную боль: безопасный запуск `pclaude` и `pcodex` из точечной ссылки Hiddify. +### Доставка отдельного пакета + +Рассмотрены четыре способа подключить отдельно скопированный каталог: + +- оставить только инструкции для stow и ручных симлинков — просто, но сохраняет + легко забываемые шаги; +- копировать файлы в домашний каталог — не требует сохранять исходный каталог, + но усложняет обновления и расходится с моделью dotfiles; +- требовать stow — даёт привычное управление ссылками, но добавляет + необязательную зависимость на удалённых серверах; +- дать пакету собственный установщик, который использует stow при наличии и + создаёт те же симлинки напрямую без него. + +Выбран последний вариант. Он сохраняет один результат установки при разном +составе машины и не требует второй копии обновляемых файлов. + ## Рекомендуемое решение ### Границы компонентов @@ -376,15 +394,50 @@ CLI раскладывается как `~/.local/bin/proxy-client`. Машин `~/.config/proxy-client/`. Шаблоны не подменяют настоящие пользовательские файлы. -В корне этого каталога находится самостоятельный `README.md`. Он является -основной инструкцией клиента и исключается из раскладки stow. README остаётся -рядом с кодом при выборочном клонировании или копировании каталога. Корневой -README репозитория только кратко описывает подсистему и ссылается на него. +В корне этого каталога находятся самостоятельный `README.md` и пакетный +`install.sh`. Оба файла относятся к исходному каталогу и исключаются из +раскладки stow. README остаётся рядом с кодом при выборочном клонировании или +копировании каталога. Корневой README репозитория только кратко описывает +подсистему и ссылается на него. -После клонирования репозитория пакет можно подключить отдельно от общего -`install.sh`. README также должен описывать эквивалентные ручные симлинки и -копирование файлов. Полный `install.sh` подключает этот же stow-пакет вместе с -остальными dotfiles и не имеет отдельной реализации. +Пакетный установщик связывает отдельно скопированный каталог с домашним +каталогом и не зависит от положения или наличия остальных dotfiles. Если stow +доступен, установщик использует его; если нет — создаёт тот же набор симлинков +напрямую. Выбранный механизм явно показывается пользователю, но не меняет +результат. Исходный каталог должен оставаться на прежнем пути всё время +использования ссылок. + +Установщик управляет только тремя публичными артефактами: CLI, `shell.bash` и +каталогом шаблонов. Машинный `config` и закрытый `default.url` не входят в +раскладку stow, никогда не создаются, не заменяются и не связываются +установщиком. Их наличие в исходном каталоге также не должно расширять набор +управляемых путей. + +Домашние `~/.local/bin` и `~/.config/proxy-client` остаются настоящими +каталогами. Stow не должен сворачивать их или их родителей в одну ссылку на +исходное дерево: ссылками становятся только три перечисленных артефакта. Это +одновременно сохраняет место для машинных файлов и делает результат stow +совпадающим с прямой раскладкой через `ln -s`. + +Перед записью установщик проверяет весь набор назначений. Правильная ссылка на +текущий исходный каталог считается уже установленной; любой другой файл, +каталог или симлинк считается конфликтом. При конфликте установщик ничего не +меняет и сообщает все пути, которые пользователь должен разобрать вручную. +Принудительной замены и автоматических резервных копий нет. + +По умолчанию установщик идемпотентно подключает Bash-модуль в `~/.bashrc` и при +необходимости добавляет `~/.local/bin` в `PATH`. Уже существующее подключение +через dotfiles не дублируется. Управляемый блок помечается, повторно не +добавляется и может быть полностью отключён параметром `--no-shell-setup`. + +Установщик не выбирает режим прокси, не создаёт конфигурацию, не читает VLESS, +не устанавливает Xray и не проверяет сеть. После раскладки он проверяет только +локальные требования и исправность CLI и Bash-модуля, затем печатает краткую +подсказку для текущей shell-сессии и направляет пользователя в README за +настройкой режима. Недоступный прокси не делает установку неуспешной. + +Полный корневой `install.sh` продолжает подключать тот же stow-пакет вместе с +остальными dotfiles и не получает отдельной реализации этой логики. ## Решения и отклонённые альтернативы @@ -412,6 +465,14 @@ README репозитория только кратко описывает по автоматическое распределение портов отложены до появления реальной нужды. - Официальный системный установщик отклонён: он требует root и создаёт systemd-службы. Пользовательская установка соответствует выбранной модели. +- Копирование файлов пакета отклонено: оно создаёт отдельные экземпляры, + усложняет обновление и нарушает общую с dotfiles модель симлинков. +- Обязательный stow отклонён как лишняя зависимость. Прямые симлинки дают тот же + результат на машине без stow, а автоматический выбор механизма не меняет + публичное поведение пакета. +- Автоматическая настройка режима, установка Xray и проверка сети отклонены для + установщика пакета. Эти операции зависят от машины, могут требовать секретов + или сети и уже имеют отдельные явные команды и инструкции. - Готовые JSON и полный импорт Hiddify отклонены как основной интерфейс: точечная VLESS-ссылка проще для пользователя и ограничивает область преобразования. @@ -428,6 +489,15 @@ README репозитория только кратко описывает по истинное значение сопровождается предупреждением. - Старые дистрибутивы могут иметь Python ниже 3.8. CLI проверяет версию до основной работы и сообщает обнаруженную и требуемую версии. +- Симлинки зависят от постоянного пути исходного каталога. Установщик явно + сообщает эту зависимость и при повторном запуске принимает только ссылки на + текущий источник. +- Изменение `~/.bashrc` может конфликтовать с пользовательским способом загрузки + оболочки. Установщик распознаёт собственный блок и известное подключение из + dotfiles, не дублирует их и предоставляет `--no-shell-setup`. +- Частичная установка может оставить неоднозначное состояние. Все конфликты + назначений проверяются до изменений; проверки охватывают отсутствие новых + ссылок и правок shell при отказе. - `NO_PROXY` реализуется программами неодинаково. README рекомендует точные IP и домены, а CIDR описывает как совместимость best-effort. - Разные пользователи делят сетевое пространство имён. Настраиваемый порт и @@ -463,6 +533,22 @@ README репозитория только кратко описывает по - Регрессионные Bash-тесты подтверждают прежнее поведение Hiddify при отсутствии машинного конфига, оба регистра proxy-переменных, настраиваемый `NO_PROXY` и отсутствие автоматического запроса внешнего IP в `proxy_on`/`proxy_off`. +- Проверки самостоятельной установки покрывают путь со stow и без него, + одинаковый набор итоговых ссылок, запуск не из каталога пакета и каталог с + пробелами в пути. +- В обоих путях домашние каталоги остаются обычными каталогами, а пакетные + `README.md` и `install.sh` не появляются в домашнем каталоге через stow. +- Повторный запуск не меняет уже правильную установку. Конфликт любого + назначения обнаруживается до записи; чужие файлы, каталоги и ссылки остаются + неизменными. +- Машинный `config` и закрытый `default.url` не управляются установщиком даже + при их наличии в исходном каталоге или домашнем каталоге. +- Настройка Bash проверяется для нового `~/.bashrc`, уже помеченного блока, + подключения из полного dotfiles и режима `--no-shell-setup`. Добавление + `~/.local/bin` не дублируется. +- Локальная проверка установки не обращается к сети и не требует доступного + прокси. Неисправный или отсутствующий машинный конфиг не отменяет успешную + раскладку пакета. - Ручная проверка выполняется минимум на Ubuntu с Python 3.10 и на одной целевой удалённой системе. РЕД ОС, Astra Linux и Alt Linux проверяются по мере доступности: недоступная среда явно записывается как неподтверждённая, а @@ -474,7 +560,11 @@ README репозитория только кратко описывает по реализацией. Она должна описывать: - различие внешнего Hiddify и управляемого Xray; -- выборочная установка через stow, ручные симлинки и копирование; +- самостоятельный `install.sh`, его автоматический выбор stow или прямых + симлинков, требование сохранять исходный каталог и режим + `--no-shell-setup`; +- границу между управляемыми файлами пакета и создаваемыми вручную машинным + конфигом и закрытым профилем; - создание машинного INI из шаблона и отдельного файла VLESS-ссылки; - команды управления, `proxy_on/off`, `pclaude/pcodex`; - настройка `NO_PROXY`, ограничения CIDR и диагностика; diff --git a/proxy-client/.stow-local-ignore b/proxy-client/.stow-local-ignore index 8d287b5..9d84037 100644 --- a/proxy-client/.stow-local-ignore +++ b/proxy-client/.stow-local-ignore @@ -1 +1,4 @@ ^/README\.md$ +^/install\.sh$ +^/\.config/proxy-client/config$ +^/\.config/proxy-client/default\.url$ diff --git a/proxy-client/README.md b/proxy-client/README.md index 6c26514..4058a08 100644 --- a/proxy-client/README.md +++ b/proxy-client/README.md @@ -10,41 +10,68 @@ CLI требует Python 3.8+ и использует только станда ## Установка +### Только пакет `proxy-client` + +Скопируйте каталог в постоянное место и запустите пакетный установщик. Текущий +рабочий каталог не важен: + +```bash +~/src/proxy-client/install.sh +``` + +При наличии Stow установщик использует его, иначе создаёт те же три симлинка +напрямую: CLI, Bash-модуль и каталог шаблонов. Исходный каталог после установки +нельзя перемещать: домашние ссылки зависят от его постоянного пути. + +По умолчанию установщик добавляет в `~/.bashrc` помеченный блок для +`~/.local/bin` и Bash-модуля. После установки откройте новый Bash или примените +блок в текущей сессии: + +```bash +source ~/.bashrc +``` + +Чтобы оставить `~/.bashrc` без изменений, запустите: + +```bash +~/src/proxy-client/install.sh --no-shell-setup +``` + +В этом случае подготовьте только текущую сессию вручную: + +```bash +export PATH="$HOME/.local/bin:$PATH" +source ~/.config/proxy-client/shell.bash +``` + +Для следующих сессий добавьте эти строки в подходящий Bash-конфиг сами. + +Установщик не создаёт машинный `config` и закрытый `default.url`, не +устанавливает Xray и не проверяет сеть. После подключения команд выберите режим +по разделу [«Настройка режима»](#настройка-режима). Без машинного конфига +действует режим Hiddify по умолчанию. + ### В составе dotfiles -Полная установка подключает пакет и автоматически загружает Bash-функции в -новых интерактивных сессиях: +Корневой установщик dotfiles подключает тот же пакет через Stow и загружает +Bash-функции в новых интерактивных сессиях: ```bash cd ~/dotfiles ./install.sh ``` -Установщик заранее создаёт `~/.local/bin` и `~/.config/proxy-client`, чтобы Stow +Он заранее создаёт `~/.local/bin` и `~/.config/proxy-client`, чтобы Stow размещал ссылки внутри них, а не превращал сами каталоги в ссылки на -репозиторий. - -После установки откройте новый Bash или выполните: +репозиторий. После установки откройте новый Bash или выполните: ```bash +export PATH="$HOME/.local/bin:$PATH" source ~/.bash_custom ``` -### Только пакет `proxy-client` - -```bash -cd ~/dotfiles -mkdir -p ~/.local/bin ~/.config/proxy-client -stow proxy-client -source ~/.config/proxy-client/shell.bash -``` - -Если Bash-пакет dotfiles не установлен, добавьте подключение модуля в -`~/.bashrc`: - -```bash -printf '%s\n' 'source ~/.config/proxy-client/shell.bash' >> ~/.bashrc -``` +Если `~/.local/bin` отсутствует в `PATH` и в новых сессиях, добавьте ту же строку +`export` в свой Bash-конфиг: корневой установщик dotfiles его не изменяет. ## Как выбирается прокси @@ -80,28 +107,46 @@ proxy-client status pclaude --version ``` -В WSL NAT mode IP Windows-шлюза меняется при перезапуске. Интерактивный -`./install.sh` предлагает добавить обновление `hproxy.local` в `/etc/wsl.conf`. -То же можно настроить вручную: +#### WSL NAT mode -```ini -[boot] -command = "/path/to/dotfiles/scripts/setup-hproxy.sh" +IP Windows-шлюза меняется при перезапуске WSL, поэтому `hproxy.local` нужно +обновлять. В отдельно скопированном пакете системных помощников нет. Узнайте +адрес шлюза: + +```bash +ip route show default | awk 'NR == 1 {print $3}' ``` -После изменения выполните `wsl --shutdown` из Windows. Обновить адрес без -перезапуска WSL можно так: +Затем откройте `/etc/hosts` через `sudoedit` и оставьте одну отдельную строку с +полученным адресом: + +```bash +sudoedit /etc/hosts +``` + +```text +<адрес-шлюза> hproxy.local +``` + +Не удаляйте другие имена на той же строке: при необходимости перенесите их на +отдельную строку. Повторяйте обновление после перезапуска WSL. + +С полным dotfiles запустите корневой `~/dotfiles/install.sh` интерактивно: он +предложит автоматическое обновление при старте WSL. Обновить адрес сразу можно +так: ```bash sudo ~/dotfiles/scripts/setup-hproxy.sh -# или после загрузки Bash-функций: +# после настройки автозапуска и загрузки общих Bash-функций: update_hproxy ``` В NAT mode Hiddify должен слушать адрес, доступный через Windows-шлюз, а не только `127.0.0.1`. -В [WSL mirrored mode](https://learn.microsoft.com/windows/wsl/networking#mirrored-mode-networking) +#### WSL mirrored mode + +В [mirrored mode](https://learn.microsoft.com/windows/wsl/networking#mirrored-mode-networking) (`networkingMode=mirrored`) Windows-сервисы доступны из WSL через `127.0.0.1`. Чтобы сохранить адрес по умолчанию, добавьте алиас в Windows hosts: @@ -110,9 +155,9 @@ update_hproxy 127.0.0.1 hproxy.local ``` -Альтернатива — создать `config.external` с `address = 127.0.0.1`. В обоих -случаях проверьте результат через `getent hosts hproxy.local` или -`proxy-client status`. +Альтернатива — настройте внешний режим по следующему разделу и укажите +`address = 127.0.0.1`. Алиас проверяйте через `getent hosts hproxy.local`, а +активный конфиг — через `proxy-client status`. ### Другой внешний прокси @@ -158,7 +203,7 @@ chmod 600 ~/.config/proxy-client/default.url proxy-client install ``` -Запустите Xray в отдельной панели tmux: +Запустите Xray в отдельном терминале или панели tmux: ```bash proxy-client start @@ -247,20 +292,32 @@ export PROXY_PREFLIGHT_TIMEOUT=5 ## Справочник -### Ручное подключение без Stow +### Ручное подключение без установщика -Можно создать ссылки на CLI, Bash-модуль и примеры либо скопировать те же файлы: +Если пакетный установщик запустить нельзя, выберите постоянный абсолютный путь +к исходному каталогу и создайте те же ссылки вручную. Команды предполагают, что +назначения ещё не заняты: ```bash mkdir -p ~/.local/bin ~/.config/proxy-client -ln -s /path/to/proxy-client/.local/bin/proxy-client ~/.local/bin/proxy-client -ln -s /path/to/proxy-client/.config/proxy-client/shell.bash \ +ln -s /absolute/path/to/proxy-client/.local/bin/proxy-client \ + ~/.local/bin/proxy-client +ln -s /absolute/path/to/proxy-client/.config/proxy-client/shell.bash \ ~/.config/proxy-client/shell.bash -ln -s /path/to/proxy-client/.config/proxy-client/examples \ +ln -s /absolute/path/to/proxy-client/.config/proxy-client/examples \ ~/.config/proxy-client/examples +chmod 755 /absolute/path/to/proxy-client/.local/bin/proxy-client ``` -Исполняемому файлу нужен режим `755`, а `~/.local/bin` должен входить в `PATH`. +Подготовьте текущую Bash-сессию: + +```bash +export PATH="$HOME/.local/bin:$PATH" +source ~/.config/proxy-client/shell.bash +``` + +Для следующих сессий добавьте эти две строки в свой Bash-конфиг. Исходный +каталог после этого нельзя перемещать: созданные ссылки зависят от его пути. ### Машинный конфиг diff --git a/proxy-client/install.sh b/proxy-client/install.sh new file mode 100755 index 0000000..133858b --- /dev/null +++ b/proxy-client/install.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env bash +set -euo pipefail + +PACKAGE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +STOW_BIN="${STOW_BIN:-stow}" +SETUP_SHELL=1 + +usage() { + cat <<'EOF' +Использование: ./install.sh [--no-shell-setup] + + --no-shell-setup не изменять ~/.bashrc +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --no-shell-setup) + SETUP_SHELL=0 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Ошибка: неизвестный параметр: $1" >&2 + usage >&2 + exit 2 + ;; + esac + shift +done + +SOURCES=( + "$PACKAGE_DIR/.local/bin/proxy-client" + "$PACKAGE_DIR/.config/proxy-client/shell.bash" + "$PACKAGE_DIR/.config/proxy-client/examples" +) +TARGETS=( + "$HOME/.local/bin/proxy-client" + "$HOME/.config/proxy-client/shell.bash" + "$HOME/.config/proxy-client/examples" +) +STOW_IGNORE_PATTERNS=( + '^\.local/bin/proxy-client$' + '^\.config/proxy-client/shell\.bash$' + '^\.config/proxy-client/examples$' +) + +source_errors=() +[ -x "${SOURCES[0]}" ] || source_errors+=("${SOURCES[0]}") +[ -f "${SOURCES[1]}" ] || source_errors+=("${SOURCES[1]}") +[ -d "${SOURCES[2]}" ] || source_errors+=("${SOURCES[2]}") +if [ "${#source_errors[@]}" -gt 0 ]; then + echo "Ошибка: отсутствуют обязательные файлы исходного пакета:" >&2 + printf ' %s\n' "${source_errors[@]}" >&2 + exit 1 +fi + +"${SOURCES[0]}" --help >/dev/null +bash -n "${SOURCES[1]}" + +SHELL_BLOCK_START='# >>> proxy-client >>>' +SHELL_BLOCK_END='# <<< proxy-client <<<' +SHELL_SETUP_NEEDED=0 +BASH_CUSTOM_HAS_MODULE=0 +SHELL_PATH_NEEDED=1 +SHELL_SOURCE_NEEDED=1 +SHELL_BLOCK_CONFLICT=0 +if [ "$SETUP_SHELL" -eq 1 ]; then + if [ -f "$HOME/.bash_custom" ] \ + && grep -Fq 'proxy-client/shell.bash' "$HOME/.bash_custom"; then + BASH_CUSTOM_HAS_MODULE=1 + SHELL_SOURCE_NEEDED=0 + fi + + bashrc_start_count=0 + bashrc_end_count=0 + if [ -f "$HOME/.bashrc" ]; then + bashrc_start_count="$( + grep -Fxc "$SHELL_BLOCK_START" "$HOME/.bashrc" || true + )" + bashrc_end_count="$( + grep -Fxc "$SHELL_BLOCK_END" "$HOME/.bashrc" || true + )" + if grep -Fq 'export PATH="$HOME/.local/bin:$PATH"' "$HOME/.bashrc"; then + SHELL_PATH_NEEDED=0 + fi + fi + + if [ "$bashrc_start_count" -eq 0 ] && [ "$bashrc_end_count" -eq 0 ]; then + if [ "$SHELL_PATH_NEEDED" -eq 1 ] \ + || [ "$SHELL_SOURCE_NEEDED" -eq 1 ]; then + SHELL_SETUP_NEEDED=1 + fi + elif [ "$bashrc_start_count" -eq 1 ] \ + && [ "$bashrc_end_count" -eq 1 ] \ + && [ "$SHELL_PATH_NEEDED" -eq 0 ] \ + && { [ "$BASH_CUSTOM_HAS_MODULE" -eq 1 ] \ + || grep -Fq 'proxy-client/shell.bash' "$HOME/.bashrc"; }; then + : + else + SHELL_BLOCK_CONFLICT=1 + fi +fi + +conflicts=() +missing_targets=0 +stow_ignore_args=() +PARENT_DIRS=( + "$HOME/.local" + "$HOME/.local/bin" + "$HOME/.config" + "$HOME/.config/proxy-client" +) +for parent_dir in "${PARENT_DIRS[@]}"; do + if { [ -e "$parent_dir" ] || [ -L "$parent_dir" ]; } \ + && { [ ! -d "$parent_dir" ] || [ -L "$parent_dir" ]; }; then + conflicts+=("$parent_dir") + fi +done + +for index in "${!TARGETS[@]}"; do + target="${TARGETS[$index]}" + source_path="${SOURCES[$index]}" + if [ -e "$target" ] || [ -L "$target" ]; then + if [ ! -L "$target" ] \ + || [ "$(readlink -f "$target" 2>/dev/null || true)" != "$source_path" ]; then + conflicts+=("$target") + else + stow_ignore_args+=("--ignore=${STOW_IGNORE_PATTERNS[$index]}") + fi + else + missing_targets=$((missing_targets + 1)) + fi +done + +if [ "$SHELL_SETUP_NEEDED" -eq 1 ] \ + && { [ -e "$HOME/.bashrc" ] || [ -L "$HOME/.bashrc" ]; } \ + && [ ! -f "$HOME/.bashrc" ]; then + conflicts+=("$HOME/.bashrc") +fi +if [ "$SHELL_BLOCK_CONFLICT" -eq 1 ]; then + conflicts+=("$HOME/.bashrc") +fi + +if [ "${#conflicts[@]}" -gt 0 ]; then + echo "Ошибка: найдены конфликтующие пути:" >&2 + printf ' %s\n' "${conflicts[@]}" >&2 + echo "Разберите их вручную и повторите установку." >&2 + exit 1 +fi + +mkdir -p "$HOME/.local/bin" "$HOME/.config/proxy-client" + +if command -v "$STOW_BIN" >/dev/null 2>&1; then + echo "Установка с помощью Stow." + if [ "$missing_targets" -gt 0 ]; then + "$STOW_BIN" --dir="$(dirname "$PACKAGE_DIR")" --target="$HOME" \ + "${stow_ignore_args[@]}" \ + "$(basename "$PACKAGE_DIR")" + fi +else + echo "Установка без Stow: создаю символические ссылки напрямую." + for index in "${!TARGETS[@]}"; do + target="${TARGETS[$index]}" + source_path="${SOURCES[$index]}" + if [ ! -L "$target" ]; then + ln -s "$source_path" "$target" + fi + done +fi + +"$HOME/.local/bin/proxy-client" --help >/dev/null +bash -n "$HOME/.config/proxy-client/shell.bash" + +if [ "$SHELL_SETUP_NEEDED" -eq 1 ]; then + cat >> "$HOME/.bashrc" <<'EOF' + +# >>> proxy-client >>> +EOF + if [ "$SHELL_PATH_NEEDED" -eq 1 ]; then + cat >> "$HOME/.bashrc" <<'EOF' +case ":$PATH:" in + *":$HOME/.local/bin:"*) ;; + *) + export PATH="$HOME/.local/bin:$PATH" + ;; +esac +EOF + fi + if [ "$SHELL_SOURCE_NEEDED" -eq 1 ]; then + cat >> "$HOME/.bashrc" <<'EOF' +[ -f "$HOME/.config/proxy-client/shell.bash" ] && source "$HOME/.config/proxy-client/shell.bash" +EOF + fi + cat >> "$HOME/.bashrc" <<'EOF' +# <<< proxy-client <<< +EOF +elif [ "$SETUP_SHELL" -eq 0 ]; then + echo "Настройка Bash пропущена (--no-shell-setup)." +fi + +if [ "$SETUP_SHELL" -eq 1 ]; then + RELOAD_COMMAND='source ~/.bashrc' +else + RELOAD_COMMAND='export PATH="$HOME/.local/bin:$PATH"; source ~/.config/proxy-client/shell.bash' +fi + +cat <&2 + exit 1 +} + +assert_link_points_to() { + local link_path="$1" expected_path="$2" actual_path + [ -L "$link_path" ] || fail "Expected symlink: $link_path" + actual_path="$(readlink -f "$link_path")" + [ "$actual_path" = "$expected_path" ] \ + || fail "$link_path points to $actual_path, expected $expected_path" +} + +assert_not_exists() { + local path="$1" + [ ! -e "$path" ] && [ ! -L "$path" ] || fail "Path should not exist: $path" +} + +setup_package() { + local fixture_root="$1" package_dir="$fixture_root/source with spaces/proxy-client" + mkdir -p \ + "$package_dir/.local/bin" \ + "$package_dir/.config/proxy-client" + cp -a \ + "$REPO_ROOT/proxy-client/.local/bin/proxy-client" \ + "$package_dir/.local/bin/proxy-client" + cp -a \ + "$REPO_ROOT/proxy-client/.config/proxy-client/shell.bash" \ + "$REPO_ROOT/proxy-client/.config/proxy-client/examples" \ + "$package_dir/.config/proxy-client/" + cp -a \ + "$REPO_ROOT/proxy-client/.stow-local-ignore" \ + "$REPO_ROOT/proxy-client/README.md" \ + "$REPO_ROOT/proxy-client/install.sh" \ + "$package_dir/" + printf '%s\n' 'MACHINE_CONFIG_SENTINEL' > "$package_dir/.config/proxy-client/config" + printf '%s\n' 'PRIVATE_PROFILE_SENTINEL' > "$package_dir/.config/proxy-client/default.url" + printf '%s\n' "$package_dir" +} + +run_package_install() { + local package_dir="$1" home_dir="$2" stow_bin="$3" + shift 3 + ( + cd "$TEST_TMP_ROOT" + HOME="$home_dir" STOW_BIN="$stow_bin" \ + "$package_dir/install.sh" "$@" + ) +} + +test_installs_same_public_artifacts_without_stow() { + local fixture_root package_dir home_dir output + fixture_root="$(mktemp -d "$TEST_TMP_ROOT/fixture.XXXXXX")" + package_dir="$(setup_package "$fixture_root")" + home_dir="$fixture_root/home" + mkdir -p "$home_dir" + + output="$( + run_package_install "$package_dir" "$home_dir" missing-stow --no-shell-setup + )" + + printf '%s' "$output" | grep -Fq 'без Stow' \ + || fail "Expected direct-symlink method in output" + printf '%s' "$output" | grep -Fq \ + 'export PATH="$HOME/.local/bin:$PATH"; source ~/.config/proxy-client/shell.bash' \ + || fail "Expected complete current-session hint with --no-shell-setup" + [ -d "$home_dir/.local/bin" ] && [ ! -L "$home_dir/.local/bin" ] \ + || fail "Expected ~/.local/bin to be a real directory" + [ -d "$home_dir/.config/proxy-client" ] \ + && [ ! -L "$home_dir/.config/proxy-client" ] \ + || fail "Expected proxy-client config home to be a real directory" + assert_link_points_to \ + "$home_dir/.local/bin/proxy-client" \ + "$package_dir/.local/bin/proxy-client" + assert_link_points_to \ + "$home_dir/.config/proxy-client/shell.bash" \ + "$package_dir/.config/proxy-client/shell.bash" + assert_link_points_to \ + "$home_dir/.config/proxy-client/examples" \ + "$package_dir/.config/proxy-client/examples" + assert_not_exists "$home_dir/.config/proxy-client/config" + assert_not_exists "$home_dir/.config/proxy-client/default.url" + assert_not_exists "$home_dir/README.md" + assert_not_exists "$home_dir/install.sh" + assert_not_exists "$home_dir/.bashrc" +} + +test_configures_bash_once_by_default() { + local fixture_root package_dir home_dir output marker_count path_count source_count + fixture_root="$(mktemp -d "$TEST_TMP_ROOT/fixture.XXXXXX")" + package_dir="$(setup_package "$fixture_root")" + home_dir="$fixture_root/home" + mkdir -p "$home_dir" + + run_package_install "$package_dir" "$home_dir" missing-stow >/dev/null + output="$(run_package_install "$package_dir" "$home_dir" missing-stow)" + + marker_count="$(grep -Fxc '# >>> proxy-client >>>' "$home_dir/.bashrc")" + path_count="$(grep -Fc 'export PATH="$HOME/.local/bin:$PATH"' "$home_dir/.bashrc")" + source_count="$( + grep -Fc \ + '[ -f "$HOME/.config/proxy-client/shell.bash" ] && source "$HOME/.config/proxy-client/shell.bash"' \ + "$home_dir/.bashrc" + )" + [ "$marker_count" -eq 1 ] || fail "Expected one managed Bash block" + [ "$path_count" -eq 1 ] || fail "Expected one ~/.local/bin PATH line" + [ "$source_count" -eq 1 ] || fail "Expected one shell module source line" + printf '%s' "$output" | grep -Fq 'source ~/.bashrc' \ + || fail "Expected current-session reload hint" +} + +test_installs_same_public_artifacts_with_stow() { + local fixture_root package_dir home_dir output + fixture_root="$(mktemp -d "$TEST_TMP_ROOT/fixture.XXXXXX")" + package_dir="$(setup_package "$fixture_root")" + home_dir="$fixture_root/home" + mkdir -p "$home_dir" + + output="$(run_package_install "$package_dir" "$home_dir" stow --no-shell-setup)" + + printf '%s' "$output" | grep -Fq 'с помощью Stow' \ + || fail "Expected Stow method in output" + [ -d "$home_dir/.local/bin" ] && [ ! -L "$home_dir/.local/bin" ] \ + || fail "Expected ~/.local/bin to be a real directory" + [ -d "$home_dir/.config/proxy-client" ] \ + && [ ! -L "$home_dir/.config/proxy-client" ] \ + || fail "Expected proxy-client config home to be a real directory" + assert_link_points_to \ + "$home_dir/.local/bin/proxy-client" \ + "$package_dir/.local/bin/proxy-client" + assert_link_points_to \ + "$home_dir/.config/proxy-client/shell.bash" \ + "$package_dir/.config/proxy-client/shell.bash" + assert_link_points_to \ + "$home_dir/.config/proxy-client/examples" \ + "$package_dir/.config/proxy-client/examples" + assert_not_exists "$home_dir/.config/proxy-client/config" + assert_not_exists "$home_dir/.config/proxy-client/default.url" + assert_not_exists "$home_dir/README.md" + assert_not_exists "$home_dir/install.sh" +} + +test_rejects_linked_parent_directories_before_changes() { + local fixture_root package_dir home_dir external_local external_config output + fixture_root="$(mktemp -d "$TEST_TMP_ROOT/fixture.XXXXXX")" + package_dir="$(setup_package "$fixture_root")" + home_dir="$fixture_root/home" + external_local="$fixture_root/external-local" + external_config="$fixture_root/external-config" + mkdir -p "$home_dir" "$external_local" "$external_config" + ln -s "$external_local" "$home_dir/.local" + ln -s "$external_config" "$home_dir/.config" + + if output="$( + run_package_install "$package_dir" "$home_dir" missing-stow 2>&1 + )"; then + fail "Expected linked parent directories to be rejected" + fi + + printf '%s' "$output" | grep -Fq "$home_dir/.local" \ + || fail "Expected ~/.local conflict in output" + printf '%s' "$output" | grep -Fq "$home_dir/.config" \ + || fail "Expected ~/.config conflict in output" + assert_not_exists "$external_local/bin" + assert_not_exists "$external_config/proxy-client" + assert_not_exists "$home_dir/.bashrc" +} + +test_reports_all_target_conflicts_without_partial_install() { + local fixture_root package_dir home_dir other_target output + fixture_root="$(mktemp -d "$TEST_TMP_ROOT/fixture.XXXXXX")" + package_dir="$(setup_package "$fixture_root")" + home_dir="$fixture_root/home" + other_target="$fixture_root/other-target" + mkdir -p "$home_dir/.local/bin" "$home_dir/.config/proxy-client/shell.bash" + printf '%s\n' 'KEEP_CLI' > "$home_dir/.local/bin/proxy-client" + printf '%s\n' 'KEEP_TARGET' > "$other_target" + ln -s "$other_target" "$home_dir/.config/proxy-client/examples" + + if output="$( + run_package_install "$package_dir" "$home_dir" missing-stow 2>&1 + )"; then + fail "Expected all target conflicts to be rejected" + fi + + printf '%s' "$output" | grep -Fq "$home_dir/.local/bin/proxy-client" \ + || fail "Expected CLI conflict in output" + printf '%s' "$output" | grep -Fq "$home_dir/.config/proxy-client/shell.bash" \ + || fail "Expected shell module conflict in output" + printf '%s' "$output" | grep -Fq "$home_dir/.config/proxy-client/examples" \ + || fail "Expected examples conflict in output" + grep -Fq 'KEEP_CLI' "$home_dir/.local/bin/proxy-client" \ + || fail "Expected conflicting CLI file to stay unchanged" + [ -d "$home_dir/.config/proxy-client/shell.bash" ] \ + || fail "Expected conflicting shell directory to stay unchanged" + assert_link_points_to "$home_dir/.config/proxy-client/examples" "$other_target" + assert_not_exists "$home_dir/.bashrc" +} + +test_rejects_broken_link_without_creating_other_targets() { + local fixture_root package_dir home_dir output + fixture_root="$(mktemp -d "$TEST_TMP_ROOT/fixture.XXXXXX")" + package_dir="$(setup_package "$fixture_root")" + home_dir="$fixture_root/home" + mkdir -p "$home_dir/.local/bin" + ln -s "$fixture_root/missing-cli" "$home_dir/.local/bin/proxy-client" + + if output="$( + run_package_install "$package_dir" "$home_dir" missing-stow 2>&1 + )"; then + fail "Expected broken target link to be rejected" + fi + + printf '%s' "$output" | grep -Fq "$home_dir/.local/bin/proxy-client" \ + || fail "Expected broken CLI link in output" + [ -L "$home_dir/.local/bin/proxy-client" ] \ + || fail "Expected broken CLI link to stay unchanged" + assert_not_exists "$home_dir/.config" + assert_not_exists "$home_dir/.bashrc" +} + +test_preserves_machine_files_with_both_methods() { + local stow_bin fixture_root package_dir home_dir + for stow_bin in missing-stow stow; do + fixture_root="$(mktemp -d "$TEST_TMP_ROOT/fixture.XXXXXX")" + package_dir="$(setup_package "$fixture_root")" + home_dir="$fixture_root/home" + mkdir -p "$home_dir/.config/proxy-client" + printf '%s\n' 'HOME_CONFIG_SENTINEL' \ + > "$home_dir/.config/proxy-client/config" + printf '%s\n' 'HOME_PROFILE_SENTINEL' \ + > "$home_dir/.config/proxy-client/default.url" + + run_package_install "$package_dir" "$home_dir" "$stow_bin" \ + --no-shell-setup >/dev/null + + [ ! -L "$home_dir/.config/proxy-client/config" ] \ + || fail "Machine config must not become a symlink" + [ ! -L "$home_dir/.config/proxy-client/default.url" ] \ + || fail "Private profile must not become a symlink" + grep -Fq 'HOME_CONFIG_SENTINEL' "$home_dir/.config/proxy-client/config" \ + || fail "Machine config changed" + grep -Fq 'HOME_PROFILE_SENTINEL' "$home_dir/.config/proxy-client/default.url" \ + || fail "Private profile changed" + done +} + +test_accepts_existing_links_when_method_changes() { + local fixture_root package_dir home_dir + fixture_root="$(mktemp -d "$TEST_TMP_ROOT/fixture.XXXXXX")" + package_dir="$(setup_package "$fixture_root")" + home_dir="$fixture_root/home" + mkdir -p "$home_dir" + + run_package_install "$package_dir" "$home_dir" missing-stow \ + --no-shell-setup >/dev/null + run_package_install "$package_dir" "$home_dir" stow \ + --no-shell-setup >/dev/null + + assert_link_points_to \ + "$home_dir/.local/bin/proxy-client" \ + "$package_dir/.local/bin/proxy-client" + assert_link_points_to \ + "$home_dir/.config/proxy-client/shell.bash" \ + "$package_dir/.config/proxy-client/shell.bash" + assert_link_points_to \ + "$home_dir/.config/proxy-client/examples" \ + "$package_dir/.config/proxy-client/examples" +} + +test_stow_completes_partial_correct_installation() { + local fixture_root package_dir home_dir + fixture_root="$(mktemp -d "$TEST_TMP_ROOT/fixture.XXXXXX")" + package_dir="$(setup_package "$fixture_root")" + home_dir="$fixture_root/home" + mkdir -p "$home_dir" + + run_package_install "$package_dir" "$home_dir" missing-stow \ + --no-shell-setup >/dev/null + rm "$home_dir/.config/proxy-client/shell.bash" + + run_package_install "$package_dir" "$home_dir" stow \ + --no-shell-setup >/dev/null + + assert_link_points_to \ + "$home_dir/.local/bin/proxy-client" \ + "$package_dir/.local/bin/proxy-client" + assert_link_points_to \ + "$home_dir/.config/proxy-client/shell.bash" \ + "$package_dir/.config/proxy-client/shell.bash" + assert_link_points_to \ + "$home_dir/.config/proxy-client/examples" \ + "$package_dir/.config/proxy-client/examples" +} + +test_does_not_duplicate_full_dotfiles_shell_setup() { + local fixture_root package_dir home_dir source_count + fixture_root="$(mktemp -d "$TEST_TMP_ROOT/fixture.XXXXXX")" + package_dir="$(setup_package "$fixture_root")" + home_dir="$fixture_root/home" + mkdir -p "$home_dir" + printf '%s\n' \ + '[ -f ~/.config/proxy-client/shell.bash ] && source ~/.config/proxy-client/shell.bash' \ + > "$home_dir/.bash_custom" + printf '%s\n' '[ -f ~/.bash_custom ] && source ~/.bash_custom' \ + > "$home_dir/.bashrc" + + run_package_install "$package_dir" "$home_dir" missing-stow >/dev/null + + grep -Fq '# >>> proxy-client >>>' "$home_dir/.bashrc" \ + || fail "Expected managed PATH block with full dotfiles" + grep -Fq 'export PATH="$HOME/.local/bin:$PATH"' "$home_dir/.bashrc" \ + || fail "Expected ~/.local/bin setup with full dotfiles" + source_count="$(grep -Fc 'proxy-client/shell.bash' "$home_dir/.bashrc" || true)" + [ "$source_count" -eq 0 ] \ + || fail "Standalone block duplicated full dotfiles module source" +} + +test_rejects_damaged_managed_bash_block_before_changes() { + local fixture_root package_dir home_dir output + fixture_root="$(mktemp -d "$TEST_TMP_ROOT/fixture.XXXXXX")" + package_dir="$(setup_package "$fixture_root")" + home_dir="$fixture_root/home" + mkdir -p "$home_dir" + printf '%s\n' '# >>> proxy-client >>>' > "$home_dir/.bashrc" + + if output="$( + run_package_install "$package_dir" "$home_dir" missing-stow 2>&1 + )"; then + fail "Expected damaged managed Bash block to be rejected" + fi + + printf '%s' "$output" | grep -Fq "$home_dir/.bashrc" \ + || fail "Expected ~/.bashrc conflict in output" + [ "$(wc -l < "$home_dir/.bashrc")" -eq 1 ] \ + || fail "Expected damaged Bash block to stay unchanged" + assert_not_exists "$home_dir/.local" + assert_not_exists "$home_dir/.config" +} + +test_does_not_duplicate_existing_local_bin_path() { + local fixture_root package_dir home_dir path_count source_count + fixture_root="$(mktemp -d "$TEST_TMP_ROOT/fixture.XXXXXX")" + package_dir="$(setup_package "$fixture_root")" + home_dir="$fixture_root/home" + mkdir -p "$home_dir" + printf '%s\n' 'export PATH="$HOME/.local/bin:$PATH"' > "$home_dir/.bashrc" + + run_package_install "$package_dir" "$home_dir" missing-stow >/dev/null + + path_count="$(grep -Fc 'export PATH="$HOME/.local/bin:$PATH"' "$home_dir/.bashrc")" + source_count="$(grep -Fc 'proxy-client/shell.bash' "$home_dir/.bashrc")" + [ "$path_count" -eq 1 ] || fail "Existing ~/.local/bin PATH line was duplicated" + [ "$source_count" -eq 1 ] || fail "Expected shell module source in managed block" +} + +test_rejects_incomplete_source_before_changes() { + local fixture_root package_dir home_dir output + fixture_root="$(mktemp -d "$TEST_TMP_ROOT/fixture.XXXXXX")" + package_dir="$(setup_package "$fixture_root")" + home_dir="$fixture_root/home" + mkdir -p "$home_dir" + rm "$package_dir/.config/proxy-client/shell.bash" + + if output="$( + run_package_install "$package_dir" "$home_dir" missing-stow 2>&1 + )"; then + fail "Expected incomplete source package to be rejected" + fi + + printf '%s' "$output" | grep -Fq 'исходного пакета' \ + || fail "Expected source package error" + assert_not_exists "$home_dir/.local" + assert_not_exists "$home_dir/.config" + assert_not_exists "$home_dir/.bashrc" +} + +main() { + test_installs_same_public_artifacts_without_stow + test_configures_bash_once_by_default + test_installs_same_public_artifacts_with_stow + test_rejects_linked_parent_directories_before_changes + test_reports_all_target_conflicts_without_partial_install + test_rejects_broken_link_without_creating_other_targets + test_preserves_machine_files_with_both_methods + test_accepts_existing_links_when_method_changes + test_stow_completes_partial_correct_installation + test_does_not_duplicate_full_dotfiles_shell_setup + test_rejects_damaged_managed_bash_block_before_changes + test_does_not_duplicate_existing_local_bin_path + test_rejects_incomplete_source_before_changes + echo "All standalone proxy-client install tests passed" +} + +main "$@"