mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-08 11:03:54 +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 { generateDesignToCodePrompt } from "./design-to-code";
|
||||||
import { serve } from "./serve";
|
import { serve } from "./serve";
|
||||||
import { gallery } from "./gallery";
|
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
|
const args = argv.slice(2); // skip bun/node and script path
|
||||||
if (args.length === 0) {
|
if (args.length === 0) {
|
||||||
printUsage();
|
printUsage();
|
||||||
@@ -35,6 +46,7 @@ function parseArgs(argv: string[]): { command: string; flags: Record<string, str
|
|||||||
|
|
||||||
const command = args[0];
|
const command = args[0];
|
||||||
const flags: Record<string, string | boolean> = {};
|
const flags: Record<string, string | boolean> = {};
|
||||||
|
const positionals: string[] = [];
|
||||||
|
|
||||||
for (let i = 1; i < args.length; i++) {
|
for (let i = 1; i < args.length; i++) {
|
||||||
const arg = args[i];
|
const arg = args[i];
|
||||||
@@ -47,10 +59,12 @@ function parseArgs(argv: string[]): { command: string; flags: Record<string, str
|
|||||||
} else {
|
} else {
|
||||||
flags[key] = true;
|
flags[key] = true;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
positionals.push(arg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { command, flags };
|
return { command, flags, positionals };
|
||||||
}
|
}
|
||||||
|
|
||||||
function printUsage(): void {
|
function printUsage(): void {
|
||||||
@@ -108,7 +122,7 @@ async function runSetup(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const { command, flags } = parseArgs(process.argv);
|
const { command, flags, positionals } = parseArgs(process.argv);
|
||||||
|
|
||||||
if (!COMMANDS.has(command)) {
|
if (!COMMANDS.has(command)) {
|
||||||
console.error(`Unknown command: ${command}`);
|
console.error(`Unknown command: ${command}`);
|
||||||
@@ -139,12 +153,24 @@ async function main(): Promise<void> {
|
|||||||
const images = await resolveImagePaths(imagesArg);
|
const images = await resolveImagePaths(imagesArg);
|
||||||
const outputPath = (flags.output as string) || "/tmp/gstack-design-board.html";
|
const outputPath = (flags.output as string) || "/tmp/gstack-design-board.html";
|
||||||
compare({ images, output: outputPath });
|
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) {
|
if (flags.serve) {
|
||||||
await serve({
|
if (flags["no-daemon"]) {
|
||||||
html: outputPath,
|
await serve({
|
||||||
timeout: flags.timeout ? parseInt(flags.timeout as string) : 600,
|
html: outputPath,
|
||||||
});
|
timeout: flags.timeout ? parseInt(flags.timeout as string) : 600,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await publishToDaemon({
|
||||||
|
html: outputPath,
|
||||||
|
title: flags.title as string | undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -247,11 +273,98 @@ async function main(): Promise<void> {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "serve":
|
case "serve":
|
||||||
await serve({
|
if (flags["no-daemon"]) {
|
||||||
html: flags.html as string,
|
await serve({
|
||||||
timeout: flags.timeout ? parseInt(flags.timeout as string) : 600,
|
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;
|
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", {
|
["compare", {
|
||||||
description: "Generate HTML comparison board for user review",
|
description: "Generate HTML comparison board for user review",
|
||||||
usage: "compare --images /path/*.png --output /path/board.html [--serve]",
|
usage: "compare --images /path/*.png --output /path/board.html [--serve [--no-daemon] [--title \"...\"]]",
|
||||||
flags: ["--images", "--output", "--serve", "--timeout"],
|
flags: ["--images", "--output", "--serve", "--no-daemon", "--title", "--timeout"],
|
||||||
}],
|
}],
|
||||||
["diff", {
|
["diff", {
|
||||||
description: "Visual diff between two mockups",
|
description: "Visual diff between two mockups",
|
||||||
@@ -71,8 +71,13 @@ export const COMMANDS = new Map<string, {
|
|||||||
}],
|
}],
|
||||||
["serve", {
|
["serve", {
|
||||||
description: "Serve comparison board over HTTP and collect user feedback",
|
description: "Serve comparison board over HTTP and collect user feedback",
|
||||||
usage: "serve --html /path/board.html [--timeout 600]",
|
usage: "serve --html /path/board.html [--no-daemon] [--title \"...\"] [--timeout 600]",
|
||||||
flags: ["--html", "--timeout"],
|
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", {
|
["setup", {
|
||||||
description: "Guided API key setup + smoke test",
|
description: "Guided API key setup + smoke test",
|
||||||
|
|||||||
Reference in New Issue
Block a user