Массовая доставка 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.zshrc.zshrcUnsupported parameters … warnwarnshellwarn/rootbecome: yes/home/user/...name: user/etc/shellschsh11) Итог
Мы выстроили прозрачную, «безмагическую» доставку одинаковой 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-файл целиком под блог/вики.