本页暂时只有英文版
macOS Deployment — Auto-Launch on Login (launchd)¶
How to run the vibe-seller server as a managed service on a Mac (e.g. a Mac Mini customer deployment) so it starts on login and restarts on crash. This is the macOS sibling of docs/windows-setup.md (which covers the WSL2 / systemd path).
The bulk of this guide is the Gotchas section. Every one of them cost real debugging time on a live box — read it before touching
launchctl.
Target state¶
| Component | Mechanism |
|---|---|
| vibe-seller server | LaunchAgent com.vibe-seller.server (RunAtLoad + KeepAlive) |
| Process supervised | uv run python -m uvicorn app.main:app (foreground — launchd tracks the real PID) |
| Auto-start at boot | macOS automatic login → Aqua session → LaunchAgent loads |
| Logs | ~/Library/Logs/vibe-seller/launchd.{out,err}.log |
| Browser automation | needs the same logged-in GUI (Aqua) session — see below |
Why a LaunchAgent (not a LaunchDaemon)¶
- A LaunchAgent runs inside the user's Aqua (GUI login) session.
The browser backends (
chrome,ziniao) drive a headed browser, which needs a real window-server session — exactly what an Agent gets and a Daemon does not. - It runs as the user, with the user's
~/.vibe-seller/data dir and tools (uv,node,claude), so no path/permission juggling for those. - Trade-off: an Agent only starts after someone logs in. For an unattended box, enable automatic login (below) so a reboot lands in the Aqua session and the Agent loads on its own.
1. The plist¶
~/Library/LaunchAgents/com.vibe-seller.server.plist (adjust the user
name, repo path, and port for the target box):
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.vibe-seller.server</string>
<key>ProgramArguments</key>
<array>
<string>/Users/YOUR_USER/.local/bin/uv</string>
<string>run</string>
<string>python</string>
<string>-m</string>
<string>uvicorn</string>
<string>app.main:app</string>
<string>--host</string>
<string>0.0.0.0</string>
<string>--port</string>
<string>7777</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/YOUR_USER/Desktop/vibe-seller</string>
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key>
<string>/Users/YOUR_USER</string>
<key>PATH</key>
<string>/Users/YOUR_USER/.local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
<key>LOG_DIR</key>
<string>/Users/YOUR_USER/Desktop/vibe-seller/logs</string>
<key>BACKEND_PORT</key>
<string>7777</string>
<!-- dev mode: full DEBUG + agent prompt logging.
Remove these two keys (or set LOG_LEVEL=INFO) for production. -->
<key>LOG_LEVEL</key>
<string>DEBUG</string>
<key>AGENT_DEBUG</key>
<string>1</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>10</integer>
<key>ProcessType</key>
<string>Interactive</string>
<key>StandardOutPath</key>
<string>/Users/YOUR_USER/Library/Logs/vibe-seller/launchd.out.log</string>
<key>StandardErrorPath</key>
<string>/Users/YOUR_USER/Library/Logs/vibe-seller/launchd.err.log</string>
</dict>
</plist>
The plist runs the backend only, against whatever is already in
frontend/dist/. It deliberately does not build the frontend (see Gotcha 6).
2. Install / load¶
mkdir -p ~/Library/Logs/vibe-seller
plutil -lint ~/Library/LaunchAgents/com.vibe-seller.server.plist # validate XML
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.vibe-seller.server.plist
RunAtLoad starts it immediately. Verify:
launchctl print gui/$(id -u)/com.vibe-seller.server | grep -iE 'state =|last exit|pid ='
# state = running
# pid = NNNN
# last exit code = (never exited)
curl -s http://127.0.0.1:7777/api/health # {"status":"ok"}
bootstrap/bootoutare the modern (macOS 11+) verbs. The oldlaunchctl load -w/unloadstill work but are deprecated and give worse error messages — preferbootstrap/bootout.
3. Day-to-day operations¶
LABEL=gui/$(id -u)/com.vibe-seller.server
# Status + last exit code
launchctl print $LABEL | grep -iE 'state =|last exit|pid ='
# Restart the running process (does NOT re-read the plist)
launchctl kickstart -k $LABEL
# Tail logs
tail -f ~/Library/Logs/vibe-seller/launchd.err.log
# Stop / unload
launchctl bootout $LABEL
To change the plist itself (port, env vars, log level) you must reload —
kickstart keeps the old definition:
launchctl bootout gui/$(id -u)/com.vibe-seller.server # then wait a beat (see Gotcha 4)
sleep 3
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.vibe-seller.server.plist
4. Deploy a new version¶
The Agent owns the process; do not use ./start.sh / ./restart.sh
for a launchd-managed box (see Gotcha 3).
Deploy = pull, rebuild the frontend if it changed, then restart via
launchd:
cd ~/Desktop/vibe-seller
git pull
(cd frontend && pnpm install && pnpm build) # only if the frontend changed
launchctl kickstart -k gui/$(id -u)/com.vibe-seller.server
Python deps are handled automatically: uv run syncs the env on start.
5. Auto-start at boot (unattended box)¶
A LaunchAgent only loads once the user is in the Aqua session, so for a headless reboot enable automatic login:
- System Settings → Users & Groups → Automatically log in as… → the service user.
- This also gives the headed browser backends a real GUI session to draw into.
Keep the machine awake:
sudo pmset -a sleep 0 disablesleep 1 # never sleep (display can still blank)
Gotchas (read before you debug)¶
These are the failures we actually hit bringing this up. Symptoms were
maddening because several produce a silent EX_CONFIG (exit 78) with no
log output at all.
1. launchd's PATH is minimal — uv won't be found via a login shell¶
launchd starts jobs with PATH=/usr/bin:/bin:/usr/sbin:/sbin. uv lives
in ~/.local/bin, which isn't on it.
The tempting fix — wrapping the command in /bin/zsh -lc "…" — does not
work: zsh -c is non-interactive, so it sources .zshenv / .zprofile
but skips .zshrc, which is exactly where the uv installer (and most
tools) export PATH. The job then either can't find uv or starts in a
half-configured env.
Fix: invoke uv by absolute path and set PATH + HOME
explicitly in EnvironmentVariables. No shell wrapper needed. (Same class
of bug as the WSL "non-interactive PATH" gotcha in
windows-setup.md §6.)
2. Logs must NOT live in ~/Desktop, ~/Documents, or ~/Downloads¶
Those folders are TCC-protected. A launchd agent with no TCC grant
cannot open a StandardErrorPath / StandardOutPath inside them — so the
job fails to set up its I/O and exits EX_CONFIG (78) while writing
absolutely nothing to the log it couldn't open. The silence is the
tell.
Fix: point the log paths at ~/Library/Logs/… (not TCC-protected,
persistent, and visible in Console.app). The repo lives in ~/Desktop here
out of convenience; keeping the logs out of ~/Desktop is what matters.
(If you ever see exit 78 with empty logs, this — or Gotcha 1 — is almost
always why.)
3. launchd owns the port — don't also run start.sh / restart.sh¶
start.sh nohups its own uvicorn on :7777. If you run it while the
Agent is loaded, two processes fight for the port: the Agent's
KeepAlive keeps trying to bind, hits EADDRINUSE, dies, and retries
forever (throttled to every 10 s). You'll see the manual server "working"
while launchctl print shows the Agent stuck failing.
Fix: pick one owner. On a launchd-managed box that's the Agent — restart
with launchctl kickstart -k, never ./restart.sh. To hand control back to
the scripts, launchctl bootout the Agent first.
4. bootstrap right after bootout races — 5: Input/output error¶
Re-bootstrapping before launchd has fully released the previous instance
fails with Bootstrap failed: 5: Input/output error. The label is still
mid-teardown.
Fix: bootout, sleep 2–3, then bootstrap (retry once or twice if
needed). A reload helper:
launchctl bootout gui/$(id -u)/com.vibe-seller.server 2>/dev/null
sleep 3
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.vibe-seller.server.plist
5. Decoding the exit status¶
launchctl list <label> reports the raw wait status, not the exit
code. 19968 looks alarming until you shift it: 19968 >> 8 = 78
(EX_CONFIG). launchctl print gui/$(id -u)/<label> shows the friendlier
last exit code = 78: EX_CONFIG plus the full resolved environment, working
dir, and log paths — start there when a job won't stay up.
6. The LaunchAgent does not build the frontend¶
By design the plist runs uvicorn directly against the existing
frontend/dist/. We keep the build out of the supervised command on
purpose:
- a slow build in the
KeepAlivecrash-recovery path would delay every restart, and - a frontend build break would take the whole server down on the next restart instead of serving the last-good bundle.
So rebuild the frontend as an explicit step of §4 Deploy, not as part of the service.
Troubleshooting cheatsheet¶
| Symptom | Cause | Fix |
|---|---|---|
Exit 78 (EX_CONFIG), empty logs |
log path in ~/Desktop (TCC) or uv not on PATH |
logs → ~/Library/Logs; absolute uv + explicit PATH (Gotchas 1–2) |
command not found / exit 127 |
zsh -lc skipped .zshrc; uv unresolved |
invoke uv by absolute path (Gotcha 1) |
| Agent stuck restarting every ~10 s | port held by a start.sh nohup server |
one owner only — bootout the manual one (Gotcha 3) |
Bootstrap failed: 5: Input/output error |
re-bootstrapped before teardown finished | bootout → sleep 3 → bootstrap (Gotcha 4) |
| Server up but serving stale UI | frontend/dist/ not rebuilt after pull |
pnpm build then kickstart -k (Gotcha 6) |
| Agent never loads after reboot | no Aqua session (nobody logged in) | enable automatic login (§5) |
| Plist edit didn't take effect | kickstart reuses the loaded definition |
reload with bootout + bootstrap (§3) |
See also¶
- docs/windows-setup.md — the Windows + WSL2 / systemd deployment path (the sibling of this guide).
- docs/browser.md — browser backends; why headed automation needs a GUI session.
start.sh/restart.sh/stop.sh— the interactive (non-launchd) run scripts.