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.
This commit is contained in:
Bee
2026-04-27 19:53:55 -07:00
committed by GitHub
parent 1cb20b5c59
commit c581550157
4 changed files with 46 additions and 46 deletions
+5 -17
View File
@@ -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<void>((resolve) => {
const shutdown = () => resolve();
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
});
await result.server.close();
io.writeln(url);
}),
);
+20 -3
View File
@@ -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",
+7 -20
View File
@@ -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),
+14 -6
View File
@@ -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<string> {
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) {