mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-04 17:18:11 +02:00
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>
This commit is contained in:
+125
-12
@@ -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<string, string | boolean> } {
|
||||
function parseArgs(argv: string[]): {
|
||||
command: string;
|
||||
flags: Record<string, string | boolean>;
|
||||
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<string, str
|
||||
|
||||
const command = args[0];
|
||||
const flags: Record<string, string | boolean> = {};
|
||||
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<string, str
|
||||
} else {
|
||||
flags[key] = true;
|
||||
}
|
||||
} else {
|
||||
positionals.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
return { command, flags };
|
||||
return { command, flags, positionals };
|
||||
}
|
||||
|
||||
function printUsage(): void {
|
||||
@@ -108,7 +122,7 @@ async function runSetup(): Promise<void> {
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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/<id>/"
|
||||
* - "BOARD_URL: <same url>" (alias for grep-friendliness)
|
||||
*/
|
||||
async function publishToDaemon(opts: { html: string; title?: string }): Promise<void> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,8 +36,8 @@ export const COMMANDS = new Map<string, {
|
||||
}],
|
||||
["compare", {
|
||||
description: "Generate HTML comparison board for user review",
|
||||
usage: "compare --images /path/*.png --output /path/board.html [--serve]",
|
||||
flags: ["--images", "--output", "--serve", "--timeout"],
|
||||
usage: "compare --images /path/*.png --output /path/board.html [--serve [--no-daemon] [--title \"...\"]]",
|
||||
flags: ["--images", "--output", "--serve", "--no-daemon", "--title", "--timeout"],
|
||||
}],
|
||||
["diff", {
|
||||
description: "Visual diff between two mockups",
|
||||
@@ -71,8 +71,13 @@ export const COMMANDS = new Map<string, {
|
||||
}],
|
||||
["serve", {
|
||||
description: "Serve comparison board over HTTP and collect user feedback",
|
||||
usage: "serve --html /path/board.html [--timeout 600]",
|
||||
flags: ["--html", "--timeout"],
|
||||
usage: "serve --html /path/board.html [--no-daemon] [--title \"...\"] [--timeout 600]",
|
||||
flags: ["--html", "--no-daemon", "--title", "--timeout"],
|
||||
}],
|
||||
["daemon", {
|
||||
description: "Manage the persistent design board daemon (sub-commands: status, stop)",
|
||||
usage: "daemon status | daemon stop [--force]",
|
||||
flags: ["--force"],
|
||||
}],
|
||||
["setup", {
|
||||
description: "Guided API key setup + smoke test",
|
||||
|
||||
Reference in New Issue
Block a user