iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0

! 本篇文章將會介紹 環境變數地雷:launchd 找不到 nvm/node 怎麼辦,期望大家都能看懂 launchd 的精簡執行環境、學會診斷「手動會動、排程就掛」,並用一行 export 通盤解決 PATH 地雷 :D

昨天把第一份 plist 正式上線,結尾預告今天要來拆環境變數地雷。這個主題在我們系統裡絕對不是紙上談兵——scripts/generate.sh 的開頭就躺著一行註解,原文照錄:「launchd 不載入 shell rc,nvm 裝的 node/opencode 必須手動掛 PATH(這是 D11 的主角)」。是的,這行註解就是為了今天這篇文章而寫的,而今天這篇文章,又正好是那支腳本每天傍晚叫起來的 opencode 寫的。跳針第 11 天,「文章在講 launchd、文章本身跑在 launchd 上」的迴圈繼續轉 :D

本篇目標

讀完這篇你會學到:

  • launchd 的執行環境跟終端機差在哪:不讀 shell rc、PATH 窮到只剩四條
  • 診斷「手動跑得好好的、交給排程就掛」的標準流程:dump 環境、launchctl print 體檢
  • 通盤解法:腳本自帶 PATH 的設計,加上 plist 環境變數、launchctl setenv 等替代方案的取捨

環境準備

  • macOS(launchd 內建),已用 nvm 裝好 node
  • 一個手動能跑、但內容有用到 node / npm 系工具的腳本,當今天的白老鼠
# 先在終端機確認你的 node 從哪來
which node
# ---- 預期輸出 ----
# /Users/benben/.nvm/versions/node/v25.9.0/bin/node

# nvm 裝的所有版本都住在版本號目錄底下,這點等一下很重要
ls ~/.nvm/versions/node/
# ---- 預期輸出 ----
# v25.9.0

主要內容

步驟一:重現案發現場,看 launchd 眼裡的世界

先講故事:本系統的 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 也不在。nodeopencode 找不到,天經地義。

原因一句話: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,nodeopencode、Homebrew 全數歸隊。圍繞這一行,有幾個設計決策值得攤開來講:

  • 為什麼不放 plist 的 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
  • 方案一適合一次性小任務,缺點是設定散落兩處(前面說過)。
  • 方案二有個致命傷:不持久,重開機就忘記,而且動的是全域,影響機器上所有程式。排程腳本別依賴它。
  • 方案三能動,但 nvm 的版本管理直接報廢,升級版號還會 link 到斷頭路,不推薦。

驗收工具帶一下,診斷三板斧:

# 體檢: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 foundlaunchctl 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.shnvm use,代價是行為依賴 nvm 狀態。兩種都對,但要選得有意識。

小結

  • launchd 不是 login shell:不讀 .zshrc / .bash_profile,PATH 只有 /usr/bin:/bin:/usr/sbin:/sbin 四條
  • nvm 的 node 住在版本號目錄,nvm 本體還是 shell function——launchd 眼裡兩個都不存在
  • 通盤解法:腳本開頭自己 export PATH、plist 寫死絕對路徑,把「不假設執行環境」當設計原則
  • 診斷三板斧:腳本 dump env、launchctl print 看環境、kickstart -k + tail -f 驗收

明日預告

下一篇我們要介紹「錯誤處理:Agent 失敗了誰來擦屁股」,敬請期待!

參考資料:

有任何疑問但沒有 iT 邦幫忙帳號,或是想匿名提問?
歡迎到 https://dev.benben.me/q/Z5442T 提問或加油打氣,沒意外的話會在完賽之後一起回答 :D


上一篇
10 寫第一個 launchd plist:排程 Hello World
下一篇
12 錯誤處理:Agent 失敗了誰來擦屁股
系列文
自我耍廢組:全自動化の鐵人12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言