! 本篇文章將會介紹 環境變數地雷:launchd 找不到 nvm/node 怎麼辦,期望大家都能看懂 launchd 的精簡執行環境、學會診斷「手動會動、排程就掛」,並用一行 export 通盤解決 PATH 地雷 :D
昨天把第一份 plist 正式上線,結尾預告今天要來拆環境變數地雷。這個主題在我們系統裡絕對不是紙上談兵——scripts/generate.sh 的開頭就躺著一行註解,原文照錄:「launchd 不載入 shell rc,nvm 裝的 node/opencode 必須手動掛 PATH(這是 D11 的主角)」。是的,這行註解就是為了今天這篇文章而寫的,而今天這篇文章,又正好是那支腳本每天傍晚叫起來的 opencode 寫的。跳針第 11 天,「文章在講 launchd、文章本身跑在 launchd 上」的迴圈繼續轉 :D
讀完這篇你會學到:
# 先在終端機確認你的 node 從哪來
which node
# ---- 預期輸出 ----
# /Users/benben/.nvm/versions/node/v25.9.0/bin/node
# nvm 裝的所有版本都住在版本號目錄底下,這點等一下很重要
ls ~/.nvm/versions/node/
# ---- 預期輸出 ----
# v25.9.0
先講故事:本系統的 plist 剛上線那晚,log 裡出現的是一行冷冰冰的 opencode: command not found。手動跑腳本一切正常,交給 launchd 就掛——這是 launchd 自動化的經典案型。要破案,先派一支偵察兵進去把現場環境倒出來:
# 環境偵察兵:被排程叫起來時,把 launchd 眼中的環境通通寫進 log
cat > ~/hello-launchd/env.sh <<'EOF'
#!/bin/bash
echo "PATH=$PATH"
env | sort
EOF
把它掛上 plist、launchctl kickstart 點火之後,log 裡的 PATH 長這樣(節選):
# launchd 給任務的 PATH,就是這麼窮
PATH=/usr/bin:/bin:/usr/sbin:/sbin
四條。終端機裡幾十條路徑的豪華陣容,到 launchd 手上只剩系統基本盤:/opt/homebrew/bin 不在、~/.nvm/... 不在、~/.opencode/bin 也不在。node、opencode 找不到,天經地義。
原因一句話:launchd 不是 login shell。 它執行任務時不會幫你跑 .zshrc、.zprofile、.bash_profile,直接 exec 你的腳本就開工。你在終端機裡看到的環境變數,是 shell 一層一層 rc 檔疊出來的便當;launchd 只給你白飯。
nvm 的處境又更尷尬一點:nvm 本體是 shell function,靠 rc 檔載入才存在;而它裝的 node 只是普通執行檔,躺在 ~/.nvm/versions/node/<版本>/bin/ 底下。終端機找得到 node,是因為 rc 檔把這條路徑塞進了 PATH——launchd 眼裡,function 不存在,路徑也沒掛,兩頭落空。
原則一句話:不要假設執行環境,腳本自己把環境準備好。 本系統三支排程腳本的開頭都是同一招:
# scripts/generate.sh(節選):launchd 不載入 shell rc,PATH 自己掛
export PATH="$HOME/.opencode/bin:$HOME/.nvm/versions/node/v25.9.0/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
一行 export,node、opencode、Homebrew 全數歸隊。圍繞這一行,有幾個設計決策值得攤開來講:
EnvironmentVariables? 可以放,但 XML 裡改字串很痛苦,而且生成、發文兩支腳本要共用同一組 PATH,散在兩份 plist 裡日後一定改漏。集中在腳本裡,哪天要動只改一處。ProgramArguments 寫死 /bin/bash 絕對路徑? 同一個哲學:連直譯器都不指望 PATH 幫你找。配套還有一行防呆,防的是手動執行時被 zsh 接手:# scripts/generate.sh(節選):被非 bash 執行時直接 re-exec 成 bash
[ -z "${BASH_VERSION:-}" ] && exec /bin/bash "$0" "$@"
source ~/.nvm/nvm.sh? 兩種都可行。source nvm.sh 的好處是換 node 版本不用改腳本,代價是載入整包 nvm 函式庫、行為跟著 nvm 的 alias 走。自動化系統要的是確定性:版本鎖死,排程行為就鎖死;要升級 node,就是一個明確的「改腳本 + 重新驗收」動作,而不是哪天 alias 悄悄換了、行為跟著悄悄變。publish.sh 也掛著同一組 PATH,理由相同。小小小測驗:你知道 launchd 給任務的 PATH 預設只有
/usr/bin:/bin:/usr/sbin:/sbin四條,連/usr/local/bin和 Homebrew 的/opt/homebrew/bin都不在裡面嗎?所以「我終端機明明裝好了」這句話,在 launchd 的世界一律不成立。
除了腳本自帶 PATH,常見的替代方案還有三種,各自有適用場景:
# 方案一:plist 的 EnvironmentVariables(節選示意)
# <key>EnvironmentVariables</key>
# <dict>
# <key>PATH</key>
# <string>/Users/benben/.nvm/versions/node/v25.9.0/bin:/usr/bin:/bin</string>
# </dict>
# 方案二:launchctl setenv 改全域環境(給 GUI app 用的主流做法)
launchctl setenv PATH "/usr/local/bin:/usr/bin:/bin"
# 方案三:把 node symlink 到系統路徑(看過就好,別學)
ln -s ~/.nvm/versions/node/v25.9.0/bin/node /usr/local/bin/node
驗收工具帶一下,診斷三板斧:
# 體檢:print 會列出任務的 environment 區塊
launchctl print gui/$(id -u)/com.benben.ironman.generate | grep -A 3 environment
# 改完腳本立刻驗收:強制重跑 + 盯 log
launchctl kickstart -k gui/$(id -u)/com.benben.ironman.generate
tail -f ~/ai/automations/ironman/logs/launchd-generate.out
log 不再噴 command not found、launchctl list 的 exit code 歸零,就是過關。本系列走到第 11 天,每天 19:00 的生成、20:00 的發文都靠這行 PATH 撐著——它就是整條自動化管線的地基。
Q:腳本手動跑一切正常,交給 launchd 就 command not found?
A:九成九是 PATH。終端機的環境是 shell rc 一層層疊出來的,launchd 不讀那些檔案。解法就是腳本開頭 export PATH,把需要的三五條路徑自己掛上,別指望繼承任何東西。
Q:用 launchctl setenv PATH ... 一次設好全域不是更省事?
A:省事但不保險:setenv 的設定不會跨重開機保存,哪天重開機後任務默默壞掉,你會花一整晚找不出原因。而且它是全域改動,波及所有 GUI 程式。自動化腳本的正解是自帶環境,不依賴機器當下的狀態。
Q:nvm 升級 node 版本之後,原本跑得好好的排程又掛了?
A:寫死 versions/node/v25.9.0/bin 的代價:版號一換,路徑跟著變。本系統的策略是把「升級 node」變成顯式動作——改腳本裡的 PATH、kickstart 驗收一次、確認收工。不想鎖版本,就改用 source ~/.nvm/nvm.sh 再 nvm use,代價是行為依賴 nvm 狀態。兩種都對,但要選得有意識。
.zshrc / .bash_profile,PATH 只有 /usr/bin:/bin:/usr/sbin:/sbin 四條export PATH、plist 寫死絕對路徑,把「不假設執行環境」當設計原則launchctl print 看環境、kickstart -k + tail -f 驗收下一篇我們要介紹「錯誤處理:Agent 失敗了誰來擦屁股」,敬請期待!
參考資料:
EnvironmentVariables 等欄位字典直接查終端機:man launchd.plist
有任何疑問但沒有 iT 邦幫忙帳號,或是想匿名提問?
歡迎到 https://dev.benben.me/q/Z5442T 提問或加油打氣,沒意外的話會在完賽之後一起回答 :D