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,环境几乎是空的——nodedsh 都不在 PATH 上。如果一个 wrapper 直接裸调 npxdsh,开机时会静默失败,你只会发现服务起不来。

这台机器上的 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,里面 RunAtLoadKeepAlive 都是 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 dshbrew uninstall dsh,再删掉 tap 目录。

换机器怎么办

本地 tap 变成远程 tap 只需要一步:把目录推到 GitHub。新机器上 brew tap your-name/dsh,然后同样的 brew services start dsh——formula、wrapper、服务定义一起过去。这就是从「在我机器上能跑」到「每台机器都能跑」的升级路径。