Two small but production-critical fixes once the binary actually runs:
1. Compiled binary couldn't spawn the daemon. daemon-client previously
pointed at design/src/daemon.ts via import.meta.dir — fine in dev,
fatal in production (the source path doesn't exist on a user's
machine). Fix: design CLI now self-execs in --daemon-mode when
invoked with that flag, so the spawn is `process.execPath
--daemon-mode --marker gstack-design-daemon` for the compiled binary
and `bun run cli.ts --daemon-mode ...` in dev. Same one binary, two
modes, no separate daemon entrypoint to ship.
2. Client and daemon disagreed on VERSION in the compiled binary.
Both used a source-tree-relative path that resolves to "unknown"
at runtime, which silently shorted the version-mismatch refusal
path (client expected "unknown" + daemon reported "unknown" → match
→ no refusal even when DESIGN_DAEMON_VERSION was set on one side).
New readVersionString() consults DESIGN_DAEMON_VERSION env first,
then design/dist/.version (sidecar baked at build time by build.sh),
then VERSION at the source-tree root. Both client and daemon now go
through this one helper.
Manual smoke (compiled binary, all checks green):
- DAEMON_STARTED + BOARD_PUBLISHED with trailing slash
- GET /boards/<id> (no slash) → 301 Location /boards/<id>/
- Second `$D serve` invocation → DAEMON_ATTACHED, new board on same port
- feedback.json gets boardId + publishedAt fields
- DESIGN_DAEMON_VERSION=v2-different on second invocation with
active board → WARNING + "Refusing to auto-kill" + exit 1,
original daemon still alive
- `$D daemon stop --force` removes state file
All 67 design tests still green after the refactor (16 serve + 30
daemon + 17 discovery + 4 daemon round-trip).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
design/src/daemon-client.ts implements the CLI side of the daemon lifecycle:
ensureDaemon() (the spawn-or-attach decision), publishBoard(), and the
$D daemon stop|status helpers.
Modeled on browse/src/cli.ts:317-415 — same health-check-first attach,
same fs.openSync('wx') lock, same re-read-state-INSIDE-the-lock guard
against two CLIs both deciding "no daemon, spawn." Two design-specific
safety properties added beyond browse:
1. verifyIdentity before any SIGTERM/SIGKILL. Reads the running process's
cmdline (/proc/PID/cmdline on Linux, `ps -p PID -o command=` on macOS)
and only signals if it contains CMDLINE_MARKER ("gstack-design-daemon",
passed as argv at spawn time). Prevents a stale state file from
causing us to kill an unrelated process that inherited the PID.
2. Refuse-kill-with-active-boards on version mismatch. Browse silently
restarts; here in-memory board history would vanish, so the client
prints a user-actionable WARNING and exit 1 instead. Users explicitly
`$D daemon stop` to override.
Spawn uses Node child_process.spawn (NOT Bun.spawn().unref) because of
the macOS session-detach quirks browse already discovered. Stdio is
redirected to ~/.gstack/design-daemon-startup.log, which the client
tails into stderr if waitForHealthOrError times out — no more silent
"daemon failed for some unknowable reason."
daemon-state.ts gains DESIGN_DAEMON_STATE_FILE env override so tests
can point both client and spawned daemon at a per-test path without a
shared cwd.
design/test/daemon-discovery.test.ts: 17 tests, all green in ~8s. Covers:
spawn-fresh, attach-existing, stale-state-file (pid dead), PID-reuse
safety (uses the test runner's own PID as the bait — verifyIdentity
catches the cmdline mismatch, daemon not signaled), version-mismatch
with/without active boards (the active-boards case runs a subprocess
and asserts exit 1 + WARNING in stderr), publishBoard 200 + 409,
shutdownDaemon refuse/force/unresponsive paths, daemonStatus.
The daemon-discovery suite is split out of daemon.test.ts because each
real spawn costs ~200ms; the in-process daemon.test.ts (30 tests, 70ms)
covers the same handler logic without the spawn overhead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds design/src/daemon.ts: a Bun.serve daemon that hosts many boards
under /boards/<id>/ instead of one server per `$D compare --serve` call.
Spawned by daemon-client (next commit); for now wired only via tests.
Endpoint table:
GET /health liveness + version + counts (unauth)
GET / index of recent boards
POST /api/boards publish; daemon derives sourceDir
from realpath(html). body sourceDir
IGNORED (Codex trust-boundary fix).
POST /shutdown graceful; refuses if active boards
exist (Codex data-loss fix)
GET /boards/<id> 301 → /boards/<id>/ (trailing slash
is load-bearing — relative URLs in
board JS resolve against pathname)
GET /boards/<id>/ render board HTML
GET /boards/<id>/api/progress state machine status (no idle reset)
POST /boards/<id>/api/feedback submit/regen; writes feedback.json
or feedback-pending.json with
boardId + publishedAt augmented in
POST /boards/<id>/api/reload swap HTML; per-board allowedDir
guard rejects traversal, directories,
out-of-allowed-dir symlinks
Lifecycle:
- 24h idle timeout (DESIGN_DAEMON_IDLE_MS for tests).
- Idle with active boards extends 1h up to 4x, then force-shuts (Codex).
- LRU cap 50 boards; evicts done before non-done; 503 when 50 non-done.
- Per-board async mutex serializes feedback POST vs reload POST.
- SIGTERM/SIGINT/uncaughtException → graceful shutdown, state file unlink.
- Stdout: DAEMON_STARTED port=<N> (the line the client parses).
Shared utilities live in design/src/daemon-state.ts: atomic state-file
write/read (mode 0o600), fs.openSync('wx') lock, isProcessAlive, cmdline
identity verification (/proc on Linux, ps on macOS), CMDLINE_MARKER
constant. Modeled on browse/src/cli.ts lock + spawn patterns.
design/test/daemon.test.ts: 30 tests, all green. Covers every endpoint,
both error paths and happy paths, cross-board feedback isolation, the
trailing-slash redirect, the directory-not-file reload rejection, LRU
preferring done over non-done, /shutdown refusal with active boards,
all path-traversal guards. Uses the exported fetchHandler in-process
(no spawn) so the suite runs in ~70ms.
design/test/daemon-tests-fixtures.ts: shared helpers — req() builder,
tmp-dir helpers, daemon reset, and a spawnDaemonForTest() helper used
by the next commit's discovery tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>