mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-19 00:00:13 +02:00
cf50443b63
* refactor(design): board JS uses relative paths; drop __GSTACK_SERVER_URL injection
Board JS in design/src/compare.ts now calls ./api/feedback and ./api/progress
(relative to location.pathname) and feature-detects server mode via
location.protocol instead of the injected window.__GSTACK_SERVER_URL global.
The injection in design/src/serve.ts is removed (dead code now that nothing
reads it). Tests updated to match the new contract: serve.test.ts asserts
the relative-path JS is present and the global is gone; feedback-roundtrip
asserts location.protocol detects HTTP mode.
Why: prep for the multi-board daemon (design/src/daemon.ts upcoming) where
the same generated HTML is served at /boards/<id>/ instead of /. Relative
paths resolve against location.pathname in both cases, so one HTML, two
hosts. The injection was the only thing tying board JS to a specific
serving path; removing it unblocks the daemon work without forking the
generator.
file:// fallback preserved via the location.protocol feature-detect — board
opened directly as a file still falls through to the DOM-only success path.
The 6 feedback-roundtrip browser tests continue to fail with
session.clearLoadedHtml undefined; that failure pre-exists this branch
(verified against HEAD with these edits stashed) and lives in
browse/src/write-commands.ts, not in the design code path. Tracking
separately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(design): reload guard rejects directory paths
design/src/serve.ts:200-212 used to accept a path that resolved to the
allowedDir itself (the OR branch `|| resolvedReload === allowedDir`),
which then crashed readFileSync with EISDIR. Now:
1. startsWith(allowedDir + path.sep) must pass — rejects the dir itself
and anything outside (403).
2. statSync(resolvedReload).isFile() must pass — rejects subdirectories
inside allowedDir with a clear "Path must be a file" 400.
The test stub in serve.test.ts mirrors prod; both updated, plus two new
test cases for the previously-broken paths. Codex caught this in the
plan-review pass; it's a latent bug in shipping code, not a regression
from the daemon work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(design): introduce design daemon — multi-board persistent server
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>
* feat(design): daemon-client with lock + identity-verified spawn
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>
* feat(design): wire daemon dispatch into CLI; add daemon stop/status
design/src/cli.ts now branches on --no-daemon for both `compare --serve`
and standalone `serve --html`. Default path: ensureDaemon → publishBoard
→ openBrowser → exit. The legacy single-process serve() is preserved
behind --no-daemon for tests, Windows, and explicit debugging.
Adds $D daemon status (prints daemon state JSON, or {running:false})
and $D daemon stop [--force] (refuses with active boards unless --force).
parseArgs gains a `positionals` field so daemon sub-commands work
naturally (`$D daemon stop` instead of `$D --action stop`).
Stderr lines printed by the publishToDaemon path:
DAEMON_STARTED port=N (or DAEMON_ATTACHED port=N)
BOARD_PUBLISHED: <url>
BOARD_URL: <url> (alias for grep-friendliness)
Stdout: JSON with id, url, sourceDir.
design/src/commands.ts: --no-daemon, --title added to compare + serve;
new daemon command entry with status|stop sub-commands.
End-to-end smoke (manual): spawning a board via $D serve, hitting the
returned URL, reading /health, calling daemon status (returns the
right JSON), and daemon stop refusing because of the active board —
all work as designed. Force-stop tears down cleanly and removes the
state file.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(design): end-to-end daemon round-trip via HTTP fetch
design/test/feedback-roundtrip-daemon.test.ts walks the full publish →
submit / regenerate / reload cycle against a real spawned daemon, using
the same HTTP calls the board JS makes. Four tests, all green in ~650ms.
Covers what design-shotgun and friends actually depend on:
- Submit writes feedback.json into the board's sourceDir with the
augmented boardId + publishedAt fields.
- GET /boards/<id> (no slash) returns a 301 to /boards/<id>/ — the
load-bearing redirect that lets the board JS use relative paths.
- Regenerate writes feedback-pending.json, flips state to regenerating,
/api/progress reflects it; /api/reload swaps HTML in place; round-2
submit writes the final feedback.json with the round-2 selection.
- Two boards published into the same daemon get independent URLs on
the same port — feedback for board A doesn't contaminate board B's
sourceDir, both URLs serve their own content, the index lists both.
Uses HTTP fetch rather than a real browser because the existing browser
round-trip (feedback-roundtrip.test.ts) is broken on a pre-existing
browse harness regression (session.clearLoadedHtml undefined in
browse/src/write-commands.ts:149) that's unrelated to this branch.
The HTTP path proves the same daemon semantics; a browser variant can
be added once the browse harness is fixed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(design): compiled binary self-execs as daemon; unified version lookup
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>
* fix(design): skill resolvers learn the daemon's BOARD_URL output
The five skills that invoke $D compare --serve (design-shotgun,
design-consultation, plan-design-review, office-hours, design-review)
parsed `SERVE_STARTED: port=N` from stderr and then POSTed to
`/api/reload` at that port during regenerate cycles. The new daemon
hosts boards under `/boards/<id>/` so the reload endpoint moved to
`<BOARD_URL>api/reload` — without this update, the regenerate phase
of every skill invocation would silently fail against daemon mode.
Updated scripts/resolvers/design.ts to parse `BOARD_URL:` instead of
the port, and to POST reloads against the per-board URL. Regenerated
the four SKILL.md files via bun run gen:skill-docs.
Legacy `--no-daemon` invocations continue to emit `SERVE_STARTED:` and
serve at `/api/reload` — the resolver instructions note both.
Surfaced by the maintainability specialist during /ship review (the
"stale comment" finding was actually a behavior bug pointing at five
downstream consumers). Codex's plan-review pass flagged the migration
story as incomplete but I dismissed the concern — Codex was right.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(design): emit SERVE_STARTED back-compat alias; drop dead import
design/src/cli.ts publishToDaemon now emits `SERVE_STARTED: port=N html=<path>`
as a third stderr line alongside DAEMON_STARTED/DAEMON_ATTACHED + BOARD_URL.
Any out-of-tree script that grepped the legacy line still gets the port —
they'd still fail at the reload step (the endpoint moved to /boards/<id>/
api/reload) but they no longer fail at the port-detection step. Combined with
the resolver updates one commit back, this is belt-and-suspenders compat.
Fixed the stale docstring at cli.ts:316 that claimed back-compat without
actually emitting the alias. The maintainability specialist flagged it.
Dropped a dead `DaemonState` import from daemon-client.ts. Same review pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: bump version and changelog (v1.45.0.0)
Design boards now live 24h, not 10 minutes. One daemon hosts every
board, one tab survives the whole day. See CHANGELOG.md for the full
release summary + metrics + itemized changes.
TODOS.md gains a "design daemon: follow-ups" section capturing the
P3 test gaps + maintainability nits the /ship review army flagged
but that aren't blocking for this release.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(design): fill daemon test gaps surfaced by ship review army
Adds 10 net new tests (and removes 1 misleading smoke) for the gaps the
testing specialist flagged at /ship time. Filed as P3 TODOs at ship,
filling now per boil-the-lake.
design/test/daemon-discovery.test.ts (+6 tests, +1 import):
- "idle daemon (no boards) shuts itself down after IDLE_MS + CHECK_MS"
Spawn-based, DESIGN_DAEMON_IDLE_MS=2000, CHECK_MS=200. Waits for the
daemon process to actually exit and asserts the state file is removed.
Previously only "callable without throwing" was tested.
- "bare GET polling does NOT prevent idle shutdown"
Hammers /api/progress every 200ms in a background loop with a done
board, asserts the daemon still idles out — proves the
meaningful-activity-only-on-POSTs guard (Codex finding) actually works.
- "idle with active (non-done) boards triggers extension instead of shutdown"
Sets DESIGN_DAEMON_EXTENSION_MS=1500 + MAX_EXTENSIONS=2, publishes a
non-done board, asserts the daemon survives past IDLE_MS (extends),
then verifies the MAX_EXTENSIONS hard ceiling force-shuts. Both the
extension counter and the hard ceiling were previously untested.
- "two parallel ensureDaemon() calls converge on one daemon"
Fires two ensureDaemon calls in Promise.all against an empty stateFile,
asserts: both ports match, exactly one spawned=true, exactly one daemon
alive, no orphaned lock file. The discovery-test file's own docstring
claimed this test existed; now it actually does.
- "acquireLock reclaims a lockfile owned by a dead PID"
Plants a lockfile with PID 999999998, calls acquireLock, asserts the
returned release fn is non-null and the lock now holds our PID.
- "acquireLock refuses to reclaim a lockfile owned by an alive PID"
Uses the test runner's own PID — alive but not the lock's intended
owner. Asserts acquireLock returns null and leaves the lockfile
untouched. The unrelated-process-PID-reuse safety guard.
design/test/daemon.test.ts (-2 misleading, +5 new = +3 net):
- Removed: "bare GET /api/progress does NOT reset meaningful activity"
(smoke pretending to be behavioral — body comment admitted it couldn't
verify). Replaced by the spawn-based version in daemon-discovery above.
- Removed: "idleCheckTick is callable without throwing when there's no idle"
(collapsed into a single smoke describe that's clearer about its scope).
- Added: "POST /api/boards rejects invalid JSON body"
- Added: "POST /api/boards rejects non-object body (e.g. JSON null)"
- Added: "POST /api/boards: array body falls through to missing-html 400"
(documents the typeof-array-is-object JS quirk; will surface if we
ever tighten the type check)
- Added: "POST /boards/<id>/api/reload rejects invalid JSON body"
- Added: "POST /boards/<id>/api/reload rejects body missing html field"
Per-file totals after: serve 16, daemon 34, discovery 23, round-trip 4 = 77.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: update CHANGELOG + TODOS for filled test gaps in v1.45.0.0
Bumps the design test count from 67 → 77 (and the new-test delta from
+51 → +61) to reflect commit 6b037c55, which filled the 5 P3 test gaps
the /ship review army had filed to TODOS.md.
Marks the "Tighten daemon test coverage" entry in TODOS.md as DONE.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
420 lines
14 KiB
TypeScript
420 lines
14 KiB
TypeScript
/**
|
|
* CLI-side client for the design daemon.
|
|
*
|
|
* Responsible for the lifecycle dance that `$D compare --serve` (default
|
|
* path) goes through:
|
|
*
|
|
* ensureDaemon() → publishBoard(html, opts) → openBrowser(url) → exit 0
|
|
*
|
|
* Mirrors browse/src/cli.ts:317-415 — same health-check-first attach
|
|
* decision, same fs.openSync('wx') lock, same re-read-under-lock guard.
|
|
* Adds two design-specific safety properties Codex flagged on the daemon
|
|
* plan:
|
|
*
|
|
* 1. Identity verification before any SIGTERM. Browse signals on PID
|
|
* alone; here we require the cmdline to contain CMDLINE_MARKER so a
|
|
* stale state file pointing at a reused PID doesn't kill an
|
|
* unrelated process.
|
|
*
|
|
* 2. Refuse-to-kill on version mismatch with active boards. Browse will
|
|
* restart on version drift; here in-memory boards would be lost, so
|
|
* we exit 1 with a user-actionable message instead of silent loss.
|
|
*
|
|
* Spawn uses Node's child_process.spawn with detached: true + stdio
|
|
* pointed at a log file. Bun.spawn().unref() has macOS session-detach
|
|
* quirks browse already discovered (browse/src/cli.ts:225-275).
|
|
*/
|
|
|
|
import { spawn as nodeSpawn } from "child_process";
|
|
import fs from "fs";
|
|
import path from "path";
|
|
import { setTimeout as delay } from "timers/promises";
|
|
|
|
import {
|
|
acquireLock,
|
|
CMDLINE_MARKER,
|
|
healthCheck,
|
|
isProcessAlive,
|
|
readStateFile,
|
|
readVersionString,
|
|
resolveLockFilePath,
|
|
resolveStartupLogPath,
|
|
resolveStateFilePath,
|
|
verifyIdentity,
|
|
} from "./daemon-state";
|
|
|
|
const MAX_START_WAIT_MS = parseInt(
|
|
process.env.DESIGN_DAEMON_START_TIMEOUT_MS || "8000",
|
|
10,
|
|
);
|
|
const POLL_INTERVAL_MS = 100;
|
|
const SIGTERM_GRACE_MS = 2000;
|
|
|
|
export interface EnsureDaemonOptions {
|
|
/** Default: package version. Used for version-match check. */
|
|
version?: string;
|
|
/** Default: `<repo>/design/src/daemon.ts`. */
|
|
daemonScript?: string;
|
|
/** Extra env vars passed to the spawned daemon. */
|
|
daemonEnv?: Record<string, string>;
|
|
/** Print noisy progress to stderr. Default true. */
|
|
verbose?: boolean;
|
|
/**
|
|
* Override the state-file path. Default: resolveStateFilePath() (env
|
|
* DESIGN_DAEMON_STATE_FILE or .gstack/design.json under the git root /
|
|
* cwd). Tests inject a per-test path; the same path is forwarded to the
|
|
* spawned daemon via env so client + daemon agree.
|
|
*/
|
|
stateFile?: string;
|
|
}
|
|
|
|
export interface EnsureDaemonResult {
|
|
port: number;
|
|
version: string;
|
|
spawned: boolean;
|
|
}
|
|
|
|
function log(verbose: boolean, msg: string): void {
|
|
if (verbose) process.stderr.write(`[design-daemon] ${msg}\n`);
|
|
}
|
|
|
|
/**
|
|
* Ensure a design daemon is reachable on the project's state file. Returns
|
|
* the port to talk to. Spawns a new daemon under an exclusive lock when
|
|
* needed; attaches to an existing healthy daemon otherwise.
|
|
*
|
|
* Exits with code 1 (not throws) on the refuse-kill-with-active-boards
|
|
* branch — that's a user-actionable situation, not a programming error.
|
|
*/
|
|
export async function ensureDaemon(
|
|
opts: EnsureDaemonOptions = {},
|
|
): Promise<EnsureDaemonResult> {
|
|
const verbose = opts.verbose !== false;
|
|
const expectedVersion = opts.version ?? readPackageVersion();
|
|
const stateFile = opts.stateFile ?? resolveStateFilePath();
|
|
|
|
const existing = readStateFile(stateFile);
|
|
if (existing) {
|
|
const health = await healthCheck(existing.port);
|
|
if (health) {
|
|
if (health.version === expectedVersion) {
|
|
log(verbose, `attached to existing daemon pid=${existing.pid} port=${existing.port}`);
|
|
return { port: existing.port, version: health.version, spawned: false };
|
|
}
|
|
// Version mismatch: refuse if active boards exist (Codex finding).
|
|
if (health.activeBoards > 0) {
|
|
process.stderr.write(
|
|
`[design-daemon] WARNING: existing daemon is gstack ${health.version}; this CLI is ${expectedVersion}.\n` +
|
|
`[design-daemon] ${health.activeBoards} active board(s) detected. Refusing to auto-kill.\n` +
|
|
`[design-daemon] Submit or close the open boards, then re-run.\n` +
|
|
`[design-daemon] Or force restart: $D daemon stop (will drop in-memory history).\n`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
// No active boards — safe to graceful-shutdown and respawn.
|
|
log(verbose, `daemon version mismatch (${health.version} vs ${expectedVersion}); shutting down`);
|
|
await gracefulShutdownExistingDaemon(existing.port);
|
|
await killByPidWithIdentity(existing.pid, existing.cmdlineMarker, verbose);
|
|
} else {
|
|
// State file points at an unresponsive port. Either the daemon
|
|
// crashed or the PID got reused. Identity-verify before any SIGTERM
|
|
// so we don't kill an unrelated process (Codex finding).
|
|
log(verbose, `state file present (pid=${existing.pid}) but /health unresponsive`);
|
|
await killByPidWithIdentity(existing.pid, existing.cmdlineMarker, verbose);
|
|
}
|
|
}
|
|
|
|
// Spawn under exclusive lock; re-read state INSIDE the lock so we don't
|
|
// race a concurrent CLI that won the lock first.
|
|
const lockPath = resolveLockFilePath(stateFile);
|
|
const release = acquireLock(lockPath);
|
|
if (!release) {
|
|
// Another process is starting the daemon. Wait for it.
|
|
log(verbose, "another CLI is spawning the daemon; waiting…");
|
|
const start = Date.now();
|
|
while (Date.now() - start < MAX_START_WAIT_MS) {
|
|
const fresh = readStateFile(stateFile);
|
|
if (fresh) {
|
|
const h = await healthCheck(fresh.port);
|
|
if (h) return { port: fresh.port, version: h.version, spawned: false };
|
|
}
|
|
await delay(POLL_INTERVAL_MS);
|
|
}
|
|
throw new Error("Timed out waiting for concurrent daemon spawn");
|
|
}
|
|
|
|
try {
|
|
// Re-read under lock. Another caller may have already finished spawning
|
|
// between our first check and our lock acquisition.
|
|
const fresh = readStateFile(stateFile);
|
|
if (fresh) {
|
|
const h = await healthCheck(fresh.port);
|
|
if (h && h.version === expectedVersion) {
|
|
log(verbose, `another CLI won the lock; attaching pid=${fresh.pid} port=${fresh.port}`);
|
|
return { port: fresh.port, version: h.version, spawned: false };
|
|
}
|
|
}
|
|
|
|
log(verbose, "spawning new daemon");
|
|
const port = await spawnDaemon({
|
|
script: opts.daemonScript,
|
|
env: { ...opts.daemonEnv, DESIGN_DAEMON_STATE_FILE: stateFile },
|
|
stateFile,
|
|
expectedVersion,
|
|
});
|
|
return { port, version: expectedVersion, spawned: true };
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Publish a board to the daemon and return its URL. Wraps the HTTP POST
|
|
* with a friendlier error surface than raw fetch.
|
|
*/
|
|
export interface PublishBoardOptions {
|
|
port: number;
|
|
html: string;
|
|
title?: string;
|
|
publisherPid?: number;
|
|
}
|
|
|
|
export interface PublishBoardResult {
|
|
id: string;
|
|
url: string;
|
|
sourceDir: string;
|
|
}
|
|
|
|
export async function publishBoard(opts: PublishBoardOptions): Promise<PublishBoardResult> {
|
|
const body: Record<string, unknown> = {
|
|
html: opts.html,
|
|
publisherPid: opts.publisherPid ?? process.pid,
|
|
};
|
|
if (opts.title) body.title = opts.title;
|
|
const resp = await fetch(`http://127.0.0.1:${opts.port}/api/boards`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!resp.ok) {
|
|
let errText: string;
|
|
try {
|
|
const j = (await resp.json()) as { error?: string; existing?: { id: string; url: string } };
|
|
if (j.existing) {
|
|
// 409: surface the existing-board URL so the caller can reuse it
|
|
return { id: j.existing.id, url: j.existing.url, sourceDir: "" };
|
|
}
|
|
errText = j.error || `HTTP ${resp.status}`;
|
|
} catch {
|
|
errText = `HTTP ${resp.status}`;
|
|
}
|
|
throw new Error(`Daemon refused publish: ${errText}`);
|
|
}
|
|
return (await resp.json()) as PublishBoardResult;
|
|
}
|
|
|
|
// ─── Internals ───────────────────────────────────────────────────
|
|
|
|
function readPackageVersion(): string {
|
|
return readVersionString();
|
|
}
|
|
|
|
function defaultDaemonScript(): string {
|
|
// design/src/daemon-client.ts → daemon.ts is a sibling. Only used in dev
|
|
// when this process is `bun run cli.ts`; the compiled-binary path
|
|
// self-execs instead (see resolveSpawnCommand).
|
|
return path.join(import.meta.dir, "daemon.ts");
|
|
}
|
|
|
|
/**
|
|
* Compute the argv to spawn the daemon. Two modes:
|
|
*
|
|
* Compiled binary (`design/dist/design`): re-exec ourselves with
|
|
* --daemon-mode. process.execPath IS the compiled design binary;
|
|
* spawning it again with the flag runs the daemon (see the
|
|
* --daemon-mode branch at the bottom of cli.ts).
|
|
*
|
|
* Dev (`bun run design/src/cli.ts`): process.execPath is bun, so we
|
|
* invoke `bun run <daemon.ts> --marker ...` directly.
|
|
*
|
|
* Tests can override the dev script via opts.script.
|
|
*/
|
|
function resolveSpawnCommand(scriptOverride: string | undefined): {
|
|
command: string;
|
|
args: string[];
|
|
} {
|
|
const execBase = path.basename(process.execPath).toLowerCase();
|
|
const isCompiledHost = execBase !== "bun" && execBase !== "bun.exe" && execBase !== "node";
|
|
if (isCompiledHost && !scriptOverride) {
|
|
return {
|
|
command: process.execPath,
|
|
args: ["--daemon-mode", "--marker", CMDLINE_MARKER],
|
|
};
|
|
}
|
|
const script = scriptOverride ?? defaultDaemonScript();
|
|
return {
|
|
command: "bun",
|
|
args: ["run", script, "--marker", CMDLINE_MARKER],
|
|
};
|
|
}
|
|
|
|
interface SpawnDaemonOpts {
|
|
script?: string;
|
|
env?: Record<string, string>;
|
|
stateFile: string;
|
|
expectedVersion: string;
|
|
}
|
|
|
|
async function spawnDaemon(opts: SpawnDaemonOpts): Promise<number> {
|
|
const logPath = resolveStartupLogPath();
|
|
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
// Truncate the startup log on each spawn so a later read finds only THIS
|
|
// attempt's output (mirrors browse's per-spawn log truncation).
|
|
fs.writeFileSync(logPath, "");
|
|
const logFd = fs.openSync(logPath, "a");
|
|
|
|
const { command, args } = resolveSpawnCommand(opts.script);
|
|
|
|
const child = nodeSpawn(command, args, {
|
|
detached: true,
|
|
stdio: ["ignore", logFd, logFd],
|
|
env: {
|
|
...process.env,
|
|
DESIGN_DAEMON_VERSION: opts.expectedVersion,
|
|
...(opts.env ?? {}),
|
|
},
|
|
});
|
|
child.unref();
|
|
fs.closeSync(logFd);
|
|
|
|
// Poll the state file + /health until the daemon is up, or until timeout.
|
|
const deadline = Date.now() + MAX_START_WAIT_MS;
|
|
while (Date.now() < deadline) {
|
|
const fresh = readStateFile(opts.stateFile);
|
|
if (fresh) {
|
|
const h = await healthCheck(fresh.port);
|
|
if (h) return fresh.port;
|
|
}
|
|
await delay(POLL_INTERVAL_MS);
|
|
}
|
|
|
|
// Timed out — surface the startup log so the user sees the actual error
|
|
// instead of "daemon failed silently."
|
|
let tail = "";
|
|
try {
|
|
tail = fs.readFileSync(logPath, "utf-8").trim();
|
|
} catch {
|
|
// log file may not exist
|
|
}
|
|
throw new Error(
|
|
`Design daemon failed to start within ${MAX_START_WAIT_MS}ms.\n` +
|
|
`Startup log (${logPath}):\n${tail || "(empty)"}`,
|
|
);
|
|
}
|
|
|
|
async function gracefulShutdownExistingDaemon(port: number): Promise<void> {
|
|
try {
|
|
await fetch(`http://127.0.0.1:${port}/shutdown`, {
|
|
method: "POST",
|
|
signal: AbortSignal.timeout(2000),
|
|
});
|
|
} catch {
|
|
// Daemon may have already exited or be unresponsive — fall through
|
|
// to the SIGTERM path with identity verification.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send SIGTERM (then SIGKILL) to `pid`, but ONLY if the running cmdline
|
|
* contains `marker`. Prevents a stale state file from causing us to signal
|
|
* an unrelated process that inherited the PID.
|
|
*/
|
|
async function killByPidWithIdentity(
|
|
pid: number,
|
|
marker: string,
|
|
verbose: boolean,
|
|
): Promise<void> {
|
|
if (!pid || pid <= 0) return;
|
|
if (!isProcessAlive(pid)) return;
|
|
if (!verifyIdentity(pid, marker || CMDLINE_MARKER)) {
|
|
log(
|
|
verbose,
|
|
`pid ${pid} is alive but cmdline doesn't match marker '${marker || CMDLINE_MARKER}'; skipping signal (possible PID reuse)`,
|
|
);
|
|
return;
|
|
}
|
|
try {
|
|
process.kill(pid, "SIGTERM");
|
|
} catch {
|
|
// already gone
|
|
return;
|
|
}
|
|
// Give it a grace period; SIGKILL if still alive AND still ours.
|
|
const deadline = Date.now() + SIGTERM_GRACE_MS;
|
|
while (Date.now() < deadline) {
|
|
if (!isProcessAlive(pid)) return;
|
|
await delay(50);
|
|
}
|
|
if (isProcessAlive(pid) && verifyIdentity(pid, marker || CMDLINE_MARKER)) {
|
|
log(verbose, `pid ${pid} survived SIGTERM; SIGKILL`);
|
|
try {
|
|
process.kill(pid, "SIGKILL");
|
|
} catch {
|
|
// raced with exit
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Public: $D daemon stop. Posts /shutdown if no active boards; otherwise
|
|
* reports refusal. Used by the CLI sub-command (next commit).
|
|
*/
|
|
export async function shutdownDaemon(opts: { force?: boolean } = {}): Promise<{
|
|
stopped: boolean;
|
|
reason?: string;
|
|
activeBoards?: number;
|
|
}> {
|
|
const stateFile = resolveStateFilePath();
|
|
const existing = readStateFile(stateFile);
|
|
if (!existing) return { stopped: false, reason: "no daemon running" };
|
|
const health = await healthCheck(existing.port);
|
|
if (!health) {
|
|
// unresponsive: try SIGTERM via identity-checked path
|
|
await killByPidWithIdentity(existing.pid, existing.cmdlineMarker, true);
|
|
return { stopped: true, reason: "unresponsive daemon killed via SIGTERM" };
|
|
}
|
|
if (health.activeBoards > 0 && !opts.force) {
|
|
return {
|
|
stopped: false,
|
|
reason: "active boards present",
|
|
activeBoards: health.activeBoards,
|
|
};
|
|
}
|
|
await gracefulShutdownExistingDaemon(existing.port);
|
|
// Best-effort: SIGTERM if /shutdown didn't take effect
|
|
if (isProcessAlive(existing.pid)) {
|
|
await killByPidWithIdentity(existing.pid, existing.cmdlineMarker, true);
|
|
}
|
|
return { stopped: true };
|
|
}
|
|
|
|
/** $D daemon status — for the CLI sub-command. */
|
|
export async function daemonStatus(): Promise<
|
|
| { running: false }
|
|
| { running: true; port: number; pid: number; version: string; boards: number; activeBoards: number; uptime: number }
|
|
> {
|
|
const existing = readStateFile();
|
|
if (!existing) return { running: false };
|
|
const h = await healthCheck(existing.port);
|
|
if (!h) return { running: false };
|
|
return {
|
|
running: true,
|
|
port: existing.port,
|
|
pid: existing.pid,
|
|
version: h.version,
|
|
boards: h.boards,
|
|
activeBoards: h.activeBoards,
|
|
uptime: h.uptime,
|
|
};
|
|
}
|