Массовая доставка 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-файл целиком под блог/вики.