From 77b5ad2fa1d63736ece1fbaa57cd954b01f7015b Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 25 May 2026 15:29:59 -0700 Subject: [PATCH] feat(design): compiled binary self-execs as daemon; unified version lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ (no slash) → 301 Location /boards// - 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) --- design/src/cli.ts | 20 +++++++++++--- design/src/daemon-client.ts | 54 ++++++++++++++++++++++++++----------- design/src/daemon-state.ts | 31 +++++++++++++++++++++ design/src/daemon.ts | 14 ++-------- 4 files changed, 88 insertions(+), 31 deletions(-) diff --git a/design/src/cli.ts b/design/src/cli.ts index a5260ed70..247e308cf 100644 --- a/design/src/cli.ts +++ b/design/src/cli.ts @@ -393,7 +393,19 @@ async function resolveImagePaths(input: string): Promise { return input.split(",").map(p => p.trim()); } -main().catch(err => { - console.error(err.message || err); - process.exit(1); -}); +// Self-execution shortcut: when invoked with --daemon-mode, this same +// binary runs as the persistent design daemon instead of the CLI. Keeps +// the production install to a single executable; daemon-client.ts spawns +// ` --daemon-mode` (or `bun run cli.ts --daemon-mode` in dev) +// rather than relying on a separate daemon.ts file at a known path. +if (process.argv.includes("--daemon-mode")) { + const { start } = await import("./daemon"); + start(); + // start() binds Bun.serve and registers signal handlers; this branch + // never falls through to main(). Process stays alive on the bound port. +} else { + main().catch((err) => { + console.error(err.message || err); + process.exit(1); + }); +} diff --git a/design/src/daemon-client.ts b/design/src/daemon-client.ts index a8ed6be9b..427e81282 100644 --- a/design/src/daemon-client.ts +++ b/design/src/daemon-client.ts @@ -37,6 +37,7 @@ import { healthCheck, isProcessAlive, readStateFile, + readVersionString, resolveLockFilePath, resolveStartupLogPath, resolveStateFilePath, @@ -216,22 +217,48 @@ export async function publishBoard(opts: PublishBoardOptions): Promise --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; @@ -240,7 +267,6 @@ interface SpawnDaemonOpts { } async function spawnDaemon(opts: SpawnDaemonOpts): Promise { - const script = opts.script ?? defaultDaemonScript(); const logPath = resolveStartupLogPath(); fs.mkdirSync(path.dirname(logPath), { recursive: true }); // Truncate the startup log on each spawn so a later read finds only THIS @@ -248,11 +274,9 @@ async function spawnDaemon(opts: SpawnDaemonOpts): Promise { fs.writeFileSync(logPath, ""); const logFd = fs.openSync(logPath, "a"); - // CMDLINE_MARKER goes into argv so verifyIdentity can later match it. - // Without this, a future SIGTERM would have no way to confirm pid is ours. - const args = ["run", script, "--marker", CMDLINE_MARKER]; + const { command, args } = resolveSpawnCommand(opts.script); - const child = nodeSpawn("bun", args, { + const child = nodeSpawn(command, args, { detached: true, stdio: ["ignore", logFd, logFd], env: { diff --git a/design/src/daemon-state.ts b/design/src/daemon-state.ts index a6eb83176..bb654d885 100644 --- a/design/src/daemon-state.ts +++ b/design/src/daemon-state.ts @@ -55,6 +55,37 @@ export function resolveStartupLogPath(): string { return path.join(os.homedir(), ".gstack", "design-daemon-startup.log"); } +/** + * Read the gstack version both client and daemon should agree on. Looks + * (in order): DESIGN_DAEMON_VERSION env, design/dist/.version baked at + * build time, VERSION at the source-tree root (dev), then "unknown". + * + * Compiled binaries lose the source-tree relative path at runtime, so we + * try the dist/.version sidecar (which build.sh writes) before falling + * back. This keeps client.expectedVersion and daemon.VERSION coherent. + */ +export function readVersionString(): string { + const env = process.env.DESIGN_DAEMON_VERSION; + if (env) return env; + const candidates = [ + // Compiled binary: design/dist/design lives alongside design/dist/.version + path.join(path.dirname(process.execPath), ".version"), + // Dev: design/src/* → repo root is two levels up + path.join(import.meta.dir, "..", "..", "VERSION"), + // Defensive: design/dist sibling of source tree + path.join(import.meta.dir, "..", "dist", ".version"), + ]; + for (const p of candidates) { + try { + const v = fs.readFileSync(p, "utf-8").trim(); + if (v) return v; + } catch { + // try next + } + } + return "unknown"; +} + export function readStateFile(stateFile: string = resolveStateFilePath()): DaemonState | null { try { return JSON.parse(fs.readFileSync(stateFile, "utf-8")) as DaemonState; diff --git a/design/src/daemon.ts b/design/src/daemon.ts index 6456f6eb2..8b6e4a1ed 100644 --- a/design/src/daemon.ts +++ b/design/src/daemon.ts @@ -33,6 +33,7 @@ import path from "path"; import { CMDLINE_MARKER, DaemonState, + readVersionString, removeStateFile, resolveDaemonLogPath, writeStateFile, @@ -56,18 +57,7 @@ const IDLE_CHECK_INTERVAL_MS = parseInt( ); const MAX_BOARDS = parseInt(process.env.DESIGN_DAEMON_MAX_BOARDS || "50", 10); -const VERSION = process.env.DESIGN_DAEMON_VERSION || readVersion(); - -function readVersion(): string { - try { - // VERSION file lives at the repo root; design/ is one level down. - return fs - .readFileSync(path.join(import.meta.dir, "..", "..", "VERSION"), "utf-8") - .trim() || "unknown"; - } catch { - return "unknown"; - } -} +const VERSION = readVersionString(); // ─── Per-board state ─────────────────────────────────────────────