mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-04 09:08:09 +02:00
feat(design): compiled binary self-execs as daemon; unified version lookup
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/<id> (no slash) → 301 Location /boards/<id>/
- 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) <noreply@anthropic.com>
This commit is contained in:
+16
-4
@@ -393,7 +393,19 @@ async function resolveImagePaths(input: string): Promise<string[]> {
|
||||
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
|
||||
// `<this binary> --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);
|
||||
});
|
||||
}
|
||||
|
||||
+39
-15
@@ -37,6 +37,7 @@ import {
|
||||
healthCheck,
|
||||
isProcessAlive,
|
||||
readStateFile,
|
||||
readVersionString,
|
||||
resolveLockFilePath,
|
||||
resolveStartupLogPath,
|
||||
resolveStateFilePath,
|
||||
@@ -216,22 +217,48 @@ export async function publishBoard(opts: PublishBoardOptions): Promise<PublishBo
|
||||
// ─── Internals ───────────────────────────────────────────────────
|
||||
|
||||
function readPackageVersion(): string {
|
||||
// Same lookup the daemon uses, kept independent so client + daemon agree
|
||||
// even when the daemon's resolution path differs (different CWD).
|
||||
try {
|
||||
return fs
|
||||
.readFileSync(path.join(import.meta.dir, "..", "..", "VERSION"), "utf-8")
|
||||
.trim() || "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
return readVersionString();
|
||||
}
|
||||
|
||||
function defaultDaemonScript(): string {
|
||||
// design/src/daemon-client.ts → daemon.ts is a sibling
|
||||
// design/src/daemon-client.ts → daemon.ts is a sibling. Only used in dev
|
||||
// when this process is `bun run cli.ts`; the compiled-binary path
|
||||
// self-execs instead (see resolveSpawnCommand).
|
||||
return path.join(import.meta.dir, "daemon.ts");
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the argv to spawn the daemon. Two modes:
|
||||
*
|
||||
* Compiled binary (`design/dist/design`): re-exec ourselves with
|
||||
* --daemon-mode. process.execPath IS the compiled design binary;
|
||||
* spawning it again with the flag runs the daemon (see the
|
||||
* --daemon-mode branch at the bottom of cli.ts).
|
||||
*
|
||||
* Dev (`bun run design/src/cli.ts`): process.execPath is bun, so we
|
||||
* invoke `bun run <daemon.ts> --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<string, string>;
|
||||
@@ -240,7 +267,6 @@ interface SpawnDaemonOpts {
|
||||
}
|
||||
|
||||
async function spawnDaemon(opts: SpawnDaemonOpts): Promise<number> {
|
||||
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<number> {
|
||||
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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+2
-12
@@ -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 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user