Skip to main content

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

круто! ниже — обещанный «научно-популярный» разбор всей нашей истории: от идеи «хочу одинаковый zsh на пяти серверах» до мелких багов, VS Code и аккуратных правок. всё обезличено (где встречались домены — заменяйте мысленно на YOU.DOMAIN). в коде — только понятные, прямые команды и явные пути; никаких «магических» автоподстановок и тем более keychain — мы его полностью убрали.


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

TL;DR (короткая выжимка)

  • Инвентарь 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.

  • Лечение:

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

      [ -n "$ZSH_VERSION" ] || return
      
    2. Выбрать zsh как дефолтный терминал в VS Code (в удалённых Settings JSON):

      {
        "terminal.integrated.profiles.linux": {
          "zsh": { "path": "/usr/bin/zsh" }
        },
        "terminal.integrated.defaultProfile.linux": "zsh"
      }
      
    3. Проверить логин-шелл:

      getent passwd user | cut -d: -f7    # должно показать /usr/bin/zsh
      
    4. Не подключать .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)

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

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

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

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

    echo $SHELL
    

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

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