Skip to main content

Массовая доставка Zsh с Oh-My-Zsh и темой Jovial на несколько серверов: практика, нюансы, анти-грабли

    Инвентарь Ansible: собрали 3 группы в одну общую через :children.

    Плейбук: ставит zsh, git, Oh-My-Zsh, тему/плагины (jovial, zsh-autosuggestions, zsh-syntax-highlighting, zsh-history-enquirer), копирует единый .zshrc, делает zsh логин-шеллом нужного пользователя.

    Ошибки и решения:

      warn: false в shell — выкинули, заменили на безопасную команду.

      «conflicting action statements … user, shell» — исправили отступы YAML.

      Пользователь «уполз» в root из-за become: yes — указали явного пользователя.

      VS Code запускал bash, а тот пытался исполнять .zshrc — поставили сторож в начало .zshrc: [ -n "$ZSH_VERSION" ] || return и выбрали zsh как дефолтный терминал в VS Code Remote.

      Конфиг .zshrc: аккуратные алиасы для eza/bat(batcat), тема Jovial c контекстной «стрелкой», никакого keychain.


      1) Исходная задача и почему это не так тривиально

      Хотим:

        На 5 удалённых хостах получить одинаковый интерактивный опыт в терминале: zsh + Oh-My-Zsh + тема Jovial + плагины (zsh-autosuggestions, zsh-syntax-highlighting, zsh-history-enquirer, и встроенный bgnotify), плюс удобные алиасы для eza и bat/batcat.

        Делать это повторяемо и масштабируемо через Ansible.

        Не ломать VS Code Remote-SSH и любые неинтерактивные сценарии.

        Подводные камни:

          Задачи с повышением привилегий (become: yes) меняют «наблюдаемого» пользователя; если опираться на «кто мы сейчас» — легко случайно накатить всё в /root, а не в /home/user.

          VS Code может поднимать bash для Server/интегрированного терминала; если .zshrc вдруг исполняется bash’ем — будут ошибки.

          Мелочи YAML (отступы), несовместимые параметры модулей (warn), и порядок плагинов (у zsh-syntax-highlighting он критичен: он должен быть последним).


          2) Инвентарь Ansible: соберём «группу групп»

          Идея: у вас уже есть тематические группы ([docker], [vpn], [zud]). Сведём их под единую «зону доставки»:

          # inventories/inventory.ini
          
          [docker]
          n-1-dsw ansible_host=203.0.113.11 ansible_user=user ansible_ssh_private_key_file=/home/user/.ssh/id_rsa_dsw1
          n-2-dsw ansible_host=203.0.113.12 ansible_user=user ansible_ssh_private_key_file=/home/user/.ssh/id_rsa_dsw2
          n-3-dsw ansible_host=203.0.113.13 ansible_user=user ansible_ssh_private_key_file=/home/user/.ssh/id_rsa_dsw3
          
          [vpn]
          srv-1   ansible_host=198.51.100.21 ansible_user=user ansible_ssh_private_key_file=/home/user/.ssh/id_rsa_vpn
          
          [zud]
          zud-1   ansible_host=192.0.2.41    ansible_user=user ansible_ssh_private_key_file=/home/user/.ssh/id_rsa_zud1
          zud-2   ansible_host=192.0.2.42    ansible_user=user ansible_ssh_private_key_file=/home/user/.ssh/id_rsa_zud2
          
          # Общая группа для доставки zsh
          [zsh_targets:children]
          docker
          vpn
          zud
          
          [all:vars]
          ansible_python_interpreter=/usr/bin/python3
          

          Комментарий: используем реальные пути и явные логины в инвентаре. Группа zsh_targets — наш единый «маркер» к каким хостам применять плейбук.


          3) .zshrc: аккуратная, безопасная конфигурация (без keychain)

          Ключевые принципы:

            Первая строка — «сторож»: исполняем только под zsh (исправляет конфликт с bash в VS Code).

            Плагин zsh-syntax-highlighting — последним.

            Никакого keychain (по вашей просьбе).

            # /home/user/.zshrc
            # 1) СТОРОЖ: не выполнять этот файл, если шелл не Zsh (важно для VS Code и любых bash-сценариев)
            [ -n "$ZSH_VERSION" ] || return
            
            # 2) Oh-My-Zsh + тема Jovial
            export ZSH="/home/user/.oh-my-zsh"      # явный путь, без $HOME, как просили
            ZSH_THEME="jovial"                       # тема от проекта zthxxx/jovial
            
            # 3) Плагины (важно: zsh-syntax-highlighting должен быть ПОСЛЕДНИМ)
            plugins=(
              git                         # стандартный набор удобств git
              autojump                    # быстрые переходы по каталогам
              bgnotify                    # уведомления о завершении долгих задач
              zsh-history-enquirer        # «поисковый» history widget с автодополнением
              zsh-autosuggestions         # «серые подсказки» на основе истории
              jovial                      # доп. фичи темы
              zsh-syntax-highlighting     # ПЛАГИН ДОЛЖЕН БЫТЬ ПОСЛЕДНИМ
            )
            
            source "/home/user/.oh-my-zsh/oh-my-zsh.sh"
            
            # 4) Порог показа времени выполнения (в теме Jovial)
            typeset -g JOVIAL_EXEC_THRESHOLD_SECONDS=1
            
            # 5) Терминал/цвета
            export TERM="xterm-256color"
            export COLORTERM="truecolor"
            
            # 6) eza: красивые списки файлов (с fallback на ls, если eza нет)
            if command -v eza >/dev/null 2>&1; then
              alias ls='eza --icons=auto --group-directories-first --git'
              alias ll='eza -l --icons=auto --group-directories-first --git'
              alias la='eza -la --icons=auto --group-directories-first --git'
              alias lt='eza --tree --level=2 --icons=auto'
              alias lsize='eza -l --sort=size --icons=auto'
            else
              alias ls='ls --color=auto'
              alias ll='ls -l --color=auto'
              alias la='ls -la --color=auto'
            fi
            
            # 7) bat/batcat: «красивый cat» без пейджера (оба случая)
            if command -v bat >/dev/null 2>&1; then
              alias cat='bat --paging=never --style=plain'
            elif command -v batcat >/dev/null 2>&1; then
              alias cat='batcat --paging=never --style=plain'
            fi
            export BAT_PAGER="less -FR"
            export BAT_THEME="ansi"
            
            # 8) Тонкая настройка темы Jovial: «стрелка» меняет «эмоцию» в зависимости от контекста
            typeset -g JOV_ARROW_DEFAULT='%(?.(◕‿◕).(╥﹏╥%))'      # успех/ошибка по статусу команды
            typeset -g JOV_ARROW_KUBE='%(?.(\_(ツ)_/¯).(╥﹏╥%))'     # для kubectl/helm
            typeset -g JOV_ARROW_CONT='%(?.(⌐■_■).(╥﹏╥%))'         # для docker/podman и пр.
            
            autoload -Uz add-zsh-hook
            
            # 9) Предзахват «базы» последней команды (чтобы менять «эмоцию»)
            typeset -g JOV_LAST_CMD_BASE=''
            jov_store_last_cmd() {
              local line="${2:-$1}"                         # OMZ часто кладёт команду во второй аргумент
            
              emulate -L zsh -o extendedglob
              line="${line##[[:space:]]##}"                 # срезаем лидирующие пробелы
            
              local base="${line%%[[:space:]]*}"            # первое слово
              if [[ "$base" == (sudo|doas) ]]; then         # поддержка sudo/doas: сдвинуться на следующую «реальную» команду
                local rest="${line#*[[:space:]]}"
                base="${rest%%[[:space:]]*}"
              fi
              JOV_LAST_CMD_BASE="$base"
            }
            preexec_functions=(${preexec_functions:#jov_store_last_cmd})
            preexec_functions+=('jov_store_last_cmd')
            
            # 10) Перед прорисовкой промпта: подставить нужную «эмоцию»
            jov_apply_context_face() {
              local expr="$JOV_ARROW_DEFAULT"
              case "$JOV_LAST_CMD_BASE" in
                kubectl|k|helm)                      expr="$JOV_ARROW_KUBE" ;;
                docker|docker-compose|podman|nerdctl) expr="$JOV_ARROW_CONT" ;;
                *)                                   expr="$JOV_ARROW_DEFAULT" ;;
              esac
              # И обычная стрелка, и git-варианты — одной логикой и эмоцией
              JOVIAL_SYMBOL[arrow]="$expr"
              JOVIAL_SYMBOL[arrow.git-clean]="$expr"
              JOVIAL_SYMBOL[arrow.git-dirty]="$expr"
            }
            precmd_functions=(${precmd_functions:#jov_apply_context_face})
            precmd_functions+=('jov_apply_context_face')
            
            # 11) Графические редакторы (VS Code) — по желанию
            export EDITOR="code --wait --reuse-window"
            export VISUAL="code --wait --reuse-window"
            export SUDO_EDITOR="code --wait --reuse-window"
            export SYSTEMD_EDITOR="code --wait --reuse-window"
            export KUBE_EDITOR="code --wait --reuse-window"
            

            Комментарий: никаких доменов/секретов/ключей, только чистая функциональная конфигурация. Если VS Code не используется — блок EDITOR можно убрать.


            4) Плейбук Ansible: «простой и явный» (без переменных-шаблонов)

            Ниже — полностью явный плейбук. В нём:

              Жёстко указан пользователь user и его домашний каталог /home/user.

              Явные пути для Oh-My-Zsh и плагинов.

              Без циклов и шаблонов — каждое действие описано прямо.

              Замените user на ваш реальный логин, если он другой.

              # playbooks/zsh_deploy.yml
              ---
              - name: Install & configure Zsh with Oh-My-Zsh + Jovial on multiple hosts
                hosts: zsh_targets
                gather_facts: yes
                become: yes
              
                tasks:
                  # 1) Убедиться, что целевой пользователь существует (и одновременно установить zsh как логин-шелл)
                  - name: Ensure user exists and set default shell to /usr/bin/zsh
                    ansible.builtin.user:
                      name: user                           # <-- ЗАМЕНИТЕ при необходимости
                      shell: /usr/bin/zsh
              
                  # 2) Установить базовые пакеты
                  - name: Install Zsh, Git, curl, ca-certificates, autojump
                    ansible.builtin.package:
                      name:
                        - zsh
                        - git
                        - curl
                        - ca-certificates
                        - autojump
                      state: present
              
                  # 3) Установить eza (если есть в репозитории; если вдруг нет — задача просто не выполнится)
                  - name: Install eza if available
                    ansible.builtin.package:
                      name: eza
                      state: present
                    ignore_errors: yes                     # у некоторых дистрибутивов пакет может называться иначе или отсутствовать
              
                  # 4) Установить bat (на части Debian-подобных он называется batcat — оставим как есть, fallback в .zshrc учтён)
                  - name: Install bat if available
                    ansible.builtin.package:
                      name: bat
                      state: present
                    ignore_errors: yes
              
                  # 5) Создать каталог Oh-My-Zsh
                  - name: Create Oh-My-Zsh directory
                    ansible.builtin.file:
                      path: /home/user/.oh-my-zsh         # <-- явный путь
                      state: directory
                      owner: user
                      group: user
                      mode: "0755"
              
                  # 6) Клонировать Oh-My-Zsh
                  - name: Clone Oh-My-Zsh
                    ansible.builtin.git:
                      repo: https://github.com/ohmyzsh/ohmyzsh.git
                      dest: /home/user/.oh-my-zsh
                      version: master
                      update: yes
                    become_user: user
              
                  # 7) Подготовить каталоги для плагинов и тем
                  - name: Create custom plugins directory
                    ansible.builtin.file:
                      path: /home/user/.oh-my-zsh/custom/plugins
                      state: directory
                      owner: user
                      group: user
                      mode: "0755"
              
                  - name: Create custom themes directory
                    ansible.builtin.file:
                      path: /home/user/.oh-my-zsh/custom/themes
                      state: directory
                      owner: user
                      group: user
                      mode: "0755"
              
                  # 8) Плагины (каждый — явной задачей)
                  - name: Clone zsh-autosuggestions
                    ansible.builtin.git:
                      repo: https://github.com/zsh-users/zsh-autosuggestions.git
                      dest: /home/user/.oh-my-zsh/custom/plugins/zsh-autosuggestions
                      version: master
                      update: yes
                    become_user: user
              
                  - name: Clone zsh-syntax-highlighting
                    ansible.builtin.git:
                      repo: https://github.com/zsh-users/zsh-syntax-highlighting.git
                      dest: /home/user/.oh-my-zsh/custom/plugins/zsh-syntax-highlighting
                      version: master
                      update: yes
                    become_user: user
              
                  - name: Clone zsh-history-enquirer
                    ansible.builtin.git:
                      repo: https://github.com/zthxxx/zsh-history-enquirer.git
                      dest: /home/user/.oh-my-zsh/custom/plugins/zsh-history-enquirer
                      version: master
                      update: yes
                    become_user: user
              
                  - name: Clone jovial (plugin + theme)
                    ansible.builtin.git:
                      repo: https://github.com/zthxxx/jovial.git
                      dest: /home/user/.oh-my-zsh/custom/plugins/jovial
                      version: master
                      update: yes
                    become_user: user
              
                  # 9) Симлинк темы Jovial в каталог тем
                  - name: Symlink jovial theme into custom themes
                    ansible.builtin.file:
                      src: /home/user/.oh-my-zsh/custom/plugins/jovial/jovial.zsh-theme
                      dest: /home/user/.oh-my-zsh/custom/themes/jovial.zsh-theme
                      state: link
                      owner: user
                      group: user
              
                  # 10) Установить наш .zshrc
                  - name: Install .zshrc
                    ansible.builtin.copy:
                      dest: /home/user/.zshrc
                      owner: user
                      group: user
                      mode: "0644"
                      content: |
                        [ -n "$ZSH_VERSION" ] || return
                        export ZSH="/home/user/.oh-my-zsh"
                        ZSH_THEME="jovial"
                        plugins=(
                          git
                          autojump
                          bgnotify
                          zsh-history-enquirer
                          zsh-autosuggestions
                          jovial
                          zsh-syntax-highlighting
                        )
                        source "/home/user/.oh-my-zsh/oh-my-zsh.sh"
                        typeset -g JOVIAL_EXEC_THRESHOLD_SECONDS=1
                        export TERM="xterm-256color"
                        export COLORTERM="truecolor"
                        if command -v eza >/dev/null 2>&1; then
                          alias ls='eza --icons=auto --group-directories-first --git'
                          alias ll='eza -l --icons=auto --group-directories-first --git'
                          alias la='eza -la --icons=auto --group-directories-first --git'
                          alias lt='eza --tree --level=2 --icons=auto'
                          alias lsize='eza -l --sort=size --icons=auto'
                        else
                          alias ls='ls --color=auto'
                          alias ll='ls -l --color=auto'
                          alias la='ls -la --color=auto'
                        fi
                        if command -v bat >/dev/null 2>&1; then
                          alias cat='bat --paging=never --style=plain'
                        elif command -v batcat >/dev/null 2>&1; then
                          alias cat='batcat --paging=never --style=plain'
                        fi
                        export BAT_PAGER="less -FR"
                        export BAT_THEME="ansi"
                        typeset -g JOV_ARROW_DEFAULT='%(?.(◕‿◕).(╥﹏╥%))'
                        typeset -g JOV_ARROW_KUBE='%(?.(\_(ツ)_/¯).(╥﹏╥%))'
                        typeset -g JOV_ARROW_CONT='%(?.(⌐■_■).(╥﹏╥%))'
                        autoload -Uz add-zsh-hook
                        typeset -g JOV_LAST_CMD_BASE=''
                        jov_store_last_cmd() {
                          local line="${2:-$1}"
                          emulate -L zsh -o extendedglob
                          line="${line##[[:space:]]##}"
                          local base="${line%%[[:space:]]*}"
                          if [[ "$base" == (sudo|doas) ]]; then
                            local rest="${line#*[[:space:]]}"
                            base="${rest%%[[:space:]]*}"
                          fi
                          JOV_LAST_CMD_BASE="$base"
                        }
                        preexec_functions=(${preexec_functions:#jov_store_last_cmd})
                        preexec_functions+=('jov_store_last_cmd')
                        jov_apply_context_face() {
                          local expr="$JOV_ARROW_DEFAULT"
                          case "$JOV_LAST_CMD_BASE" in
                            kubectl|k|helm)                      expr="$JOV_ARROW_KUBE" ;;
                            docker|docker-compose|podman|nerdctl) expr="$JOV_ARROW_CONT" ;;
                            *)                                   expr="$JOV_ARROW_DEFAULT" ;;
                          esac
                          JOVIAL_SYMBOL[arrow]="$expr"
                          JOVIAL_SYMBOL[arrow.git-clean]="$expr"
                          JOVIAL_SYMBOL[arrow.git-dirty]="$expr"
                        }
                        precmd_functions=(${precmd_functions:#jov_apply_context_face})
                        precmd_functions+=('jov_apply_context_face')
                        export EDITOR="code --wait --reuse-window"
                        export VISUAL="code --wait --reuse-window"
                        export SUDO_EDITOR="code --wait --reuse-window"
                        export SYSTEMD_EDITOR="code --wait --reuse-window"
                        export KUBE_EDITOR="code --wait --reuse-window"
              
                  # 11) На всякий случай — права на дерево OMZ
                  - name: Ensure ownership on .oh-my-zsh tree
                    ansible.builtin.file:
                      path: /home/user/.oh-my-zsh
                      state: directory
                      recurse: yes
                      owner: user
                      group: user
              

              Запуск:

              ansible-playbook -i inventories/inventory.ini playbooks/zsh_deploy.yml -l zsh_targets
              

              5) Что пошло не так по дороге — и как мы это лечили

              5.1. «Unsupported parameters … warn» в shell

                Симптом: падение на задаче «Find zsh path» со ссылкой на warn.

                Причина: ваша версия Ansible (и модуль command/shell) не поддерживает параметр warn.

                Лечение: просто убрали warn, а команду сделали явной и «безопасной».

                - name: Find zsh path
                  ansible.builtin.shell: "command -v zsh || true"
                  register: zsh_path_cmd
                  changed_when: false
                

                В нашем «простом» плейбуке это вообще не требуется — мы заранее ставим /usr/bin/zsh логин-шеллом.

                5.2. «conflicting action statements … user, shell» (ошибка отступов)

                  Симптом: Ansible ругается на «двойной экшен».

                  Причина: у модуля ansible.builtin.user параметры name/shell оказались на неверном уровне отступов.

                  Лечение: вернуть два пробела под модуль:

                  - name: Set zsh as default shell for the user
                    ansible.builtin.user:
                      name: user
                      shell: /usr/bin/zsh
                  

                  5.3. Всё поставилось root’у, а не обычному пользователю

                    Симптом: у root всё красиво, у человеческого пользователя — ничего.

                    Причина: в варианте с автопеременными мы опирались на «текущего пользователя», а become: yes делал его root. В «простом» плейбуке это исключено: мы всегда работаем с конкретным user и явными путями /home/user/....

                    Лечение в общем случае: указывать нужного пользователя в явном виде в задачах и путях.

                    5.4. VS Code: «Oh My Zsh can't be loaded from: bash»

                      Симптом: ошибки autoload и «unexpected argument ( … )» — признак, что bash попытался исполнить .zshrc.

                      Причины: VS Code Server поднимает процессы через bash; где-то подключился .zshrc.

                      Лечение:

                        Сторож в начало .zshrc:

                        [ -n "$ZSH_VERSION" ] || return
                        

                        Выбрать zsh как дефолтный терминал в VS Code (в удалённых Settings JSON):

                        {
                          "terminal.integrated.profiles.linux": {
                            "zsh": { "path": "/usr/bin/zsh" }
                          },
                          "terminal.integrated.defaultProfile.linux": "zsh"
                        }
                        

                        Проверить логин-шелл:

                        getent passwd user | cut -d: -f7    # должно показать /usr/bin/zsh
                        

                        Не подключать .zshrc из ~/.bashrc, ~/.profile и т. п. Если когда-то было «source ~/.zshrc» — удалить или обернуть условием на $ZSH_VERSION.


                        6) Проверки, которые стоит сделать после раскатки

                        # Проверить логин-шелл
                        getent passwd user | cut -d: -f7
                        # ожидаем: /usr/bin/zsh
                        
                        # Проверить, что OMZ на месте и принадлежит пользователю
                        ls -ld /home/user/.oh-my-zsh
                        # ожидаем владельца user и права 755
                        
                        # Убедиться, что .zshrc скопирован
                        head -n 3 /home/user/.zshrc
                        # ожидаем первую строку-сторож: [ -n "$ZSH_VERSION" ] || return
                        
                        # Проверка в терминале
                        echo $SHELL
                        # ожидаем /usr/bin/zsh
                        
                        # Проверка алиасов (если eza установлен)
                        type ls
                        # ожидаем «ls is aliased to `eza ...`»
                        

                        7) Почему плагин zsh-syntax-highlighting должен быть последним

                        Этот плагин «подсвечивает» вводимые команды, и ему важно видеть финальную форму строки. Если поставить его раньше, последующие плагины инициализации промпта/подсказок могут сломать подсветку или привести к странностям. Поэтому мы фиксируем порядок плагинов и в .zshrc, и в рассылке через Ansible.


                        8) Алиасы eza и bat(batcat): дружелюбный fallback

                          eza — не всегда есть в стандартных репозиториях старых дистрибутивов. Мы пробуем установить; если не получилось — .zshrc мягко сваливается на ls --color=auto.

                          bat на Debian-подобных системах иногда именуется batcat. В .zshrc мы сначала ищем bat, затем batcat. Команда cat алиасится на найденную бинарь без пейджера — привычный UX.


                          9) Чистая деинсталляция (если вдруг нужно откатить)

                          # Вернуть стандартный шелл bash
                          sudo chsh -s /bin/bash user
                          
                          # Удалить Oh-My-Zsh и .zshrc
                          sudo rm -rf /home/user/.oh-my-zsh
                          sudo rm -f  /home/user/.zshrc
                          
                          # (необязательно) удалить пакеты
                          sudo apt remove --purge -y zsh eza bat autojump
                          sudo apt autoremove -y
                          

                          Комментарий: если использовались другие менеджеры пакетов (dnf/yum/apk/pacman) — команды подменить на их аналоги.


                          10) Мини-шпаргалка по частым ошибкам и быстрым решениям

                          Симптом Причина Решение Oh My Zsh can't be loaded from: bash Bash исполняет .zshrc В начало .zshrc: `[ -n "$ZSH_VERSION" ] Unsupported parameters … warn Параметр warn у shell для вашей версии Ansible Удалить warn; команда: `command -v zsh «conflicting action statements …» Ошибка отступов YAML Параметры модуля под ним, с двумя пробелами Всё в /root, у пользователя пусто become: yes + опора на «текущего» пользователя В явном плейбуке использовать жёсткие пути /home/user/... и name: user В VS Code «случайно» bash Терминал по умолчанию — bash В Remote Settings выбрать zsh, проверить /etc/shells и chsh

                          11) Итог

                          Мы выстроили прозрачную, «безмагическую» доставку одинаковой zsh-среды на несколько серверов. Плейбук предельно явный: без циклов, без переменных, с прямыми путями и понятной логикой. .zshrc чистый (без keychain), безопасный для VS Code и неинтерактивных сценариев.

                          Если захочется довести до «промышленной» роли — можно постепенно параметризовать (но это уже другая история). Для публикации же — текущая версия наглядна и само-документирована комментариями.


                          Приложение A. Команда запуска

                          # Прогнать на всех целевых узлах из общей группы
                          ansible-playbook -i inventories/inventory.ini playbooks/zsh_deploy.yml -l zsh_targets
                          

                          Приложение B. Проверка VS Code (Remote-SSH)

                            Подключиться к хосту под пользователем user.

                            Открыть Settings (Remote) → найти terminal.integrated.defaultProfile.linux → выбрать zsh.

                            Убедиться, что появился каталог: /home/user/.vscode-server/.

                            В интегрированном терминале проверить:

                            echo $SHELL
                            

                            ожидаем: /usr/bin/zsh.

                            если захочешь — могу в этом же стиле оформить «ready-to-publish» markdown-файл целиком под блог/вики.