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 ─────────────────────────────────────────────