From 00fda1e6e6f52e224efcb678685e89b41798a25a Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 25 May 2026 15:27:28 -0700 Subject: [PATCH] feat(design): wire daemon dispatch into CLI; add daemon stop/status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: BOARD_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) --- design/src/cli.ts | 137 +++++++++++++++++++++++++++++++++++++---- design/src/commands.ts | 13 ++-- 2 files changed, 134 insertions(+), 16 deletions(-) diff --git a/design/src/cli.ts b/design/src/cli.ts index 7432c3c2c..a5260ed70 100644 --- a/design/src/cli.ts +++ b/design/src/cli.ts @@ -25,8 +25,19 @@ import { evolve } from "./evolve"; import { generateDesignToCodePrompt } from "./design-to-code"; import { serve } from "./serve"; import { gallery } from "./gallery"; +import { + daemonStatus as daemonStatusClient, + ensureDaemon, + publishBoard, + shutdownDaemon, +} from "./daemon-client"; +import { spawn as nodeSpawn } from "child_process"; -function parseArgs(argv: string[]): { command: string; flags: Record } { +function parseArgs(argv: string[]): { + command: string; + flags: Record; + positionals: string[]; +} { const args = argv.slice(2); // skip bun/node and script path if (args.length === 0) { printUsage(); @@ -35,6 +46,7 @@ function parseArgs(argv: string[]): { command: string; flags: Record = {}; + const positionals: string[] = []; for (let i = 1; i < args.length; i++) { const arg = args[i]; @@ -47,10 +59,12 @@ function parseArgs(argv: string[]): { command: string; flags: Record { } async function main(): Promise { - const { command, flags } = parseArgs(process.argv); + const { command, flags, positionals } = parseArgs(process.argv); if (!COMMANDS.has(command)) { console.error(`Unknown command: ${command}`); @@ -139,12 +153,24 @@ async function main(): Promise { const images = await resolveImagePaths(imagesArg); const outputPath = (flags.output as string) || "/tmp/gstack-design-board.html"; compare({ images, output: outputPath }); - // If --serve flag is set, start HTTP server for the board + // If --serve flag is set, publish the board. + // Default: ensure the persistent daemon is up, POST the board, open + // the browser, exit. The daemon survives the CLI and hosts every + // board the user has published this day at stable URLs. + // --no-daemon: legacy single-process server in serve.ts (kept for + // tests / Windows / explicit debugging). if (flags.serve) { - await serve({ - html: outputPath, - timeout: flags.timeout ? parseInt(flags.timeout as string) : 600, - }); + if (flags["no-daemon"]) { + await serve({ + html: outputPath, + timeout: flags.timeout ? parseInt(flags.timeout as string) : 600, + }); + } else { + await publishToDaemon({ + html: outputPath, + title: flags.title as string | undefined, + }); + } } break; } @@ -247,11 +273,98 @@ async function main(): Promise { break; case "serve": - await serve({ - html: flags.html as string, - timeout: flags.timeout ? parseInt(flags.timeout as string) : 600, - }); + if (flags["no-daemon"]) { + await serve({ + html: flags.html as string, + timeout: flags.timeout ? parseInt(flags.timeout as string) : 600, + }); + } else { + await publishToDaemon({ + html: flags.html as string, + title: flags.title as string | undefined, + }); + } break; + + case "daemon": { + // Sub-commands: `$D daemon status` and `$D daemon stop [--force]`. + const sub = positionals[0] || "status"; + if (sub === "status") { + const s = await daemonStatusClient(); + if (!s.running) { + console.log(JSON.stringify({ running: false }, null, 2)); + process.exit(0); + } + console.log(JSON.stringify(s, null, 2)); + break; + } + if (sub === "stop") { + const r = await shutdownDaemon({ force: !!flags.force }); + if (r.stopped) { + console.log(JSON.stringify({ stopped: true, reason: r.reason }, null, 2)); + process.exit(0); + } + console.error( + `Refused to stop daemon: ${r.reason} (activeBoards=${r.activeBoards ?? 0})`, + ); + console.error( + `Submit/close active boards first, or pass --force to drop in-memory history.`, + ); + process.exit(1); + } + console.error(`Unknown daemon sub-command: ${sub}. Use 'status' or 'stop'.`); + process.exit(2); + } + } +} + +/** + * Default `$D compare --serve` path: ensure the persistent daemon is up, + * publish the board, open the browser to its URL, then exit. The daemon + * survives. + * + * Backward-compatible stderr lines for any external script that scraped the + * old `SERVE_STARTED` output: + * - "DAEMON_ATTACHED port=N" or "DAEMON_STARTED port=N" (one or the other) + * - "BOARD_PUBLISHED: http://127.0.0.1:N/boards//" + * - "BOARD_URL: " (alias for grep-friendliness) + */ +async function publishToDaemon(opts: { html: string; title?: string }): Promise { + if (!opts.html) { + console.error("--html is required (compare --serve provides --output as the html)"); + process.exit(1); + } + const ensured = await ensureDaemon({}); + console.error( + `${ensured.spawned ? "DAEMON_STARTED" : "DAEMON_ATTACHED"} port=${ensured.port} version=${ensured.version}`, + ); + const result = await publishBoard({ + port: ensured.port, + html: opts.html, + title: opts.title, + }); + console.error(`BOARD_PUBLISHED: ${result.url}`); + console.error(`BOARD_URL: ${result.url}`); + console.log(JSON.stringify({ id: result.id, url: result.url, sourceDir: result.sourceDir }, null, 2)); + openBrowser(result.url); + // Short-lived publisher process exits; daemon keeps serving. +} + +/** Open a URL in the default browser. Stays cross-platform with serve.ts. */ +function openBrowser(url: string): void { + const platform = process.platform; + let cmd: string; + if (platform === "darwin") cmd = "open"; + else if (platform === "linux") cmd = "xdg-open"; + else { + console.error(`Open this URL in your browser: ${url}`); + return; + } + try { + const child = nodeSpawn(cmd, [url], { stdio: "ignore", detached: true }); + child.unref(); + } catch { + console.error(`Open this URL in your browser: ${url}`); } } diff --git a/design/src/commands.ts b/design/src/commands.ts index c8331e970..17d5811a7 100644 --- a/design/src/commands.ts +++ b/design/src/commands.ts @@ -36,8 +36,8 @@ export const COMMANDS = new Map