npx @deepseek-ai/dsh web 适合临时跑一跑,但一个要开机就在的服务需要三件事:登录后自动启动、崩了能自己拉起来、日志随时能看。brew services 三件都管,再配一个本地 tap,服务定义就活在 formula 里,跟着版本走。
这篇就是手头这台机器(Apple Silicon,Homebrew 在 /opt/homebrew)实际在跑的那套配置。
为什么要 tap
brew services start <formula> 需要先有一个 formula。tap 就是装着 Formula/*.rb 的目录——本地 tap 就是把这个目录放在磁盘上而不是 GitHub 上。同一个 formula,不用先发布。Homebrew 的惯例是把 tap 放在 $(brew --prefix)/Library/Taps/<owner>/homebrew-<name>/,所以这台机器上是:
/opt/homebrew/Library/Taps/<you>/homebrew-dsh/
├── Formula/dsh.rb
├── dsh-web # formula 安装的 wrapper 脚本
└── README.md
Formula
wrapper 脚本就放在 tap 目录里,formula 用 file:// URL 指向它并固定 SHA-256:
class Dsh < Formula
desc "DeepSeek Harness web UI service wrapper"
homepage "https://github.com/deepseek-ai/deepseek-harness"
version "0.1.2"
license "MIT"
# 本地 tap:直接从 tap 目录取 wrapper 脚本。
# 改过 dsh-web 后记得刷新 hash:shasum -a 256 dsh-web
url "file://#{__dir__}/../dsh-web", using: :nounzip
sha256 "8971a8894d71119dfc3bc0a15f146558932681a89928aab1e833b502c1070007"
depends_on :macos
def install
bin.install "dsh-web"
end
service do
run [opt_bin / "dsh-web"]
keep_alive true
working_dir HOMEBREW_PREFIX
log_path var / "log/dsh-web.log"
error_log_path var / "log/dsh-web.error.log"
process_type :interactive
end
test do
system "bash", "-n", bin / "dsh-web"
assert_path_exists bin / "dsh-web"
end
end
service do 块是全部重点:keep_alive true 让 launchd 在进程崩溃时自动拉起,working_dir HOMEBREW_PREFIX 给一个合理的工作目录,log_path/error_log_path 把日志统一收进 /opt/homebrew/var/log/。
Wrapper:没人提醒你的那个坑
launchd 不加载 shell profile,环境几乎是空的——node 和 dsh 都不在 PATH 上。如果一个 wrapper 直接裸调 npx 或 dsh,开机时会静默失败,你只会发现服务起不来。
这台机器上的 wrapper 显式解析两个可执行文件,优先走 Node 版本管理器(znvm)的当前版本,再退回 nub 的 shim,最后才是 Homebrew 的 node:
node_bin="$(find_bin "${DSH_NODE:-}" \
"$znvm_default" "$znvm_newest" \
"$HOME/.nub/node-shim/node" \
"/opt/homebrew/bin/node" "/usr/local/bin/node" "/usr/bin/node" || true)"
if [[ -n "$node_bin" ]]; then
export PATH="$(dirname "$node_bin"):$PATH"
fi
dsh_bin="$(find_bin "${DSH_BIN:-}" \
"$HOME/.znvm/npm/bin/dsh" \
"$HOME/.npm-global/bin/dsh" \
"/opt/homebrew/bin/dsh" "/usr/local/bin/dsh" || true)"
exec "$dsh_bin" web "$@"
两个路径都留了环境变量覆盖口(DSH_NODE/DSH_BIN)。脚本里有一句注释值得原样记住:nub 的 shim 在交互式 shell 里会解析到当前激活的 Node 版本,但在 launchd 的干净环境下可能退回旧版本——所以查找顺序很重要,znvm 的当前版本要排最前面。
安装、启动、验证
brew install <you>/dsh/dsh
brew services start dsh
brew services start 会生成 launchd agent——~/Library/LaunchAgents/homebrew.mxcl.dsh.plist,里面 RunAtLoad 和 KeepAlive 都是 true,程序参数、日志路径、工作目录都来自 formula 的 service 块。
然后确认真的起来了:
brew services list | grep dsh # started
launchctl list | grep dsh # 第一列是 PID
tail -f /opt/homebrew/var/log/dsh-web.log
这台机器的日志就是你最想看到的那行:
dsh web: http://127.0.0.1:3080
日常管理
brew services stop dsh
brew services restart dsh
brew services list
彻底移除:brew services stop dsh,brew uninstall dsh,再删掉 tap 目录。
换机器怎么办
本地 tap 变成远程 tap 只需要一步:把目录推到 GitHub。新机器上 brew tap your-name/dsh,然后同样的 brew services start dsh——formula、wrapper、服务定义一起过去。这就是从「在我机器上能跑」到「每台机器都能跑」的升级路径。