笔记 by 车东

zsh 的 PATH 该写哪个文件:.zshenv / .zprofile / .zshrc 加载时机,及 ssh/pdsh 报 command not found 的根因

· 运维

一次 pdsh 批量巡检里 6 台机器 5 台报 command not found: fastfetch 的排查沉淀。软件早就装好了,问题出在 PATH 写错了文件。

TLDR

.zprofile 和 .zshrc 不能合并——它们由「是否登录」×「是否交互」两个开关组合触发,不是同一件事写两遍:

场景 .zshenv .zprofile .zshrc .zlogin
登录 + 交互(Terminal/iTerm 新窗口) ✅ ✅ ✅ ✅
登录 + 非交互(zsh -lc) ✅ ✅ ❌ ✅
非登录 + 交互(zsh -i、tmux 默认) ✅ ❌ ✅ ❌
非登录 + 非交互(ssh host cmd、pdsh、脚本) ✅ ❌ ❌ ❌

只有 .zshenv 被所有场景读取——所以 PATH 写这里:

# ~/.zshenv 顶部
typeset -U path PATH                                      # 让 path/PATH 自动去重,重复 prepend 不累积
export PATH="/opt/local/bin:/opt/local/sbin:$PATH"        # MacPorts
export PATH="$HOME/.local/bin:$PATH"

先分清「没装」还是「没在 PATH」——这是最容易误判的一步:

ls -l /opt/local/bin/fastfetch    # 有输出 = 装了;没输出 = 真没装
which fastfetch                   # 有输出 = 在 PATH;没输出 = PATH 问题

问题

一条批量巡检命令,6 台机器只有 1 台成功:

pdsh -w host1,host2,host3,host4,host5,host6 'source .zshrc; fastfetch'
# host1..host5: zsh:1: command not found: fastfetch
# host6: 正常输出

诡异之处:失败那 5 台都装了 fastfetch(/opt/local/bin/fastfetch,校验和一致),成功那台反而是唯一没迁到 MacPorts的。

为什么「装了却找不到」

MacPorts 安装器把 PATH 写进了 ~/.zprofile,注释还留着它自己的签名:

# MacPorts Installer addition on 2026-09-04_at_19:01:13
export PATH="/opt/local/bin:/opt/local/sbin:$PATH"

而 pdsh/ssh host cmd 走的是非登录 + 非交互 shell,.zprofile 和 .zshrc 都不读,只读 .zshenv——那里面没有 /opt/local/bin,于是找不到。

成功那台之所以正常,恰恰因为它是唯一没迁的:fastfetch 在 Homebrew 的 /opt/homebrew/bin,而它的 .zshrc 里有 eval "$(/opt/homebrew/bin/brew shellenv)",source .zshrc 正好生效。

关键区分:路径存在 ≠ 在 PATH 里。 迁移类操作只搬了二进制,没搬 PATH,就会出现「装了却 command not found」。

为什么三个方向都不能合并

合并方向 后果
并进 .zshrc 非交互场景(pdsh、脚本、zsh -lc)全废——正是本文这个 bug。且 .zshrc 里有 oh-my-zsh、compinit、PROMPT,在脚本和 $(...) 里执行会拖慢启动、污染 stdout、让别名泄漏进脚本
并进 .zprofile 非登录交互 shell(tmux、zsh -i)丢掉 alias / 补全 / 提示符
并进 .zshenv 所有场景都读,于是 oh-my-zsh 和补全在每个子 shell、每次命令替换里跑一遍,最糟

结论:职责不能合并,但重复内容可以抽走——公共环境变量抽到 .zshenv,.zprofile 留登录专属,.zshrc 只留交互专属。

「交互」不等于「登录」——最隐蔽的一坑

日常用 Terminal / iTerm 开的是登录 shell,所以 .zprofile 生效,问题长期不暴露。但这两类场景会静默失效:

# tmux 默认起非登录 shell、zsh -i 同理 —— 都不读 .zprofile
zsh -ic 'which fastfetch'
# 修复前: zsh:1: command not found: fastfetch
# 修复后: /opt/local/bin/fastfetch

同一个 bug 还会让交互式别名一起失效(如 alias ff='fastfetch -c examples/25'),表现为「明明在终端里好好的,tmux 里就是不行」。

重复 prepend 会累积——用 typeset -U 根治

PATH 重复不会报错,只会静默膨胀。实测某台机器 $HOME/.local/bin 出现 2 次、/opt/homebrew/bin 3 次,来源是 .zshrc 里多轮安装器各自追加:

# 同一路径,不同写法,各写一遍
export PATH="$HOME/.local/bin:$PATH"
export PATH="/Users/chedong/.local/bin:$PATH"

zsh 的 path 数组与 PATH 联动,typeset -U 让赋值自动去重。放在 .zshenv 最顶部,之后所有 export PATH=... 都受保护:

typeset -U path PATH

(同一手法对 fpath 也适用:typeset -U fpath,用于抑制补全函数目录重复扫描。)

验证:四种场景必须一致

改完 PATH 后,用这段脚本一次性确认四种场景解析到同一个二进制:

pdsh -w host1,host2,host3 'h=$(hostname)
a=$(zsh -c   "which fastfetch" 2>&1 | tail -1)   # 非交互
b=$(zsh -lc  "which fastfetch" 2>&1 | tail -1)   # 登录非交互
c=$(zsh -ic  "which fastfetch" 2>&1 | tail -1)   # 交互非登录
d=$(zsh -lic "which fastfetch" 2>&1 | tail -1)   # 登录交互
if [ "$a" = "$b" ] && [ "$b" = "$c" ] && [ "$c" = "$d" ]; then
  printf "  %-14s 一致 OK  %s\n" "$h" "$a"
else
  printf "  %-14s 不一致! %s | %s | %s | %s\n" "$h" "$a" "$b" "$c" "$d"
fi'

顺带查 PATH 是否有重复项(应无输出):

echo $PATH | tr ':' '\n' | grep -v '^$' | sort | uniq -d

已知坑速查

症状 原因 处置
ssh host cmd / pdsh 报 command not found,但登录终端正常 PATH 写在 .zprofile(登录专属) 挪到 .zshenv
tmux / zsh -i 里命令或 alias 失效 同上,非登录交互 shell 不读 .zprofile 同上
确认「装了」但 which 找不到 二进制在,PATH 没搬 ls -l /opt/.../bin/<cmd> 与 which 对比
PATH 里同一目录出现多次 多轮安装器重复 prepend typeset -U path PATH
MacPorts 装完新终端才有 安装器只写 .zprofile 见上

命令速查

# 判断没装还是没在 PATH
ls -l /opt/local/bin/<cmd>              # 装了?
which <cmd>                             # 在 PATH?
echo $PATH | tr ':' '\n' | grep -c local # PATH 里有几份

# 四种场景一致性
zsh -c 'which <cmd>'                    # 非交互(ssh/pdsh/脚本)
zsh -lc 'which <cmd>'                   # 登录非交互
zsh -ic 'which <cmd>'                   # 交互非登录(tmux)
zsh -lic 'which <cmd>'                  # 登录交互(Terminal 新窗口)

# 让 PATH 对所有场景生效
# ~/.zshenv 顶部:
#   typeset -U path PATH
#   export PATH="/opt/local/bin:/opt/local/sbin:$PATH"

# 语法自检(改完必跑)
zsh -n ~/.zshenv && zsh -n ~/.zshrc

参考资料