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());
|
return input.split(",").map(p => p.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch(err => {
|
// Self-execution shortcut: when invoked with --daemon-mode, this same
|
||||||
console.error(err.message || err);
|
// binary runs as the persistent design daemon instead of the CLI. Keeps
|
||||||
process.exit(1);
|
// 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,
|
healthCheck,
|
||||||
isProcessAlive,
|
isProcessAlive,
|
||||||
readStateFile,
|
readStateFile,
|
||||||
|
readVersionString,
|
||||||
resolveLockFilePath,
|
resolveLockFilePath,
|
||||||
resolveStartupLogPath,
|
resolveStartupLogPath,
|
||||||
resolveStateFilePath,
|
resolveStateFilePath,
|
||||||
@@ -216,22 +217,48 @@ export async function publishBoard(opts: PublishBoardOptions): Promise<PublishBo
|
|||||||
// ─── Internals ───────────────────────────────────────────────────
|
// ─── Internals ───────────────────────────────────────────────────
|
||||||
|
|
||||||
function readPackageVersion(): string {
|
function readPackageVersion(): string {
|
||||||
// Same lookup the daemon uses, kept independent so client + daemon agree
|
return readVersionString();
|
||||||
// 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";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultDaemonScript(): string {
|
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");
|
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 {
|
interface SpawnDaemonOpts {
|
||||||
script?: string;
|
script?: string;
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
@@ -240,7 +267,6 @@ interface SpawnDaemonOpts {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function spawnDaemon(opts: SpawnDaemonOpts): Promise<number> {
|
async function spawnDaemon(opts: SpawnDaemonOpts): Promise<number> {
|
||||||
const script = opts.script ?? defaultDaemonScript();
|
|
||||||
const logPath = resolveStartupLogPath();
|
const logPath = resolveStartupLogPath();
|
||||||
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
||||||
// Truncate the startup log on each spawn so a later read finds only THIS
|
// 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, "");
|
fs.writeFileSync(logPath, "");
|
||||||
const logFd = fs.openSync(logPath, "a");
|
const logFd = fs.openSync(logPath, "a");
|
||||||
|
|
||||||
// CMDLINE_MARKER goes into argv so verifyIdentity can later match it.
|
const { command, args } = resolveSpawnCommand(opts.script);
|
||||||
// Without this, a future SIGTERM would have no way to confirm pid is ours.
|
|
||||||
const args = ["run", script, "--marker", CMDLINE_MARKER];
|
|
||||||
|
|
||||||
const child = nodeSpawn("bun", args, {
|
const child = nodeSpawn(command, args, {
|
||||||
detached: true,
|
detached: true,
|
||||||
stdio: ["ignore", logFd, logFd],
|
stdio: ["ignore", logFd, logFd],
|
||||||
env: {
|
env: {
|
||||||
|
|||||||
@@ -55,6 +55,37 @@ export function resolveStartupLogPath(): string {
|
|||||||
return path.join(os.homedir(), ".gstack", "design-daemon-startup.log");
|
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 {
|
export function readStateFile(stateFile: string = resolveStateFilePath()): DaemonState | null {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(fs.readFileSync(stateFile, "utf-8")) as DaemonState;
|
return JSON.parse(fs.readFileSync(stateFile, "utf-8")) as DaemonState;
|
||||||
|
|||||||
+2
-12
@@ -33,6 +33,7 @@ import path from "path";
|
|||||||
import {
|
import {
|
||||||
CMDLINE_MARKER,
|
CMDLINE_MARKER,
|
||||||
DaemonState,
|
DaemonState,
|
||||||
|
readVersionString,
|
||||||
removeStateFile,
|
removeStateFile,
|
||||||
resolveDaemonLogPath,
|
resolveDaemonLogPath,
|
||||||
writeStateFile,
|
writeStateFile,
|
||||||
@@ -56,18 +57,7 @@ const IDLE_CHECK_INTERVAL_MS = parseInt(
|
|||||||
);
|
);
|
||||||
const MAX_BOARDS = parseInt(process.env.DESIGN_DAEMON_MAX_BOARDS || "50", 10);
|
const MAX_BOARDS = parseInt(process.env.DESIGN_DAEMON_MAX_BOARDS || "50", 10);
|
||||||
|
|
||||||
const VERSION = process.env.DESIGN_DAEMON_VERSION || readVersion();
|
const VERSION = readVersionString();
|
||||||
|
|
||||||
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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Per-board state ─────────────────────────────────────────────
|
// ─── Per-board state ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user