From c58155015748c1ef3f51827a60e5a7e3885a3854 Mon Sep 17 00:00:00 2001 From: Bee <68532117+abeatrix@users.noreply.github.com> Date: Mon, 27 Apr 2026 19:53:55 -0700 Subject: [PATCH] fix: CLI hub commands to use detached daemon (#267) Fix CLI hub start and ensure so they launch and reuse the detached hub daemon instead of starting an in-process server that dies when the CLI exits. Also make detached hub startup fall back to an ephemeral port when the default port is unavailable, matching the existing in-process fallback behavior. --- sdk/apps/cli/src/commands/hub.ts | 22 ++++------------ sdk/apps/cli/src/commands/schedule.test.ts | 23 ++++++++++++++--- sdk/apps/cli/src/commands/schedule/client.ts | 27 +++++--------------- sdk/packages/core/src/hub/daemon.ts | 20 ++++++++++----- 4 files changed, 46 insertions(+), 46 deletions(-) diff --git a/sdk/apps/cli/src/commands/hub.ts b/sdk/apps/cli/src/commands/hub.ts index 732dc04f02..8ab7be860d 100644 --- a/sdk/apps/cli/src/commands/hub.ts +++ b/sdk/apps/cli/src/commands/hub.ts @@ -1,7 +1,6 @@ import { clearHubDiscovery, - createLocalHubScheduleRuntimeHandlers, - ensureHubServer, + ensureDetachedHubServer, probeHubServer, readHubDiscovery, resolveSharedHubOwnerContext, @@ -71,13 +70,12 @@ export function createHubCommand( port?: number; pathname?: string; }>(); - const result = await ensureHubServer({ + const url = await ensureDetachedHubServer(opts.cwd, { host: opts.host, port: opts.port, pathname: opts.pathname, - runtimeHandlers: createLocalHubScheduleRuntimeHandlers(), }); - io.writeln(result.url); + io.writeln(url); }), ); @@ -89,22 +87,12 @@ export function createHubCommand( port?: number; pathname?: string; }>(); - const result = await ensureHubServer({ + const url = await ensureDetachedHubServer(opts.cwd, { host: opts.host, port: opts.port, pathname: opts.pathname, - runtimeHandlers: createLocalHubScheduleRuntimeHandlers(), }); - io.writeln(result.url); - if (!result.server) { - return; - } - await new Promise((resolve) => { - const shutdown = () => resolve(); - process.once("SIGINT", shutdown); - process.once("SIGTERM", shutdown); - }); - await result.server.close(); + io.writeln(url); }), ); diff --git a/sdk/apps/cli/src/commands/schedule.test.ts b/sdk/apps/cli/src/commands/schedule.test.ts index 382ad4f69d..9086a0e25f 100644 --- a/sdk/apps/cli/src/commands/schedule.test.ts +++ b/sdk/apps/cli/src/commands/schedule.test.ts @@ -13,6 +13,23 @@ vi.mock("@clinebot/core", () => ({ vi.mock("../utils/hub-runtime", () => ({ ensureCliHubServer: mockEnsureCliHubServer, + parseHubEndpointOverride: (rawAddress: string | undefined) => { + const trimmed = rawAddress?.trim(); + if (!trimmed) { + return {}; + } + const parsed = new URL( + trimmed.includes("://") ? trimmed : `ws://${trimmed}`, + ); + return { + host: parsed.hostname || undefined, + port: parsed.port ? Number(parsed.port) : undefined, + pathname: + parsed.pathname && parsed.pathname !== "/" + ? parsed.pathname + : undefined, + }; + }, })); async function runScheduleCommand( @@ -54,7 +71,7 @@ describe("runScheduleCommand list output", () => { expect(errors).toEqual([]); expect(output).toEqual(["No schedules found."]); expect(mockSendHubCommand).toHaveBeenCalledWith( - {}, + { host: "127.0.0.1", port: 25463, pathname: "/hub" }, { clientId: "clite-schedule", command: "schedule.list", @@ -138,7 +155,7 @@ describe("runScheduleCommand import", () => { expect(errors).toEqual([]); expect(output).toEqual(['{\n "scheduleId": "sched_123"\n}']); expect(mockSendHubCommand).toHaveBeenCalledWith( - {}, + { host: "127.0.0.1", port: 25463, pathname: "/hub" }, { clientId: "clite-schedule", command: "schedule.create", @@ -199,7 +216,7 @@ describe("runScheduleCommand export", () => { const written = await readFile(targetPath, "utf8"); expect(written).toBe(JSON.stringify(scheduleRecord, null, 2)); expect(mockSendHubCommand).toHaveBeenCalledWith( - {}, + { host: "127.0.0.1", port: 25463, pathname: "/hub" }, { clientId: "clite-schedule", command: "schedule.get", diff --git a/sdk/apps/cli/src/commands/schedule/client.ts b/sdk/apps/cli/src/commands/schedule/client.ts index 0ca57b6191..dd9ef71a32 100644 --- a/sdk/apps/cli/src/commands/schedule/client.ts +++ b/sdk/apps/cli/src/commands/schedule/client.ts @@ -1,24 +1,10 @@ import { sendHubCommand } from "@clinebot/core"; -import { ensureCliHubServer } from "../../utils/hub-runtime"; +import { + ensureCliHubServer, + parseHubEndpointOverride, +} from "../../utils/hub-runtime"; import type { CommandIo } from "./types"; -export function parseHubAddress(address: string | undefined): { - host?: string; - port?: number; - pathname?: string; -} { - const trimmed = address?.trim(); - if (!trimmed) { - return {}; - } - const [host, portRaw] = trimmed.split(":", 2); - const port = Number.parseInt(portRaw ?? "", 10); - return { - host: host?.trim() || undefined, - port: Number.isInteger(port) ? port : undefined, - }; -} - export class HubScheduleClient { constructor( private readonly endpoint: { @@ -110,8 +96,9 @@ export async function ensureSchedulerHub( client: HubScheduleClient; }> { try { - const endpoint = parseHubAddress(address); - await ensureCliHubServer(workspaceRoot, endpoint); + const requestedEndpoint = parseHubEndpointOverride(address); + const hubUrl = await ensureCliHubServer(workspaceRoot, requestedEndpoint); + const endpoint = parseHubEndpointOverride(hubUrl); return { ok: true, client: new HubScheduleClient(endpoint), diff --git a/sdk/packages/core/src/hub/daemon.ts b/sdk/packages/core/src/hub/daemon.ts index b2be2c76e8..500c7a964c 100644 --- a/sdk/packages/core/src/hub/daemon.ts +++ b/sdk/packages/core/src/hub/daemon.ts @@ -105,6 +105,8 @@ export function prewarmDetachedHubServer( endpoint: HubEndpointOverrides = {}, ): void { const owner = resolveSharedHubOwnerContext(); + const hasExplicitPort = + endpoint.port !== undefined || !!process.env.CLINE_HUB_PORT?.trim(); const resolvedEndpoint = resolveHubEndpointOptions(endpoint); const expectedUrl = createHubServerUrl( resolvedEndpoint.host, @@ -124,10 +126,11 @@ export function prewarmDetachedHubServer( await writeHubDiscovery(owner.discoveryPath, expected); return; } - const spawnEndpoint = - expected?.url && resolvedEndpoint.port !== 0 - ? { ...resolvedEndpoint, port: 0 } - : resolvedEndpoint; + const shouldUseFallbackPort = + !hasExplicitPort && resolvedEndpoint.port !== 0; + const spawnEndpoint = shouldUseFallbackPort + ? { ...resolvedEndpoint, port: 0 } + : resolvedEndpoint; spawnDetachedHubServer(workspaceRoot, spawnEndpoint); }) .catch(() => { @@ -140,6 +143,9 @@ export async function ensureDetachedHubServer( endpointOverrides: HubEndpointOverrides = {}, ): Promise { const owner = resolveSharedHubOwnerContext(); + const hasExplicitPort = + endpointOverrides.port !== undefined || + !!process.env.CLINE_HUB_PORT?.trim(); const endpoint = resolveHubEndpointOptions(endpointOverrides); const expectedUrl = createHubServerUrl( endpoint.host, @@ -158,8 +164,10 @@ export async function ensureDetachedHubServer( await writeHubDiscovery(owner.discoveryPath, expected); return expected.url; } - const spawnEndpoint = - expected?.url && endpoint.port !== 0 ? { ...endpoint, port: 0 } : endpoint; + const shouldUseFallbackPort = !hasExplicitPort && endpoint.port !== 0; + const spawnEndpoint = shouldUseFallbackPort + ? { ...endpoint, port: 0 } + : endpoint; spawnDetachedHubServer(workspaceRoot, spawnEndpoint); const deadline = Date.now() + HUB_STARTUP_TIMEOUT_MS; while (Date.now() < deadline) {