From 8d995d8357161096bfa4203b3c0cfd563a3f093b Mon Sep 17 00:00:00 2001 From: Vivaan Dhawan Date: Sat, 23 May 2026 22:16:31 +0530 Subject: [PATCH] fix(cli): discover tools-dev daemon URL --- apps/daemon/src/daemon-url.ts | 67 +++++++++++++++++++++++++++- apps/daemon/tests/daemon-url.test.ts | 32 +++++++++++++ 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/apps/daemon/src/daemon-url.ts b/apps/daemon/src/daemon-url.ts index b8e6116e5..5a16c4c89 100644 --- a/apps/daemon/src/daemon-url.ts +++ b/apps/daemon/src/daemon-url.ts @@ -1,3 +1,6 @@ +import { spawn } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { SIDECAR_ENV, SIDECAR_MESSAGES, @@ -6,6 +9,7 @@ import { import { requestJsonIpc } from "@open-design/sidecar"; export const DEFAULT_DAEMON_URL = "http://127.0.0.1:7456"; +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); export interface ResolveDaemonUrlOptions { /** Value passed via `--daemon-url`. Empty string is treated as unset. */ @@ -21,8 +25,9 @@ export interface ResolveDaemonUrlOptions { * * Spawn order: explicit `--daemon-url` flag, `OD_DAEMON_URL` env, then * a STATUS roundtrip to the concrete sidecar IPC endpoint supplied by - * the lifecycle owner in `OD_SIDECAR_IPC_PATH`. Falls back to the - * legacy default for direct `od` launches that do not run as a sidecar. + * the lifecycle owner in `OD_SIDECAR_IPC_PATH`, then the default + * `tools-dev status --json` runtime. Falls back to the legacy default + * for direct `od` launches that do not run as a sidecar. */ export async function resolveDaemonUrl( options: ResolveDaemonUrlOptions = {}, @@ -34,6 +39,8 @@ export async function resolveDaemonUrl( if (envUrl != null && envUrl.length > 0) return envUrl; const discovered = await discoverDaemonUrlFromIpc(env, options.timeoutMs ?? 800); if (discovered != null) return discovered; + const toolsDevUrl = await discoverDaemonUrlFromToolsDev(env, options.timeoutMs ?? 800); + if (toolsDevUrl != null) return toolsDevUrl; return DEFAULT_DAEMON_URL; } @@ -54,3 +61,59 @@ async function discoverDaemonUrlFromIpc( return null; } } + +async function discoverDaemonUrlFromToolsDev( + env: NodeJS.ProcessEnv, + timeoutMs: number, +): Promise { + return await new Promise((resolve) => { + let child; + try { + child = spawn("pnpm", ["--silent", "exec", "tools-dev", "status", "--json"], { + cwd: REPO_ROOT, + env, + stdio: ["ignore", "pipe", "ignore"], + }); + } catch { + resolve(null); + return; + } + + let settled = false; + let stdout = ""; + const done = (url: string | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(url); + }; + const timer = setTimeout(() => { + child.kill(); + done(null); + }, timeoutMs); + + child.stdout?.on("data", (chunk: Buffer | string) => { + stdout += typeof chunk === "string" ? chunk : chunk.toString("utf8"); + }); + child.on("error", () => done(null)); + child.on("close", (code) => { + done(code === 0 ? extractDaemonUrlFromToolsDevStatus(stdout) : null); + }); + }); +} + +function extractDaemonUrlFromToolsDevStatus(stdout: string): string | null { + for (let i = stdout.indexOf("{"); i !== -1; i = stdout.indexOf("{", i + 1)) { + try { + const parsed = JSON.parse(stdout.slice(i)) as { + apps?: { daemon?: { url?: string | null } }; + url?: string | null; + }; + const url = parsed?.apps?.daemon?.url ?? parsed?.url ?? null; + if (typeof url === "string" && url.length > 0) return url; + } catch { + // pnpm wrappers can print warnings before JSON; continue scanning. + } + } + return null; +} diff --git a/apps/daemon/tests/daemon-url.test.ts b/apps/daemon/tests/daemon-url.test.ts index 11996b103..36764054f 100644 --- a/apps/daemon/tests/daemon-url.test.ts +++ b/apps/daemon/tests/daemon-url.test.ts @@ -12,13 +12,19 @@ import { resolveDaemonUrl, DEFAULT_DAEMON_URL } from "../src/daemon-url.js"; describe("resolveDaemonUrl", () => { let ipcBaseDir: string; + let fakeBinDir: string; + let emptyBinDir: string; beforeAll(() => { ipcBaseDir = fs.mkdtempSync(path.join(os.tmpdir(), "od-mcp-resolve-")); + fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), "od-tools-dev-resolve-")); + emptyBinDir = fs.mkdtempSync(path.join(os.tmpdir(), "od-tools-dev-empty-")); }); afterAll(() => { fs.rmSync(ipcBaseDir, { recursive: true, force: true }); + fs.rmSync(fakeBinDir, { recursive: true, force: true }); + fs.rmSync(emptyBinDir, { recursive: true, force: true }); }); it("prefers the explicit --daemon-url flag", async () => { @@ -45,6 +51,7 @@ describe("resolveDaemonUrl", () => { it("returns the legacy default when no flag/env/socket is available", async () => { const url = await resolveDaemonUrl({ env: { + PATH: emptyBinDir, [SIDECAR_ENV.IPC_PATH]: path.join(ipcBaseDir, "missing.sock"), }, timeoutMs: 200, @@ -52,6 +59,31 @@ describe("resolveDaemonUrl", () => { expect(url).toBe(DEFAULT_DAEMON_URL); }); + it("discovers the default tools-dev daemon URL when no sidecar IPC path is available", async () => { + const pnpmBin = path.join(fakeBinDir, process.platform === "win32" ? "pnpm.cmd" : "pnpm"); + const statusJson = JSON.stringify({ + apps: { + daemon: { + url: "http://127.0.0.1:60123", + }, + }, + }); + if (process.platform === "win32") { + fs.writeFileSync(pnpmBin, `@echo off\r\necho ${statusJson.replace(/"/g, '\\"')}\r\n`); + } else { + fs.writeFileSync(pnpmBin, `#!/bin/sh\nprintf '%s\\n' 'pnpm warning before json'\nprintf '%s\\n' '${statusJson}'\n`); + fs.chmodSync(pnpmBin, 0o755); + } + + const url = await resolveDaemonUrl({ + env: { + PATH: `${fakeBinDir}${path.delimiter}${process.env.PATH ?? ""}`, + }, + timeoutMs: 1000, + }); + expect(url).toBe("http://127.0.0.1:60123"); + }); + it("discovers the live daemon URL via the concrete sidecar IPC status endpoint", async () => { const socketPath = process.platform === "win32" ? `\\\\.\\pipe\\open-design-daemon-url-${process.pid}-${Date.now()}`