mirror of
https://github.com/cline/cline.git
synced 2026-09-13 18:10:14 +08:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd5dbb4148 | ||
|
|
3f5715ceb6 | ||
|
|
0c8226c9ba | ||
|
|
686bce7451 | ||
|
|
6362b425d1 | ||
|
|
ad90909ccf | ||
|
|
d86283e09b | ||
|
|
1ff3784b39 | ||
|
|
d279a99436 | ||
|
|
f3679d52f7 | ||
|
|
b8dc65b4fe | ||
|
|
0b36eb53d0 | ||
|
|
e89c6a422b | ||
|
|
dbb6c4ac6b | ||
|
|
9838666a8a |
@@ -0,0 +1,378 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { spawn, coreMocks, buildCliSubcommandCommand } = vi.hoisted(() => ({
|
||||
spawn: vi.fn(() => ({ unref: vi.fn() })),
|
||||
coreMocks: {
|
||||
CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV:
|
||||
"CLINE_HUB_DASHBOARD_DISCOVERY_PATH",
|
||||
clearHubDashboardDiscovery: vi.fn(async () => undefined),
|
||||
ensureDetachedHubServer: vi.fn(async () => ({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "hub-token",
|
||||
})),
|
||||
isHubDashboardPidAlive: vi.fn(
|
||||
(pid: number | undefined) => !!pid && pid > 0,
|
||||
),
|
||||
readHubDashboardDiscovery: vi.fn(),
|
||||
readHubDiscovery: vi.fn(),
|
||||
resolveDefaultHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-shared",
|
||||
discoveryPath: "/tmp/hub.json",
|
||||
})),
|
||||
resolveHubDashboardDiscoveryPath: vi.fn(() => "/tmp/dashboard.json"),
|
||||
resolveProductionHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-production",
|
||||
discoveryPath: "/tmp/hub.json",
|
||||
})),
|
||||
resolveSharedHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-shared",
|
||||
discoveryPath: "/tmp/hub.json",
|
||||
})),
|
||||
stopManagedHubDashboardProcess: vi.fn(async () => false),
|
||||
writeHubDashboardDiscovery: vi.fn(async () => undefined),
|
||||
},
|
||||
buildCliSubcommandCommand: vi.fn(() => ({
|
||||
launcher: "bun",
|
||||
childArgs: ["cline", "dashboard", "serve"],
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", () => ({ spawn }));
|
||||
vi.mock("@cline/core", () => coreMocks);
|
||||
vi.mock("@cline/shared", () => ({
|
||||
resolveClineBuildEnv: () => "development",
|
||||
}));
|
||||
vi.mock("../utils/internal-launch", () => ({ buildCliSubcommandCommand }));
|
||||
|
||||
const originalEnv = {
|
||||
CLINE_HUB_DASHBOARD_LAUNCHER: process.env.CLINE_HUB_DASHBOARD_LAUNCHER,
|
||||
CLINE_HUB_DASHBOARD_ARGS: process.env.CLINE_HUB_DASHBOARD_ARGS,
|
||||
CLINE_HUB_DASHBOARD_DISCOVERY_PATH:
|
||||
process.env.CLINE_HUB_DASHBOARD_DISCOVERY_PATH,
|
||||
};
|
||||
|
||||
function dashboardRecord(pid: number) {
|
||||
return {
|
||||
pid,
|
||||
listenUrl: "http://127.0.0.1:8787/",
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
inviteUrl: "http://127.0.0.1:8787",
|
||||
hubUrl: "ws://127.0.0.1:25463/hub",
|
||||
startedAt: "2026-06-22T20:00:00.000Z",
|
||||
updatedAt: "2026-06-22T20:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("dashboard command lifecycle", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
spawn.mockClear();
|
||||
buildCliSubcommandCommand.mockClear();
|
||||
coreMocks.clearHubDashboardDiscovery.mockClear();
|
||||
coreMocks.ensureDetachedHubServer.mockClear();
|
||||
coreMocks.isHubDashboardPidAlive.mockClear();
|
||||
coreMocks.isHubDashboardPidAlive.mockImplementation(
|
||||
(pid: number | undefined) => !!pid && pid > 0,
|
||||
);
|
||||
coreMocks.readHubDashboardDiscovery.mockReset();
|
||||
coreMocks.readHubDiscovery.mockReset();
|
||||
coreMocks.resolveHubDashboardDiscoveryPath.mockReset();
|
||||
coreMocks.resolveHubDashboardDiscoveryPath.mockReturnValue(
|
||||
"/tmp/dashboard.json",
|
||||
);
|
||||
coreMocks.stopManagedHubDashboardProcess.mockReset();
|
||||
coreMocks.stopManagedHubDashboardProcess.mockResolvedValue(false);
|
||||
coreMocks.writeHubDashboardDiscovery.mockClear();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({ ok: true })),
|
||||
);
|
||||
process.env.CLINE_HUB_DASHBOARD_LAUNCHER = "bun";
|
||||
process.env.CLINE_HUB_DASHBOARD_ARGS = JSON.stringify([
|
||||
"cline",
|
||||
"dashboard",
|
||||
"serve",
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnv.CLINE_HUB_DASHBOARD_LAUNCHER === undefined) {
|
||||
delete process.env.CLINE_HUB_DASHBOARD_LAUNCHER;
|
||||
} else {
|
||||
process.env.CLINE_HUB_DASHBOARD_LAUNCHER =
|
||||
originalEnv.CLINE_HUB_DASHBOARD_LAUNCHER;
|
||||
}
|
||||
if (originalEnv.CLINE_HUB_DASHBOARD_ARGS === undefined) {
|
||||
delete process.env.CLINE_HUB_DASHBOARD_ARGS;
|
||||
} else {
|
||||
process.env.CLINE_HUB_DASHBOARD_ARGS =
|
||||
originalEnv.CLINE_HUB_DASHBOARD_ARGS;
|
||||
}
|
||||
if (originalEnv.CLINE_HUB_DASHBOARD_DISCOVERY_PATH === undefined) {
|
||||
delete process.env.CLINE_HUB_DASHBOARD_DISCOVERY_PATH;
|
||||
} else {
|
||||
process.env.CLINE_HUB_DASHBOARD_DISCOVERY_PATH =
|
||||
originalEnv.CLINE_HUB_DASHBOARD_DISCOVERY_PATH;
|
||||
}
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("waits for a newly started hub-owned dashboard instead of spawning a competing fallback", async () => {
|
||||
const opened: string[] = [];
|
||||
coreMocks.readHubDiscovery
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
pid: 777,
|
||||
startedAt: "2026-06-22T20:00:00.000Z",
|
||||
});
|
||||
coreMocks.readHubDashboardDiscovery
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(dashboardRecord(888));
|
||||
const { runDashboardCommand } = await import("./dashboard");
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
io: {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
openUrl: async (url) => {
|
||||
opened.push(url);
|
||||
},
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(opened).toEqual(["http://127.0.0.1:8787"]);
|
||||
});
|
||||
|
||||
it("waits for the replacement dashboard after the hub process changes", async () => {
|
||||
const opened: string[] = [];
|
||||
const stale = dashboardRecord(888);
|
||||
const replacement = {
|
||||
...dashboardRecord(999),
|
||||
startedAt: "2026-06-22T20:00:01.000Z",
|
||||
updatedAt: "2026-06-22T20:00:01.000Z",
|
||||
};
|
||||
coreMocks.readHubDiscovery
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
pid: 777,
|
||||
startedAt: "2026-06-22T20:00:00.000Z",
|
||||
});
|
||||
coreMocks.readHubDashboardDiscovery
|
||||
.mockResolvedValueOnce(stale)
|
||||
.mockResolvedValueOnce(stale)
|
||||
.mockResolvedValueOnce(stale)
|
||||
.mockResolvedValueOnce(replacement);
|
||||
const { runDashboardCommand } = await import("./dashboard");
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
io: {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
openUrl: async (url) => {
|
||||
opened.push(url);
|
||||
},
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(opened).toEqual(["http://127.0.0.1:8787"]);
|
||||
expect(coreMocks.readHubDashboardDiscovery).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("accepts a healthy dashboard whose optional hub URL is absent", async () => {
|
||||
const existingHub = {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
pid: 777,
|
||||
startedAt: "2026-06-22T20:00:00.000Z",
|
||||
};
|
||||
const dashboardWithoutHubUrl = {
|
||||
...dashboardRecord(888),
|
||||
hubUrl: undefined,
|
||||
};
|
||||
coreMocks.readHubDiscovery
|
||||
.mockResolvedValueOnce(existingHub)
|
||||
.mockResolvedValueOnce(existingHub);
|
||||
coreMocks.readHubDashboardDiscovery
|
||||
.mockResolvedValueOnce(dashboardWithoutHubUrl)
|
||||
.mockResolvedValueOnce(dashboardWithoutHubUrl);
|
||||
const { runDashboardCommand } = await import("./dashboard");
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
io: {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
openUrl: async () => {},
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(coreMocks.stopManagedHubDashboardProcess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back when a newly started hub never publishes a healthy dashboard", async () => {
|
||||
vi.useFakeTimers();
|
||||
coreMocks.readHubDiscovery
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
pid: 777,
|
||||
startedAt: "2026-06-22T20:00:00.000Z",
|
||||
});
|
||||
coreMocks.readHubDashboardDiscovery.mockResolvedValue(undefined);
|
||||
const { runDashboardCommand } = await import("./dashboard");
|
||||
|
||||
const pending = runDashboardCommand({
|
||||
io: {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
openUrl: async () => {},
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(8_000);
|
||||
coreMocks.readHubDashboardDiscovery.mockResolvedValue(dashboardRecord(999));
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
|
||||
await expect(pending).resolves.toBe(0);
|
||||
expect(coreMocks.stopManagedHubDashboardProcess).toHaveBeenCalledTimes(1);
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows CLI fallback only when the hub was already running", async () => {
|
||||
const existingHub = {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
pid: 777,
|
||||
startedAt: "2026-06-22T20:00:00.000Z",
|
||||
};
|
||||
coreMocks.readHubDiscovery
|
||||
.mockResolvedValueOnce(existingHub)
|
||||
.mockResolvedValueOnce(existingHub);
|
||||
coreMocks.readHubDashboardDiscovery
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(dashboardRecord(999));
|
||||
const { runDashboardCommand } = await import("./dashboard");
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
io: {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
openUrl: async () => {},
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(buildCliSubcommandCommand).toHaveBeenCalledWith(
|
||||
"dashboard",
|
||||
["serve"],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not spawn a replacement when the existing dashboard cannot stop", async () => {
|
||||
const existingHub = {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
pid: 777,
|
||||
startedAt: "2026-06-22T20:00:00.000Z",
|
||||
};
|
||||
coreMocks.readHubDiscovery
|
||||
.mockResolvedValueOnce(existingHub)
|
||||
.mockResolvedValueOnce(existingHub);
|
||||
coreMocks.readHubDashboardDiscovery.mockResolvedValueOnce(undefined);
|
||||
coreMocks.stopManagedHubDashboardProcess.mockRejectedValueOnce(
|
||||
new Error("dashboard process did not stop"),
|
||||
);
|
||||
const errors: string[] = [];
|
||||
const { runDashboardCommand } = await import("./dashboard");
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
io: {
|
||||
writeln: () => {},
|
||||
writeErr: (message) => errors.push(message),
|
||||
},
|
||||
openUrl: async () => {},
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(errors).toEqual(["dashboard process did not stop"]);
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("writes and clears serve discovery inside the sandbox environment", async () => {
|
||||
const observedDataDirs: Array<string | undefined> = [];
|
||||
coreMocks.resolveHubDashboardDiscoveryPath.mockImplementation(() => {
|
||||
observedDataDirs.push(process.env.CLINE_DATA_DIR);
|
||||
return "/tmp/dashboard.json";
|
||||
});
|
||||
const { runDashboardCommand } = await import("./dashboard");
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
action: "serve",
|
||||
cwd: "/tmp/dashboard-serve-cwd",
|
||||
dataDir: "/tmp/dashboard-serve-data",
|
||||
io: {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
startServer: async () => ({
|
||||
listenUrl: "http://127.0.0.1:8787/",
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
inviteUrl: "http://127.0.0.1:8787",
|
||||
stop: vi.fn(),
|
||||
}),
|
||||
waitForShutdown: async () => {},
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(observedDataDirs).toEqual([
|
||||
"/tmp/dashboard-serve-data",
|
||||
"/tmp/dashboard-serve-data",
|
||||
]);
|
||||
expect(coreMocks.writeHubDashboardDiscovery).toHaveBeenCalledWith(
|
||||
"/tmp/dashboard.json",
|
||||
expect.objectContaining({
|
||||
listenUrl: "http://127.0.0.1:8787/",
|
||||
}),
|
||||
);
|
||||
expect(coreMocks.clearHubDashboardDiscovery).toHaveBeenCalledWith(
|
||||
"/tmp/dashboard.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("stops the serve process when dashboard discovery cannot be written", async () => {
|
||||
coreMocks.writeHubDashboardDiscovery.mockRejectedValueOnce(
|
||||
new Error("discovery write failed"),
|
||||
);
|
||||
const stop = vi.fn(async () => undefined);
|
||||
const errors: string[] = [];
|
||||
const { runDashboardCommand } = await import("./dashboard");
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
action: "serve",
|
||||
io: {
|
||||
writeln: () => {},
|
||||
writeErr: (message) => errors.push(message),
|
||||
},
|
||||
startServer: async () => ({
|
||||
listenUrl: "http://127.0.0.1:8787/",
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
inviteUrl: "http://127.0.0.1:8787",
|
||||
stop,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(errors).toEqual(["discovery write failed"]);
|
||||
expect(stop).toHaveBeenCalledOnce();
|
||||
expect(coreMocks.clearHubDashboardDiscovery).toHaveBeenCalledWith(
|
||||
"/tmp/dashboard.json",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,8 @@ const ENV_KEYS = [
|
||||
"PUBLIC_URL",
|
||||
"ROOM_SECRET",
|
||||
"CLINE_HUB_WEBVIEW_DIST_DIR",
|
||||
"CLINE_HUB_DASHBOARD_WEB_URL",
|
||||
"CLINE_HUB_DASHBOARD_DISCOVERY_PATH",
|
||||
"CLINE_WRAPPER_PATH",
|
||||
] as const;
|
||||
|
||||
@@ -39,10 +41,9 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("runDashboardCommand", () => {
|
||||
it("starts the dashboard server, opens the invite URL, and waits for shutdown", async () => {
|
||||
it("serves the dashboard in the foreground and waits for shutdown", async () => {
|
||||
const output: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const opened: string[] = [];
|
||||
const stop = vi.fn();
|
||||
let observedEnv:
|
||||
| {
|
||||
@@ -55,13 +56,16 @@ describe("runDashboardCommand", () => {
|
||||
publicUrl: string | undefined;
|
||||
roomSecret: string | undefined;
|
||||
webviewDistDir: string | undefined;
|
||||
dashboardWebUrl: string | undefined;
|
||||
}
|
||||
| undefined;
|
||||
const webviewDistDir = mkdtempSync(join(tmpdir(), "cline-webview-dist-"));
|
||||
mkdirSync(webviewDistDir, { recursive: true });
|
||||
process.env.CLINE_HUB_WEBVIEW_DIST_DIR = webviewDistDir;
|
||||
delete process.env.CLINE_HUB_DASHBOARD_WEB_URL;
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
action: "serve",
|
||||
configDir: "/tmp/cline-config",
|
||||
cwd: "sdk",
|
||||
dataDir: ".cline-dashboard-data",
|
||||
@@ -84,6 +88,7 @@ describe("runDashboardCommand", () => {
|
||||
publicUrl: process.env.PUBLIC_URL,
|
||||
roomSecret: process.env.ROOM_SECRET,
|
||||
webviewDistDir: process.env.CLINE_HUB_WEBVIEW_DIST_DIR,
|
||||
dashboardWebUrl: process.env.CLINE_HUB_DASHBOARD_WEB_URL,
|
||||
};
|
||||
return {
|
||||
listenUrl: "http://127.0.0.1:9090/",
|
||||
@@ -93,9 +98,6 @@ describe("runDashboardCommand", () => {
|
||||
stop,
|
||||
};
|
||||
},
|
||||
openUrl: async (url) => {
|
||||
opened.push(url);
|
||||
},
|
||||
waitForShutdown: async (server) => {
|
||||
await server.stop();
|
||||
},
|
||||
@@ -116,8 +118,8 @@ describe("runDashboardCommand", () => {
|
||||
publicUrl: "http://127.0.0.1:9090",
|
||||
roomSecret: "secret",
|
||||
webviewDistDir,
|
||||
dashboardWebUrl: undefined,
|
||||
});
|
||||
expect(opened).toEqual(["http://127.0.0.1:9090/?roomSecret=secret"]);
|
||||
expect(stop).toHaveBeenCalledTimes(1);
|
||||
expect(output.join("\n")).toContain("Cline dashboard listening at");
|
||||
expect(output.join("\n")).toContain("ws://127.0.0.1:25463/hub");
|
||||
@@ -126,6 +128,42 @@ describe("runDashboardCommand", () => {
|
||||
expect(process.env.CLINE_HUB_WEBVIEW_DIST_DIR).toBe(webviewDistDir);
|
||||
});
|
||||
|
||||
it("opens a detached dashboard and exits without waiting for shutdown", async () => {
|
||||
const output: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const opened: string[] = [];
|
||||
const ensureDashboard = vi.fn(async () => ({
|
||||
pid: 123,
|
||||
listenUrl: "http://127.0.0.1:8787/",
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
inviteUrl: "http://127.0.0.1:8787",
|
||||
hubUrl: "ws://127.0.0.1:25463/hub",
|
||||
startedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
const waitForShutdown = vi.fn();
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
io: {
|
||||
writeln: (text) => output.push(text ?? ""),
|
||||
writeErr: (text) => errors.push(text),
|
||||
},
|
||||
ensureDashboard,
|
||||
openUrl: async (url) => {
|
||||
opened.push(url);
|
||||
},
|
||||
waitForShutdown,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(ensureDashboard).toHaveBeenCalledTimes(1);
|
||||
expect(waitForShutdown).not.toHaveBeenCalled();
|
||||
expect(opened).toEqual(["http://127.0.0.1:8787"]);
|
||||
expect(output.join("\n")).toContain("Cline dashboard listening at");
|
||||
expect(output.join("\n")).toContain("ws://127.0.0.1:25463/hub");
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("honors --no-open behavior", async () => {
|
||||
const openUrl = vi.fn();
|
||||
|
||||
@@ -135,20 +173,69 @@ describe("runDashboardCommand", () => {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
startServer: async () => ({
|
||||
ensureDashboard: async () => ({
|
||||
pid: 123,
|
||||
listenUrl: "http://127.0.0.1:8787/",
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
inviteUrl: "http://127.0.0.1:8787",
|
||||
stop: vi.fn(),
|
||||
startedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
openUrl,
|
||||
waitForShutdown: async () => {},
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(openUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops a detached dashboard", async () => {
|
||||
const output: string[] = [];
|
||||
const stopDashboard = vi.fn(async () => true);
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
action: "stop",
|
||||
io: {
|
||||
writeln: (text) => output.push(text ?? ""),
|
||||
writeErr: () => {},
|
||||
},
|
||||
stopDashboard,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stopDashboard).toHaveBeenCalledTimes(1);
|
||||
expect(output).toEqual([JSON.stringify({ stopped: true })]);
|
||||
});
|
||||
|
||||
it("restarts a detached dashboard before opening it", async () => {
|
||||
const stopDashboard = vi.fn(async () => true);
|
||||
const ensureDashboard = vi.fn(async () => ({
|
||||
pid: 456,
|
||||
listenUrl: "http://127.0.0.1:8787/",
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
inviteUrl: "http://127.0.0.1:8787",
|
||||
startedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
action: "restart",
|
||||
io: {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
stopDashboard,
|
||||
ensureDashboard,
|
||||
openUrl: async () => {},
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stopDashboard).toHaveBeenCalledTimes(1);
|
||||
expect(ensureDashboard).toHaveBeenCalledTimes(1);
|
||||
expect(stopDashboard.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
ensureDashboard.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("finds webview assets from the published wrapper package layout", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "cline-wrapper-layout-"));
|
||||
const wrapperPath = join(root, "node_modules", "cline", "bin", "cline");
|
||||
@@ -170,6 +257,7 @@ describe("runDashboardCommand", () => {
|
||||
let observedWebviewDistDir: string | undefined;
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
action: "serve",
|
||||
openBrowser: false,
|
||||
io: {
|
||||
writeln: () => {},
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { arch, platform } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV,
|
||||
clearHubDashboardDiscovery,
|
||||
ensureDetachedHubServer,
|
||||
type HubDashboardDiscoveryRecord,
|
||||
type HubServerDiscoveryRecord,
|
||||
isHubDashboardPidAlive,
|
||||
readHubDashboardDiscovery,
|
||||
readHubDiscovery,
|
||||
resolveDefaultHubOwnerContext,
|
||||
resolveHubDashboardDiscoveryPath,
|
||||
stopManagedHubDashboardProcess,
|
||||
writeHubDashboardDiscovery,
|
||||
} from "@cline/core";
|
||||
import open from "open";
|
||||
import { configureSandboxEnvironment } from "../utils/helpers";
|
||||
import { buildCliSubcommandCommand } from "../utils/internal-launch";
|
||||
import { c } from "../utils/output";
|
||||
|
||||
export interface DashboardServerHandle {
|
||||
@@ -20,6 +36,7 @@ interface DashboardCommandIo {
|
||||
}
|
||||
|
||||
export interface RunDashboardCommandOptions {
|
||||
action?: "open" | "restart" | "serve" | "stop";
|
||||
configDir?: string;
|
||||
cwd?: string;
|
||||
dataDir?: string;
|
||||
@@ -30,12 +47,20 @@ export interface RunDashboardCommandOptions {
|
||||
openBrowser?: boolean;
|
||||
io: DashboardCommandIo;
|
||||
startServer?: () => Promise<DashboardServerHandle>;
|
||||
ensureDashboard?: () => Promise<HubDashboardDiscoveryRecord>;
|
||||
stopDashboard?: () => Promise<boolean>;
|
||||
openUrl?: (url: string) => Promise<void>;
|
||||
waitForShutdown?: (server: DashboardServerHandle) => Promise<void>;
|
||||
}
|
||||
|
||||
const DASHBOARD_PORT_ENV = "CLINE_HUB_DASHBOARD_PORT";
|
||||
const WEBVIEW_DIST_ENV = "CLINE_HUB_WEBVIEW_DIST_DIR";
|
||||
const DASHBOARD_WEB_URL_ENV = "CLINE_HUB_DASHBOARD_WEB_URL";
|
||||
const DEFAULT_HOSTED_DASHBOARD_WEB_URL = "https://cline.bot/dashboard";
|
||||
const DASHBOARD_STARTUP_TIMEOUT_MS = 8_000;
|
||||
const DASHBOARD_STARTUP_POLL_MS = 200;
|
||||
const DASHBOARD_LAUNCHER_ENV = "CLINE_HUB_DASHBOARD_LAUNCHER";
|
||||
const DASHBOARD_ARGS_ENV = "CLINE_HUB_DASHBOARD_ARGS";
|
||||
|
||||
function setEnvValue(name: string, value: string | undefined): () => void {
|
||||
const previous = process.env[name];
|
||||
@@ -67,14 +92,20 @@ async function withDashboardEnvironment<T>(
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const cwd = options.cwd ? resolve(options.cwd) : process.cwd();
|
||||
const webviewDistDir = resolveDefaultWebviewDistDir();
|
||||
const restore = [
|
||||
setEnvValue("WORKSPACE_ROOT", options.cwd ? cwd : undefined),
|
||||
setEnvValue("CLINE_DIR", options.configDir?.trim() || undefined),
|
||||
setEnvValue("HOST", options.host),
|
||||
setEnvValue(DASHBOARD_PORT_ENV, options.port),
|
||||
setEnvValue("PUBLIC_URL", options.publicUrl),
|
||||
setEnvValue(
|
||||
DASHBOARD_WEB_URL_ENV,
|
||||
process.env[DASHBOARD_WEB_URL_ENV]?.trim() ||
|
||||
(webviewDistDir ? undefined : DEFAULT_HOSTED_DASHBOARD_WEB_URL),
|
||||
),
|
||||
setEnvValue("ROOM_SECRET", options.roomSecret),
|
||||
setEnvValue(WEBVIEW_DIST_ENV, resolveDefaultWebviewDistDir()),
|
||||
setEnvValue(WEBVIEW_DIST_ENV, webviewDistDir),
|
||||
...SANDBOX_ENV_KEYS.map((key) => setEnvValue(key, undefined)),
|
||||
];
|
||||
if (options.dataDir || process.env.CLINE_SANDBOX?.trim() === "1") {
|
||||
@@ -94,8 +125,9 @@ async function withDashboardEnvironment<T>(
|
||||
}
|
||||
|
||||
function resolveDefaultWebviewDistDir(): string | undefined {
|
||||
if (process.env[WEBVIEW_DIST_ENV]?.trim()) {
|
||||
return undefined;
|
||||
const configuredWebviewDistDir = process.env[WEBVIEW_DIST_ENV]?.trim();
|
||||
if (configuredWebviewDistDir) {
|
||||
return configuredWebviewDistDir;
|
||||
}
|
||||
|
||||
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -149,6 +181,187 @@ async function openDefaultUrl(url: string): Promise<void> {
|
||||
await open(url, { wait: false });
|
||||
}
|
||||
|
||||
function resolveDashboardDiscoveryPath(): string {
|
||||
return (
|
||||
process.env[CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV]?.trim() ||
|
||||
resolveHubDashboardDiscoveryPath(resolveDefaultHubOwnerContext())
|
||||
);
|
||||
}
|
||||
|
||||
async function isDashboardHealthy(
|
||||
record: HubDashboardDiscoveryRecord,
|
||||
): Promise<boolean> {
|
||||
if (!isHubDashboardPidAlive(record.pid)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(new URL("/health", record.listenUrl));
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function stopDefaultDashboard(): Promise<boolean> {
|
||||
return await stopManagedHubDashboardProcess(resolveDashboardDiscoveryPath());
|
||||
}
|
||||
|
||||
function spawnDetachedDashboardServer(cwd: string): void {
|
||||
const command = buildCliSubcommandCommand("dashboard", ["serve"], { cwd });
|
||||
if (!command) {
|
||||
throw new Error("unable to resolve CLI command for dashboard process");
|
||||
}
|
||||
const child = spawn(command.launcher, command.childArgs, {
|
||||
cwd,
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
env: {
|
||||
...process.env,
|
||||
CLINE_NO_INTERACTIVE: "1",
|
||||
[CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV]: resolveDashboardDiscoveryPath(),
|
||||
},
|
||||
windowsHide: true,
|
||||
});
|
||||
child.unref();
|
||||
}
|
||||
|
||||
async function waitForDashboardDiscovery(
|
||||
discoveryPath: string,
|
||||
options: {
|
||||
timeoutMs?: number;
|
||||
acceptRecord?: (record: HubDashboardDiscoveryRecord) => boolean;
|
||||
} = {},
|
||||
): Promise<HubDashboardDiscoveryRecord> {
|
||||
const timeoutMs = options.timeoutMs ?? DASHBOARD_STARTUP_TIMEOUT_MS;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const discovered = await readHubDashboardDiscovery(discoveryPath);
|
||||
if (
|
||||
discovered &&
|
||||
(options.acceptRecord?.(discovered) ?? true) &&
|
||||
(await isDashboardHealthy(discovered))
|
||||
) {
|
||||
return discovered;
|
||||
}
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, DASHBOARD_STARTUP_POLL_MS),
|
||||
);
|
||||
}
|
||||
throw new Error("Timed out waiting for dashboard startup.");
|
||||
}
|
||||
|
||||
function hasHubManagedDashboardLaunchSpec(): boolean {
|
||||
return Boolean(
|
||||
process.env[DASHBOARD_LAUNCHER_ENV]?.trim() &&
|
||||
process.env[DASHBOARD_ARGS_ENV]?.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
function isSameHubProcess(
|
||||
before: HubServerDiscoveryRecord | undefined,
|
||||
after: HubServerDiscoveryRecord | undefined,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
before &&
|
||||
after &&
|
||||
before.url === after.url &&
|
||||
before.pid === after.pid &&
|
||||
before.startedAt === after.startedAt,
|
||||
);
|
||||
}
|
||||
|
||||
function isSameDashboardProcess(
|
||||
before: HubDashboardDiscoveryRecord | undefined,
|
||||
after: HubDashboardDiscoveryRecord,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
before && before.pid === after.pid && before.startedAt === after.startedAt,
|
||||
);
|
||||
}
|
||||
|
||||
function isExpectedDashboard(
|
||||
record: HubDashboardDiscoveryRecord,
|
||||
options: {
|
||||
hubChanged: boolean;
|
||||
hubUrl?: string;
|
||||
previousDashboard?: HubDashboardDiscoveryRecord;
|
||||
},
|
||||
): boolean {
|
||||
if (
|
||||
options.hubChanged &&
|
||||
isSameDashboardProcess(options.previousDashboard, record)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (options.hubUrl && record.hubUrl && record.hubUrl !== options.hubUrl) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function ensureDefaultDashboard(
|
||||
options: RunDashboardCommandOptions,
|
||||
): Promise<HubDashboardDiscoveryRecord> {
|
||||
return await withDashboardEnvironment(options, async () => {
|
||||
const cwd = options.cwd ? resolve(options.cwd) : process.cwd();
|
||||
const owner = resolveDefaultHubOwnerContext();
|
||||
const discoveryPath = resolveDashboardDiscoveryPath();
|
||||
const hubBefore = await readHubDiscovery(owner.discoveryPath);
|
||||
const dashboardBefore = await readHubDashboardDiscovery(discoveryPath);
|
||||
await ensureDetachedHubServer(cwd);
|
||||
const hubAfter = await readHubDiscovery(owner.discoveryPath);
|
||||
const hubChanged = !isSameHubProcess(hubBefore, hubAfter);
|
||||
const acceptRecord = (record: HubDashboardDiscoveryRecord) =>
|
||||
isExpectedDashboard(record, {
|
||||
hubChanged,
|
||||
hubUrl: hubAfter?.url,
|
||||
previousDashboard: dashboardBefore,
|
||||
});
|
||||
const discovered = await readHubDashboardDiscovery(discoveryPath);
|
||||
if (
|
||||
discovered &&
|
||||
acceptRecord(discovered) &&
|
||||
(await isDashboardHealthy(discovered))
|
||||
) {
|
||||
return discovered;
|
||||
}
|
||||
if (hasHubManagedDashboardLaunchSpec() && hubChanged) {
|
||||
try {
|
||||
return await waitForDashboardDiscovery(discoveryPath, {
|
||||
acceptRecord,
|
||||
});
|
||||
} catch {
|
||||
// A healthy hub may outlive a failed dashboard child. Fall back to
|
||||
// the CLI-managed child after the hub startup window has elapsed.
|
||||
}
|
||||
}
|
||||
await stopDefaultDashboard();
|
||||
spawnDetachedDashboardServer(cwd);
|
||||
return await waitForDashboardDiscovery(discoveryPath, { acceptRecord });
|
||||
});
|
||||
}
|
||||
|
||||
async function writeDashboardDiscovery(
|
||||
server: DashboardServerHandle,
|
||||
): Promise<void> {
|
||||
const timestamp = new Date().toISOString();
|
||||
await writeHubDashboardDiscovery(resolveDashboardDiscoveryPath(), {
|
||||
pid: process.pid,
|
||||
listenUrl: server.listenUrl,
|
||||
publicUrl: server.publicUrl,
|
||||
inviteUrl: server.inviteUrl,
|
||||
hubUrl: server.hubUrl,
|
||||
startedAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
async function clearDashboardDiscovery(): Promise<void> {
|
||||
await clearHubDashboardDiscovery(resolveDashboardDiscoveryPath()).catch(
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export function waitForProcessShutdown(
|
||||
server: DashboardServerHandle,
|
||||
): Promise<void> {
|
||||
@@ -181,32 +394,86 @@ export function waitForProcessShutdown(
|
||||
});
|
||||
}
|
||||
|
||||
async function runDashboardServeCommand(
|
||||
options: RunDashboardCommandOptions,
|
||||
): Promise<number> {
|
||||
return await withDashboardEnvironment(options, async () => {
|
||||
const server = await (options.startServer ?? startDefaultDashboardServer)();
|
||||
try {
|
||||
await writeDashboardDiscovery(server);
|
||||
const dashboardUrl =
|
||||
server.inviteUrl || server.publicUrl || server.listenUrl;
|
||||
options.io.writeln(
|
||||
`${c.green}Cline dashboard listening at${c.reset} ${dashboardUrl}`,
|
||||
);
|
||||
if (server.hubUrl) {
|
||||
options.io.writeln(`${c.dim}Hub endpoint: ${server.hubUrl}${c.reset}`);
|
||||
}
|
||||
await (options.waitForShutdown ?? waitForProcessShutdown)(server);
|
||||
} catch (error) {
|
||||
try {
|
||||
await server.stop();
|
||||
} catch {
|
||||
// Preserve the original serve failure after best-effort cleanup.
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await clearDashboardDiscovery();
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
async function openDashboardUrl(
|
||||
options: RunDashboardCommandOptions,
|
||||
record: Pick<
|
||||
HubDashboardDiscoveryRecord,
|
||||
"inviteUrl" | "publicUrl" | "listenUrl"
|
||||
>,
|
||||
): Promise<void> {
|
||||
const dashboardUrl = record.inviteUrl || record.publicUrl || record.listenUrl;
|
||||
options.io.writeln(
|
||||
`${c.green}Cline dashboard listening at${c.reset} ${dashboardUrl}`,
|
||||
);
|
||||
if (options.openBrowser !== false) {
|
||||
try {
|
||||
await (options.openUrl ?? openDefaultUrl)(dashboardUrl);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.io.writeErr(`Failed to open browser: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runDashboardCommand(
|
||||
options: RunDashboardCommandOptions,
|
||||
): Promise<number> {
|
||||
try {
|
||||
const server = await withDashboardEnvironment(options, () =>
|
||||
(options.startServer ?? startDefaultDashboardServer)(),
|
||||
);
|
||||
const dashboardUrl =
|
||||
server.inviteUrl || server.publicUrl || server.listenUrl;
|
||||
options.io.writeln(
|
||||
`${c.green}Cline dashboard listening at${c.reset} ${dashboardUrl}`,
|
||||
);
|
||||
if (server.hubUrl) {
|
||||
options.io.writeln(`${c.dim}Hub endpoint: ${server.hubUrl}${c.reset}`);
|
||||
const action = options.action ?? "open";
|
||||
if (action === "serve") {
|
||||
return await runDashboardServeCommand(options);
|
||||
}
|
||||
|
||||
if (options.openBrowser !== false) {
|
||||
try {
|
||||
await (options.openUrl ?? openDefaultUrl)(dashboardUrl);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.io.writeErr(`Failed to open browser: ${message}`);
|
||||
if (action === "stop") {
|
||||
const stopped = options.stopDashboard
|
||||
? await options.stopDashboard()
|
||||
: await withDashboardEnvironment(options, stopDefaultDashboard);
|
||||
options.io.writeln(JSON.stringify({ stopped }));
|
||||
return 0;
|
||||
}
|
||||
if (action === "restart") {
|
||||
if (options.stopDashboard) {
|
||||
await options.stopDashboard();
|
||||
} else {
|
||||
await withDashboardEnvironment(options, stopDefaultDashboard);
|
||||
}
|
||||
}
|
||||
|
||||
await (options.waitForShutdown ?? waitForProcessShutdown)(server);
|
||||
const record = await (
|
||||
options.ensureDashboard ?? (() => ensureDefaultDashboard(options))
|
||||
)();
|
||||
await openDashboardUrl(options, record);
|
||||
if (record.hubUrl) {
|
||||
options.io.writeln(`${c.dim}Hub endpoint: ${record.hubUrl}${c.reset}`);
|
||||
}
|
||||
return 0;
|
||||
} catch (error) {
|
||||
options.io.writeErr(error instanceof Error ? error.message : String(error));
|
||||
|
||||
@@ -15,6 +15,7 @@ import { getCliBuildInfo } from "../utils/common";
|
||||
const {
|
||||
mockSpawnSync,
|
||||
mockResolveClineDataDir,
|
||||
mockResolveDefaultHubOwnerContext,
|
||||
mockResolveProductionHubOwnerContext,
|
||||
mockResolveSharedHubOwnerContext,
|
||||
mockReadHubDiscovery,
|
||||
@@ -26,6 +27,16 @@ const {
|
||||
} = vi.hoisted(() => ({
|
||||
mockSpawnSync: vi.fn(),
|
||||
mockResolveClineDataDir: vi.fn(() => "/tmp/cline-data"),
|
||||
mockResolveDefaultHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-owner",
|
||||
discoveryPath: path.join(
|
||||
"/tmp/cline-data",
|
||||
"locks",
|
||||
"hub",
|
||||
"owners",
|
||||
"hub-owner.json",
|
||||
),
|
||||
})),
|
||||
mockResolveProductionHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-production",
|
||||
discoveryPath: path.join(
|
||||
@@ -63,6 +74,7 @@ vi.mock("node:child_process", () => ({
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
resolveClineDataDir: mockResolveClineDataDir,
|
||||
resolveDefaultHubOwnerContext: mockResolveDefaultHubOwnerContext,
|
||||
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
|
||||
clearHubDiscovery: mockClearHubDiscovery,
|
||||
@@ -97,6 +109,16 @@ describe("runDoctorCommand", () => {
|
||||
"production.json",
|
||||
),
|
||||
});
|
||||
mockResolveDefaultHubOwnerContext.mockReturnValue({
|
||||
ownerId: "hub-owner",
|
||||
discoveryPath: path.join(
|
||||
"/tmp/cline-data",
|
||||
"locks",
|
||||
"hub",
|
||||
"owners",
|
||||
"hub-owner.json",
|
||||
),
|
||||
});
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(false);
|
||||
mockStopAllConnectors.mockResolvedValue({
|
||||
stoppedProcesses: 0,
|
||||
@@ -217,6 +239,10 @@ describe("runDoctorCommand", () => {
|
||||
ownerId: "hub-owner",
|
||||
discoveryPath,
|
||||
});
|
||||
mockResolveDefaultHubOwnerContext.mockReturnValue({
|
||||
ownerId: "hub-owner",
|
||||
discoveryPath,
|
||||
});
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
port: 25463,
|
||||
|
||||
@@ -7,11 +7,10 @@ import {
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
resolveClineDataDir,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
resolveDefaultHubOwnerContext,
|
||||
stopLocalHubServerGracefully,
|
||||
} from "@cline/core";
|
||||
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { formatUptime } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import open from "open";
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
@@ -320,9 +319,7 @@ function formatHubUptimeFromStartedAt(
|
||||
}
|
||||
|
||||
function resolveCliHubOwnerContext() {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
return resolveDefaultHubOwnerContext();
|
||||
}
|
||||
|
||||
async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
|
||||
@@ -469,9 +466,9 @@ export async function runDoctorCommand(
|
||||
}
|
||||
|
||||
const gracefullyStoppedHub = before.hubHealthy
|
||||
? await stopLocalHubServerGracefully(resolveCliHubOwnerContext()).catch(
|
||||
() => false,
|
||||
)
|
||||
? await stopLocalHubServerGracefully({
|
||||
owner: resolveCliHubOwnerContext(),
|
||||
}).catch(() => false)
|
||||
: false;
|
||||
const refreshedAfterGracefulStop = gracefullyStoppedHub
|
||||
? await collectDoctorStatus(opts.cwd)
|
||||
|
||||
@@ -5,6 +5,7 @@ const {
|
||||
mockEnsureDetachedHubServer,
|
||||
mockProbeHubServer,
|
||||
mockReadHubDiscovery,
|
||||
mockResolveDefaultHubOwnerContext,
|
||||
mockResolveProductionHubOwnerContext,
|
||||
mockResolveSharedHubOwnerContext,
|
||||
mockStopLocalHubServerGracefully,
|
||||
@@ -13,6 +14,10 @@ const {
|
||||
mockEnsureDetachedHubServer: vi.fn(),
|
||||
mockProbeHubServer: vi.fn(),
|
||||
mockReadHubDiscovery: vi.fn(),
|
||||
mockResolveDefaultHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-owner",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/owners/hub-owner.json",
|
||||
})),
|
||||
mockResolveProductionHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-production",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
|
||||
@@ -29,6 +34,7 @@ vi.mock("@cline/core", () => ({
|
||||
ensureDetachedHubServer: mockEnsureDetachedHubServer,
|
||||
probeHubServer: mockProbeHubServer,
|
||||
readHubDiscovery: mockReadHubDiscovery,
|
||||
resolveDefaultHubOwnerContext: mockResolveDefaultHubOwnerContext,
|
||||
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
|
||||
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
|
||||
@@ -122,8 +128,10 @@ describe("createHubCommand", () => {
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(mockStopLocalHubServerGracefully).toHaveBeenCalledWith({
|
||||
ownerId: "hub-owner",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/owners/hub-owner.json",
|
||||
owner: {
|
||||
ownerId: "hub-owner",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/owners/hub-owner.json",
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(output[0] || "")).toEqual({ stopped: true });
|
||||
});
|
||||
|
||||
@@ -3,11 +3,10 @@ import {
|
||||
ensureDetachedHubServer,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
resolveDefaultHubOwnerContext,
|
||||
stopLocalHubServerGracefully,
|
||||
} from "@cline/core";
|
||||
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { formatUptime } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import { version as cliVersion } from "../../package.json";
|
||||
|
||||
@@ -19,7 +18,7 @@ interface HubCommandIo {
|
||||
async function stopHubServer(_workspaceRoot: string): Promise<boolean> {
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
if (await stopLocalHubServerGracefully(owner)) {
|
||||
if (await stopLocalHubServerGracefully({ owner })) {
|
||||
await clearHubDiscovery(owner.discoveryPath);
|
||||
return true;
|
||||
}
|
||||
@@ -49,9 +48,7 @@ function formatHubUptimeFromStartedAt(
|
||||
}
|
||||
|
||||
function resolveCliHubOwnerContext() {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
return resolveDefaultHubOwnerContext();
|
||||
}
|
||||
|
||||
export function createHubCommand(
|
||||
|
||||
@@ -5,11 +5,9 @@ import {
|
||||
isAutoUpdateEnabledGlobally,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
resolveDefaultHubOwnerContext,
|
||||
stopLocalHubServerGracefully,
|
||||
} from "@cline/core";
|
||||
import { resolveClineBuildEnv } from "@cline/shared";
|
||||
import { version } from "../../package.json";
|
||||
import { ensureCliHubServer } from "../utils/hub-runtime";
|
||||
import { c, writeErr, writeln } from "../utils/output";
|
||||
@@ -277,9 +275,7 @@ export function getPreferredKanbanInstaller(
|
||||
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
export function resolveCliHubOwnerContext() {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
return resolveDefaultHubOwnerContext();
|
||||
}
|
||||
|
||||
async function waitForHubToStop(
|
||||
@@ -319,7 +315,9 @@ async function restartHubServerIfRunning(): Promise<void> {
|
||||
const pid = discovery?.pid;
|
||||
writeln(`${c.dim}[hub] restarting server…${c.reset}`);
|
||||
|
||||
let stopped = await stopLocalHubServerGracefully(owner).catch(() => false);
|
||||
let stopped = await stopLocalHubServerGracefully({ owner }).catch(
|
||||
() => false,
|
||||
);
|
||||
if (!stopped && pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
|
||||
@@ -61,6 +61,11 @@ if (!isMainThread) {
|
||||
});
|
||||
|
||||
void (async () => {
|
||||
const { configureCliHubDashboardLaunchEnvironment } = await import(
|
||||
"./utils/hub-dashboard-launch"
|
||||
);
|
||||
configureCliHubDashboardLaunchEnvironment();
|
||||
|
||||
let exitCode = 0;
|
||||
try {
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
@@ -1131,6 +1131,27 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("does not expose the internal dashboard serve action in invalid action errors", async () => {
|
||||
const consoleError = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => undefined);
|
||||
process.argv = ["bun", "src/index.ts", "dashboard", "nonesuch"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Unknown dashboard action "nonesuch". Expected restart or stop.',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
consoleError.mock.calls.some((call) => String(call[0]).includes("serve")),
|
||||
).toBe(false);
|
||||
expect(dashboardMocks.runDashboardCommand).not.toHaveBeenCalled();
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("prints an install hint when kanban is missing", async () => {
|
||||
const stderrWrite = vi
|
||||
.spyOn(process.stderr, "write")
|
||||
|
||||
+17
-2
@@ -623,7 +623,8 @@ export async function runCli(): Promise<void> {
|
||||
|
||||
const dashboardCmd = program
|
||||
.command("dashboard")
|
||||
.description("Start the Cline Hub dashboard and open it in a browser")
|
||||
.description("Open or manage the Cline Hub dashboard")
|
||||
.argument("[action]", "dashboard action: restart or stop")
|
||||
.option("--config <dir>", "configuration directory")
|
||||
.option("-c, --cwd <path>", "Workspace root", process.cwd())
|
||||
.option(
|
||||
@@ -635,7 +636,7 @@ export async function runCli(): Promise<void> {
|
||||
.option("--public-url <url>", "Public dashboard URL")
|
||||
.option("--room-secret <secret>", "Invite secret for browser access")
|
||||
.option("--no-open", "Start the dashboard without opening a browser")
|
||||
.action(async () => {
|
||||
.action(async (action?: string) => {
|
||||
const opts = dashboardCmd.opts<{
|
||||
config?: string;
|
||||
cwd?: string;
|
||||
@@ -646,8 +647,22 @@ export async function runCli(): Promise<void> {
|
||||
roomSecret?: string;
|
||||
open?: boolean;
|
||||
}>();
|
||||
const normalizedAction = action?.trim().toLowerCase();
|
||||
if (
|
||||
normalizedAction &&
|
||||
normalizedAction !== "restart" &&
|
||||
normalizedAction !== "stop" &&
|
||||
normalizedAction !== "serve"
|
||||
) {
|
||||
io.writeErr(
|
||||
`Unknown dashboard action "${action}". Expected restart or stop.`,
|
||||
);
|
||||
ctx.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const { runDashboardCommand } = await import("./commands/dashboard");
|
||||
ctx.exitCode = await runDashboardCommand({
|
||||
action: normalizedAction as "restart" | "serve" | "stop" | undefined,
|
||||
configDir: opts.config,
|
||||
cwd: opts.cwd,
|
||||
dataDir: opts.dataDir,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { buildCliSubcommandCommand } from "./internal-launch";
|
||||
|
||||
const DASHBOARD_LAUNCHER_ENV = "CLINE_HUB_DASHBOARD_LAUNCHER";
|
||||
const DASHBOARD_ARGS_ENV = "CLINE_HUB_DASHBOARD_ARGS";
|
||||
|
||||
export function configureCliHubDashboardLaunchEnvironment(): void {
|
||||
if (
|
||||
process.env[DASHBOARD_LAUNCHER_ENV]?.trim() &&
|
||||
process.env[DASHBOARD_ARGS_ENV]?.trim()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const command = buildCliSubcommandCommand("dashboard", ["serve"]);
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
process.env[DASHBOARD_LAUNCHER_ENV] = command.launcher;
|
||||
process.env[DASHBOARD_ARGS_ENV] = JSON.stringify(command.childArgs);
|
||||
}
|
||||
@@ -23,6 +23,12 @@ bun run start
|
||||
|
||||
Open <http://127.0.0.1:8787> and click **Connect**. The server will discover or spawn a local detached hub on startup; the hub endpoint is printed in the console and shown in the sidebar.
|
||||
|
||||
From the CLI, `cline dashboard` opens the running dashboard in the default
|
||||
browser and exits. The local hub daemon owns the background dashboard process
|
||||
when the hub is launched by the CLI; starting a new hub replaces the discovered
|
||||
dashboard process. Use `cline dashboard restart` or `cline dashboard stop` for
|
||||
manual lifecycle control.
|
||||
|
||||
For webview development with Vite hot reload:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -54,7 +54,9 @@ process.on("SIGINT", () => shutdown(0));
|
||||
process.on("SIGTERM", () => shutdown(0));
|
||||
|
||||
console.log(`[cline-hub:dev] Vite webview: ${webviewDevServerUrl}`);
|
||||
console.log("[cline-hub:dev] Hub dashboard: http://127.0.0.1:8787/");
|
||||
console.log(
|
||||
"[cline-hub:dev] Hub dashboard: use the invite URL printed by the server (includes roomSecret).",
|
||||
);
|
||||
|
||||
spawn(
|
||||
"webview",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildDashboardLaunchUrl,
|
||||
resolveClineHubServerOptions,
|
||||
} from "./options";
|
||||
|
||||
describe("resolveClineHubServerOptions", () => {
|
||||
it("generates a room secret for local dashboard bridges by default", () => {
|
||||
const options = resolveClineHubServerOptions({});
|
||||
|
||||
expect(options.host).toBe("127.0.0.1");
|
||||
expect(options.publicUrl).toBe("http://127.0.0.1:8787");
|
||||
expect(options.dashboardWebUrl).toBe("http://127.0.0.1:8787");
|
||||
expect(options.roomSecret).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
it("resolves a hosted dashboard web URL separately from the local bridge URL", () => {
|
||||
const options = resolveClineHubServerOptions({
|
||||
PUBLIC_URL: "http://127.0.0.1:8787/",
|
||||
CLINE_HUB_DASHBOARD_WEB_URL: "https://cline.bot/dashboard/",
|
||||
ROOM_SECRET: "invite-123",
|
||||
});
|
||||
|
||||
expect(options.publicUrl).toBe("http://127.0.0.1:8787");
|
||||
expect(options.dashboardWebUrl).toBe("https://cline.bot/dashboard");
|
||||
expect(options.roomSecret).toBe("invite-123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDashboardLaunchUrl", () => {
|
||||
it("puts local bridge credentials in the URL fragment", () => {
|
||||
expect(
|
||||
buildDashboardLaunchUrl(
|
||||
"https://cline.bot/dashboard",
|
||||
"http://127.0.0.1:8787",
|
||||
"invite-123",
|
||||
),
|
||||
).toBe(
|
||||
"https://cline.bot/dashboard#bridgeUrl=http%3A%2F%2F127.0.0.1%3A8787&roomSecret=invite-123",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,19 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { isIP } from "node:net";
|
||||
|
||||
export interface ClineHubServerOptions {
|
||||
host: string;
|
||||
port: number;
|
||||
publicUrl: string;
|
||||
roomSecret?: string;
|
||||
dashboardWebUrl: string;
|
||||
roomSecret: string;
|
||||
workspaceRoot: string;
|
||||
}
|
||||
|
||||
const DEFAULT_HOST = "127.0.0.1";
|
||||
const DEFAULT_PORT = 8787;
|
||||
const DASHBOARD_PORT_ENV = "CLINE_HUB_DASHBOARD_PORT";
|
||||
const DASHBOARD_WEB_URL_ENV = "CLINE_HUB_DASHBOARD_WEB_URL";
|
||||
|
||||
function parsePort(value: string | undefined): number {
|
||||
if (!value?.trim()) return DEFAULT_PORT;
|
||||
@@ -56,9 +59,33 @@ function normalizePublicUrl(
|
||||
return parsed.toString().replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function normalizeRoomSecret(value: string | undefined): string | undefined {
|
||||
function normalizeDashboardWebUrl(
|
||||
value: string | undefined,
|
||||
publicUrl: string,
|
||||
): string {
|
||||
const raw = value?.trim() || publicUrl;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`${DASHBOARD_WEB_URL_ENV} must be a valid http(s) URL, got ${raw}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error(
|
||||
`${DASHBOARD_WEB_URL_ENV} must use http: or https:, got ${parsed.protocol}`,
|
||||
);
|
||||
}
|
||||
parsed.hash = "";
|
||||
return parsed.toString().replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function normalizeRoomSecret(value: string | undefined): string {
|
||||
const secret = value?.trim();
|
||||
return secret ? secret : undefined;
|
||||
return secret ? secret : randomBytes(32).toString("hex");
|
||||
}
|
||||
|
||||
function isLocalBindHost(host: string): boolean {
|
||||
@@ -75,16 +102,16 @@ export function resolveClineHubServerOptions(
|
||||
const host = normalizeHost(env.HOST);
|
||||
const port = parsePort(env[DASHBOARD_PORT_ENV]);
|
||||
const publicUrl = normalizePublicUrl(env.PUBLIC_URL, host, port);
|
||||
const dashboardWebUrl = normalizeDashboardWebUrl(
|
||||
env[DASHBOARD_WEB_URL_ENV],
|
||||
publicUrl,
|
||||
);
|
||||
const roomSecret = normalizeRoomSecret(env.ROOM_SECRET);
|
||||
if (isNonLocalBindHost(host) && !roomSecret) {
|
||||
throw new Error(
|
||||
`ROOM_SECRET is required when HOST=${host}. Use HOST=127.0.0.1 for local-only development or set ROOM_SECRET before exposing this example on a LAN/tunnel.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
host,
|
||||
port,
|
||||
publicUrl,
|
||||
dashboardWebUrl,
|
||||
roomSecret,
|
||||
workspaceRoot: env.WORKSPACE_ROOT?.trim() || process.cwd(),
|
||||
};
|
||||
@@ -103,13 +130,15 @@ function shouldAddDashboardPortToPublicUrl(url: URL, port: number): boolean {
|
||||
return hostname === "localhost" || isIP(hostname) !== 0;
|
||||
}
|
||||
|
||||
export function buildInviteUrl(
|
||||
publicUrl: string,
|
||||
roomSecret: string | undefined,
|
||||
export function buildDashboardLaunchUrl(
|
||||
dashboardWebUrl: string,
|
||||
bridgeUrl: string,
|
||||
roomSecret: string,
|
||||
): string {
|
||||
const url = new URL(publicUrl);
|
||||
if (roomSecret) {
|
||||
url.searchParams.set("roomSecret", roomSecret);
|
||||
}
|
||||
const url = new URL(dashboardWebUrl);
|
||||
const fragment = new URLSearchParams(url.hash.replace(/^#/, ""));
|
||||
fragment.set("bridgeUrl", bridgeUrl);
|
||||
fragment.set("roomSecret", roomSecret);
|
||||
url.hash = fragment.toString();
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CORE_BUILD_VERSION } from "@cline/core";
|
||||
import { isNonLocalBindHost } from "./options";
|
||||
import { buildDashboardLaunchUrl } from "./options";
|
||||
import {
|
||||
handleToolApprovalResponse,
|
||||
rejectOrphanedApprovals,
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
import { isAuthorizedBrowserToDesktopRequest } from "./server/browser-auth";
|
||||
import {
|
||||
browserConfig,
|
||||
dashboardWebUrl,
|
||||
host,
|
||||
inviteUrl,
|
||||
port,
|
||||
publicUrl,
|
||||
roomSecret,
|
||||
@@ -23,10 +23,13 @@ import {
|
||||
import {
|
||||
attachHub,
|
||||
detachHub,
|
||||
restartHub,
|
||||
syncHubClientsAndSessions,
|
||||
syncHubHealth,
|
||||
} from "./server/hub";
|
||||
import {
|
||||
connectHubFromWebview,
|
||||
restartHubFromWebview,
|
||||
} from "./server/hub-actions";
|
||||
import { fetchMarketplaceCatalog } from "./server/marketplace";
|
||||
import {
|
||||
loadModels,
|
||||
@@ -52,8 +55,6 @@ export interface ClineHubDashboardServer {
|
||||
listenUrl: string;
|
||||
publicUrl: string;
|
||||
inviteUrl: string;
|
||||
bindHost: string;
|
||||
inviteRequired: boolean;
|
||||
hubUrl: string | undefined;
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
@@ -85,7 +86,7 @@ export async function startClineHubDashboardServer(): Promise<ClineHubDashboardS
|
||||
const syncClientsAndSessions = () => syncHubClientsAndSessions(ctx);
|
||||
let stopped = false;
|
||||
|
||||
await attachHub(ctx);
|
||||
await attachHub(ctx, { preserveDashboard: true });
|
||||
const healthInterval = setInterval(() => {
|
||||
void (async () => {
|
||||
await syncHubHealth(ctx);
|
||||
@@ -104,6 +105,7 @@ export async function startClineHubDashboardServer(): Promise<ClineHubDashboardS
|
||||
url,
|
||||
{
|
||||
bindHost: host,
|
||||
dashboardWebUrl,
|
||||
port,
|
||||
publicUrl,
|
||||
roomSecret,
|
||||
@@ -183,6 +185,13 @@ export async function startClineHubDashboardServer(): Promise<ClineHubDashboardS
|
||||
}
|
||||
} else if (frame.type === "ready") {
|
||||
await initializePeer(ctx, peer, syncClientsAndSessions);
|
||||
} else if (frame.type === "connect_hub") {
|
||||
await connectHubFromWebview(
|
||||
ctx,
|
||||
peer,
|
||||
frame,
|
||||
syncClientsAndSessions,
|
||||
);
|
||||
} else if (frame.type === "loadModels") {
|
||||
await loadModels(ctx, peer, frame.providerId);
|
||||
} else if (frame.type === "loadProviderCatalog") {
|
||||
@@ -243,7 +252,7 @@ export async function startClineHubDashboardServer(): Promise<ClineHubDashboardS
|
||||
syncClientsAndSessions,
|
||||
);
|
||||
} else if (frame.type === "restart_hub") {
|
||||
await restartHub(ctx);
|
||||
await restartHubFromWebview(ctx, peer);
|
||||
}
|
||||
} catch (error) {
|
||||
ctx.send(peer, {
|
||||
@@ -264,9 +273,7 @@ export async function startClineHubDashboardServer(): Promise<ClineHubDashboardS
|
||||
return {
|
||||
listenUrl: server.url.toString(),
|
||||
publicUrl,
|
||||
inviteUrl,
|
||||
bindHost: host,
|
||||
inviteRequired: Boolean(roomSecret),
|
||||
inviteUrl: buildDashboardLaunchUrl(dashboardWebUrl, publicUrl, roomSecret),
|
||||
hubUrl: ctx.hubUrl,
|
||||
stop: async () => {
|
||||
if (stopped) return;
|
||||
@@ -287,15 +294,7 @@ export function printClineHubDashboardServerInfo(
|
||||
console.log(`Cline Hub dashboard listening: ${server.listenUrl}`);
|
||||
console.log(`Cline Hub public URL: ${server.publicUrl}`);
|
||||
console.log(`hub endpoint: ${server.hubUrl}`);
|
||||
if (server.inviteRequired) {
|
||||
console.log(`Cline Hub invite URL: ${server.inviteUrl}`);
|
||||
} else if (isNonLocalBindHost(server.bindHost)) {
|
||||
console.warn("WARNING: non-local bind without ROOM_SECRET is not allowed.");
|
||||
} else {
|
||||
console.log(
|
||||
"ROOM_SECRET is not set; this local-only instance accepts browser connections without an invite token.",
|
||||
);
|
||||
}
|
||||
console.log(`Cline Hub invite URL: ${server.inviteUrl}`);
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
|
||||
@@ -89,6 +89,22 @@ describe("allowedBrowserOrigins", () => {
|
||||
].sort(),
|
||||
).toEqual(["https://0.0.0.0:8787", "https://example.ngrok-free.app"]);
|
||||
});
|
||||
|
||||
it("allows a separately hosted dashboard web origin", () => {
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserOrigins({
|
||||
...defaultOptions,
|
||||
dashboardWebUrl: "https://cline.bot/dashboard",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual([
|
||||
"http://127.0.0.1:8787",
|
||||
"http://[::1]:8787",
|
||||
"http://localhost:8787",
|
||||
"https://cline.bot",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("allowedBrowserHosts", () => {
|
||||
@@ -292,6 +308,20 @@ describe("isAuthorizedBrowserRequest", () => {
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows hosted dashboard browsers to connect to the local bridge with the room secret", () => {
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("https://cline.bot"),
|
||||
new URL("http://127.0.0.1:8787/browser?roomSecret=invite-123"),
|
||||
{
|
||||
...defaultOptions,
|
||||
dashboardWebUrl: "https://cline.bot/dashboard",
|
||||
roomSecret: "invite-123",
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAuthorizedBrowserToDesktopRequest", () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { isNonLocalBindHost } from "../options";
|
||||
|
||||
export interface BrowserRequestAuthOptions {
|
||||
bindHost: string;
|
||||
dashboardWebUrl?: string;
|
||||
port: number;
|
||||
publicUrl: string;
|
||||
roomSecret?: string;
|
||||
@@ -57,12 +58,16 @@ function hostHeaderForHost(
|
||||
|
||||
export function allowedBrowserOrigins({
|
||||
bindHost,
|
||||
dashboardWebUrl,
|
||||
port,
|
||||
publicUrl,
|
||||
}: BrowserRequestAuthOptions): Set<string> {
|
||||
const publicUrlParts = new URL(publicUrl);
|
||||
const origins = new Set<string>();
|
||||
origins.add(publicUrlParts.origin);
|
||||
if (dashboardWebUrl?.trim()) {
|
||||
origins.add(new URL(dashboardWebUrl).origin);
|
||||
}
|
||||
|
||||
origins.add(originForHost(publicUrlParts.protocol, bindHost, port));
|
||||
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { dirname, join, normalize } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { ProviderSettingsManager } from "@cline/core";
|
||||
import { buildInviteUrl, resolveClineHubServerOptions } from "../options";
|
||||
import { resolveClineHubServerOptions } from "../options";
|
||||
import type { BrowserConfig } from "./types";
|
||||
|
||||
export const options = resolveClineHubServerOptions();
|
||||
export const { host, port, publicUrl, roomSecret, workspaceRoot } = options;
|
||||
export const inviteUrl = buildInviteUrl(publicUrl, roomSecret);
|
||||
export const {
|
||||
dashboardWebUrl,
|
||||
host,
|
||||
port,
|
||||
publicUrl,
|
||||
roomSecret,
|
||||
workspaceRoot,
|
||||
} = options;
|
||||
|
||||
const serverDir = dirname(fileURLToPath(import.meta.url));
|
||||
/** server.ts lives one level up from this module, so resolve relative to it. */
|
||||
@@ -21,6 +27,7 @@ export const cliIndexPath = normalize(
|
||||
export const providerSettingsManager = new ProviderSettingsManager();
|
||||
|
||||
export const browserConfig: BrowserConfig = {
|
||||
inviteRequired: Boolean(roomSecret),
|
||||
bridgeUrl: publicUrl,
|
||||
dashboardWebUrl,
|
||||
publicUrl,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
attachHub: vi.fn(),
|
||||
initializePeer: vi.fn(),
|
||||
restartHub: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./hub", () => ({
|
||||
attachHub: mocks.attachHub,
|
||||
restartHub: mocks.restartHub,
|
||||
}));
|
||||
|
||||
vi.mock("./sessions", () => ({
|
||||
initializePeer: mocks.initializePeer,
|
||||
}));
|
||||
|
||||
describe("connectHubFromWebview", () => {
|
||||
beforeEach(() => {
|
||||
mocks.attachHub.mockReset();
|
||||
mocks.initializePeer.mockReset();
|
||||
mocks.restartHub.mockReset();
|
||||
});
|
||||
|
||||
it("reports the attached hub after initialization succeeds", async () => {
|
||||
const send = vi.fn();
|
||||
const ctx = {
|
||||
hubUrl: "ws://127.0.0.1:25464/hub",
|
||||
send,
|
||||
};
|
||||
const peer = {};
|
||||
const syncClientsAndSessions = vi.fn(async () => undefined);
|
||||
const { connectHubFromWebview } = await import("./hub-actions");
|
||||
|
||||
await connectHubFromWebview(
|
||||
ctx as never,
|
||||
peer as never,
|
||||
{
|
||||
type: "connect_hub",
|
||||
hubUrl: "ws://127.0.0.1:25464/hub?authToken=custom-token",
|
||||
},
|
||||
syncClientsAndSessions,
|
||||
);
|
||||
|
||||
expect(mocks.attachHub).toHaveBeenCalledWith(ctx, {
|
||||
hubUrl: "ws://127.0.0.1:25464/hub?authToken=custom-token",
|
||||
authToken: undefined,
|
||||
});
|
||||
expect(mocks.initializePeer).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
peer,
|
||||
syncClientsAndSessions,
|
||||
);
|
||||
expect(send).toHaveBeenCalledWith(peer, {
|
||||
type: "hub_connection_result",
|
||||
ok: true,
|
||||
hubUrl: "ws://127.0.0.1:25464/hub",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports connection failures without reinitializing the peer", async () => {
|
||||
mocks.attachHub.mockRejectedValue(new Error("connection refused"));
|
||||
const send = vi.fn();
|
||||
const ctx = { send };
|
||||
const peer = {};
|
||||
const { connectHubFromWebview } = await import("./hub-actions");
|
||||
|
||||
await connectHubFromWebview(
|
||||
ctx as never,
|
||||
peer as never,
|
||||
{
|
||||
type: "connect_hub",
|
||||
hubUrl: "ws://127.0.0.1:25464/hub",
|
||||
authToken: "custom-token",
|
||||
},
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(mocks.initializePeer).not.toHaveBeenCalled();
|
||||
expect(send).toHaveBeenCalledWith(peer, {
|
||||
type: "hub_connection_result",
|
||||
ok: false,
|
||||
error: "connection refused",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a successful connection result when peer refresh fails", async () => {
|
||||
mocks.initializePeer.mockRejectedValue(
|
||||
new Error("provider refresh failed"),
|
||||
);
|
||||
const send = vi.fn();
|
||||
const ctx = {
|
||||
hubUrl: "ws://127.0.0.1:25464/hub",
|
||||
send,
|
||||
};
|
||||
const peer = {};
|
||||
const { connectHubFromWebview } = await import("./hub-actions");
|
||||
|
||||
await connectHubFromWebview(
|
||||
ctx as never,
|
||||
peer as never,
|
||||
{
|
||||
type: "connect_hub",
|
||||
hubUrl: "ws://127.0.0.1:25464/hub",
|
||||
authToken: "custom-token",
|
||||
},
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(send).toHaveBeenCalledWith(peer, {
|
||||
type: "hub_connection_result",
|
||||
ok: true,
|
||||
hubUrl: "ws://127.0.0.1:25464/hub",
|
||||
});
|
||||
expect(send).not.toHaveBeenCalledWith(
|
||||
peer,
|
||||
expect.objectContaining({
|
||||
type: "hub_connection_result",
|
||||
ok: false,
|
||||
}),
|
||||
);
|
||||
expect(send).toHaveBeenCalledWith(peer, {
|
||||
type: "error",
|
||||
text: "Connected to the hub, but failed to refresh dashboard state: provider refresh failed",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("restartHubFromWebview", () => {
|
||||
beforeEach(() => {
|
||||
mocks.restartHub.mockReset();
|
||||
});
|
||||
|
||||
it("reports a successful restart", async () => {
|
||||
const send = vi.fn();
|
||||
const ctx = { send };
|
||||
const peer = {};
|
||||
const { restartHubFromWebview } = await import("./hub-actions");
|
||||
|
||||
await restartHubFromWebview(ctx as never, peer as never);
|
||||
|
||||
expect(mocks.restartHub).toHaveBeenCalledWith(ctx);
|
||||
expect(send).toHaveBeenCalledWith(peer, {
|
||||
type: "hub_restart_result",
|
||||
ok: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports restart failures", async () => {
|
||||
mocks.restartHub.mockRejectedValue(new Error("Unable to stop the hub"));
|
||||
const send = vi.fn();
|
||||
const ctx = { send };
|
||||
const peer = {};
|
||||
const { restartHubFromWebview } = await import("./hub-actions");
|
||||
|
||||
await restartHubFromWebview(ctx as never, peer as never);
|
||||
|
||||
expect(send).toHaveBeenCalledWith(peer, {
|
||||
type: "hub_restart_result",
|
||||
ok: false,
|
||||
error: "Unable to stop the hub",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { WebviewInboundMessage } from "../webview-protocol";
|
||||
import { attachHub, restartHub } from "./hub";
|
||||
import { initializePeer } from "./sessions";
|
||||
import type { HubContext } from "./state";
|
||||
import type { BrowserPeer } from "./types";
|
||||
|
||||
type ConnectHubMessage = Extract<
|
||||
WebviewInboundMessage,
|
||||
{ type: "connect_hub" }
|
||||
>;
|
||||
|
||||
export async function connectHubFromWebview(
|
||||
ctx: HubContext,
|
||||
peer: BrowserPeer,
|
||||
frame: ConnectHubMessage,
|
||||
syncClientsAndSessions: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await attachHub(ctx, {
|
||||
hubUrl: frame.hubUrl,
|
||||
authToken: frame.authToken,
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.send(peer, {
|
||||
type: "hub_connection_result",
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.send(peer, {
|
||||
type: "hub_connection_result",
|
||||
ok: true,
|
||||
hubUrl: ctx.hubUrl,
|
||||
});
|
||||
try {
|
||||
await initializePeer(ctx, peer, syncClientsAndSessions);
|
||||
} catch (error) {
|
||||
ctx.send(peer, {
|
||||
type: "error",
|
||||
text: `Connected to the hub, but failed to refresh dashboard state: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function restartHubFromWebview(
|
||||
ctx: HubContext,
|
||||
peer: BrowserPeer,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await restartHub(ctx);
|
||||
ctx.send(peer, {
|
||||
type: "hub_restart_result",
|
||||
ok: true,
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.send(peer, {
|
||||
type: "hub_restart_result",
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createCline: vi.fn(),
|
||||
createUiClient: vi.fn(),
|
||||
ensureDetachedHubServer: vi.fn(),
|
||||
probeHubServer: vi.fn(),
|
||||
readHubDashboardDiscovery: vi.fn(),
|
||||
readHubDiscovery: vi.fn(),
|
||||
rememberRecoverableLocalHubUrl: vi.fn((url: string) => url),
|
||||
resolveDefaultHubOwnerContext: vi.fn(() => ({
|
||||
discoveryPath: "/tmp/hub.json",
|
||||
})),
|
||||
resolveHubDashboardDiscoveryPath: vi.fn(() => "/tmp/dashboard.json"),
|
||||
stopLocalHubServerGracefully: vi.fn(),
|
||||
writeHubDashboardDiscovery: vi.fn(),
|
||||
rejectAllPendingApprovals: vi.fn(),
|
||||
broadcastHubState: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV: "CLINE_HUB_DASHBOARD_DISCOVERY_PATH",
|
||||
CORE_BUILD_VERSION: "test",
|
||||
ClineCore: { create: mocks.createCline },
|
||||
HubUIClient: vi.fn(function HubUIClient(options: unknown) {
|
||||
return mocks.createUiClient(options);
|
||||
}),
|
||||
ensureDetachedHubServer: mocks.ensureDetachedHubServer,
|
||||
probeHubServer: mocks.probeHubServer,
|
||||
readHubDashboardDiscovery: mocks.readHubDashboardDiscovery,
|
||||
readHubDiscovery: mocks.readHubDiscovery,
|
||||
rememberRecoverableLocalHubUrl: mocks.rememberRecoverableLocalHubUrl,
|
||||
resolveDefaultHubOwnerContext: mocks.resolveDefaultHubOwnerContext,
|
||||
resolveHubDashboardDiscoveryPath: mocks.resolveHubDashboardDiscoveryPath,
|
||||
stopLocalHubServerGracefully: mocks.stopLocalHubServerGracefully,
|
||||
toHubHealthUrl: (url: string) => url,
|
||||
writeHubDashboardDiscovery: mocks.writeHubDashboardDiscovery,
|
||||
}));
|
||||
|
||||
vi.mock("./agent-events", () => ({
|
||||
handleSessionEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./approvals", () => ({
|
||||
rejectAllPendingApprovals: mocks.rejectAllPendingApprovals,
|
||||
requestToolApprovalFromWebview: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./deps", () => ({
|
||||
workspaceRoot: "/workspace",
|
||||
}));
|
||||
|
||||
vi.mock("./state-payloads", () => ({
|
||||
broadcastHubState: mocks.broadcastHubState,
|
||||
}));
|
||||
|
||||
function createClineClient() {
|
||||
return {
|
||||
dispose: vi.fn(async () => undefined),
|
||||
subscribe: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createUiClient(options: { connectError?: Error } = {}) {
|
||||
return {
|
||||
close: vi.fn(),
|
||||
connect: options.connectError
|
||||
? vi.fn(async () => {
|
||||
throw options.connectError;
|
||||
})
|
||||
: vi.fn(async () => undefined),
|
||||
listClients: vi.fn(async () => []),
|
||||
listSessions: vi.fn(async () => []),
|
||||
subscribeUI: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("hub attachment lifecycle", () => {
|
||||
beforeEach(() => {
|
||||
mocks.createCline.mockReset();
|
||||
mocks.createUiClient.mockReset();
|
||||
mocks.ensureDetachedHubServer.mockReset();
|
||||
mocks.ensureDetachedHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "new-token",
|
||||
});
|
||||
mocks.probeHubServer.mockReset();
|
||||
mocks.probeHubServer.mockResolvedValue(undefined);
|
||||
mocks.readHubDashboardDiscovery.mockReset();
|
||||
mocks.readHubDashboardDiscovery.mockResolvedValue(undefined);
|
||||
mocks.readHubDiscovery.mockReset();
|
||||
mocks.readHubDiscovery.mockResolvedValue(undefined);
|
||||
mocks.rememberRecoverableLocalHubUrl.mockClear();
|
||||
mocks.resolveDefaultHubOwnerContext.mockClear();
|
||||
mocks.resolveHubDashboardDiscoveryPath.mockClear();
|
||||
mocks.stopLocalHubServerGracefully.mockReset();
|
||||
mocks.stopLocalHubServerGracefully.mockResolvedValue(true);
|
||||
mocks.writeHubDashboardDiscovery.mockReset();
|
||||
mocks.writeHubDashboardDiscovery.mockResolvedValue(undefined);
|
||||
mocks.rejectAllPendingApprovals.mockClear();
|
||||
mocks.broadcastHubState.mockClear();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({ ok: false })),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("keeps the existing attachment when a replacement connection fails", async () => {
|
||||
const oldCline = createClineClient();
|
||||
const oldUiClient = createUiClient();
|
||||
const nextCline = createClineClient();
|
||||
const connectError = new Error("connection refused");
|
||||
const nextUiClient = createUiClient({ connectError });
|
||||
mocks.createCline.mockResolvedValue(nextCline);
|
||||
mocks.createUiClient.mockReturnValue(nextUiClient);
|
||||
const { HubContext } = await import("./state");
|
||||
const ctx = new HubContext();
|
||||
ctx.hubUrl = "ws://127.0.0.1:25463/hub";
|
||||
ctx.hubAuthToken = "old-token";
|
||||
ctx.hubManagedLocally = true;
|
||||
ctx.cline = oldCline as never;
|
||||
ctx.uiClient = oldUiClient as never;
|
||||
const { attachHub } = await import("./hub");
|
||||
|
||||
await expect(
|
||||
attachHub(ctx, {
|
||||
hubUrl: "ws://127.0.0.1:25464/hub?authToken=new-token",
|
||||
}),
|
||||
).rejects.toThrow("connection refused");
|
||||
|
||||
expect(oldUiClient.close).not.toHaveBeenCalled();
|
||||
expect(oldCline.dispose).not.toHaveBeenCalled();
|
||||
expect(ctx.uiClient).toBe(oldUiClient);
|
||||
expect(ctx.cline).toBe(oldCline);
|
||||
expect(ctx.hubUrl).toBe("ws://127.0.0.1:25463/hub");
|
||||
expect(ctx.hubManagedLocally).toBe(true);
|
||||
expect(nextUiClient.close).toHaveBeenCalledOnce();
|
||||
expect(nextCline.dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("preserves the dashboard when the initial attachment starts a hub", async () => {
|
||||
const nextCline = createClineClient();
|
||||
const nextUiClient = createUiClient();
|
||||
mocks.createCline.mockResolvedValue(nextCline);
|
||||
mocks.createUiClient.mockReturnValue(nextUiClient);
|
||||
const { HubContext } = await import("./state");
|
||||
const ctx = new HubContext();
|
||||
const { attachHub } = await import("./hub");
|
||||
|
||||
await attachHub(ctx);
|
||||
|
||||
expect(mocks.ensureDetachedHubServer).toHaveBeenCalledWith("/workspace", {
|
||||
preserveDashboard: true,
|
||||
});
|
||||
expect(ctx.hubManagedLocally).toBe(true);
|
||||
});
|
||||
|
||||
it("uses discovery auth and remains restartable for the managed local hub URL", async () => {
|
||||
const nextCline = createClineClient();
|
||||
const nextUiClient = createUiClient();
|
||||
mocks.createCline.mockResolvedValue(nextCline);
|
||||
mocks.createUiClient.mockReturnValue(nextUiClient);
|
||||
mocks.readHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "discovery-token",
|
||||
});
|
||||
const { HubContext } = await import("./state");
|
||||
const ctx = new HubContext();
|
||||
const { attachHub } = await import("./hub");
|
||||
|
||||
await attachHub(ctx, {
|
||||
hubUrl: "ws://127.0.0.1:25463/hub",
|
||||
});
|
||||
|
||||
expect(mocks.readHubDiscovery).toHaveBeenCalledWith("/tmp/hub.json");
|
||||
expect(mocks.createCline).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
hub: expect.objectContaining({
|
||||
endpoint: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "discovery-token",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mocks.createUiClient).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
address: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "discovery-token",
|
||||
}),
|
||||
);
|
||||
expect(ctx.hubManagedLocally).toBe(true);
|
||||
});
|
||||
|
||||
it("refreshes this dashboard's discovery after a custom reattach", async () => {
|
||||
const nextCline = createClineClient();
|
||||
const nextUiClient = createUiClient();
|
||||
mocks.createCline.mockResolvedValue(nextCline);
|
||||
mocks.createUiClient.mockReturnValue(nextUiClient);
|
||||
mocks.readHubDashboardDiscovery.mockResolvedValue({
|
||||
pid: process.pid,
|
||||
listenUrl: "http://127.0.0.1:8787",
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
inviteUrl: "https://cline.bot/dashboard#bridgeUrl=old",
|
||||
hubUrl: "ws://127.0.0.1:25463/hub",
|
||||
startedAt: "2026-07-24T00:00:00.000Z",
|
||||
updatedAt: "2026-07-24T00:00:00.000Z",
|
||||
});
|
||||
const { HubContext } = await import("./state");
|
||||
const ctx = new HubContext();
|
||||
const { attachHub } = await import("./hub");
|
||||
|
||||
await attachHub(ctx, {
|
||||
hubUrl: "ws://127.0.0.1:25464/hub?authToken=custom-token",
|
||||
});
|
||||
|
||||
expect(mocks.writeHubDashboardDiscovery).toHaveBeenCalledWith(
|
||||
"/tmp/dashboard.json",
|
||||
expect.objectContaining({
|
||||
pid: process.pid,
|
||||
hubUrl: "ws://127.0.0.1:25464/hub",
|
||||
updatedAt: expect.any(String),
|
||||
}),
|
||||
);
|
||||
expect(ctx.hubManagedLocally).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves the dashboard while replacing and reattaching to the hub", async () => {
|
||||
const oldCline = createClineClient();
|
||||
const oldUiClient = createUiClient();
|
||||
const nextCline = createClineClient();
|
||||
const nextUiClient = createUiClient();
|
||||
mocks.createCline.mockResolvedValue(nextCline);
|
||||
mocks.createUiClient.mockReturnValue(nextUiClient);
|
||||
const { HubContext } = await import("./state");
|
||||
const ctx = new HubContext();
|
||||
ctx.hubManagedLocally = true;
|
||||
ctx.cline = oldCline as never;
|
||||
ctx.uiClient = oldUiClient as never;
|
||||
const { restartHub } = await import("./hub");
|
||||
|
||||
await restartHub(ctx);
|
||||
|
||||
expect(mocks.stopLocalHubServerGracefully).toHaveBeenCalledWith({
|
||||
preserveDashboard: true,
|
||||
});
|
||||
expect(mocks.ensureDetachedHubServer).toHaveBeenCalledWith("/workspace", {
|
||||
preserveDashboard: true,
|
||||
});
|
||||
expect(nextUiClient.connect).toHaveBeenCalledOnce();
|
||||
expect(oldUiClient.close).toHaveBeenCalledOnce();
|
||||
expect(oldCline.dispose).toHaveBeenCalledOnce();
|
||||
expect(ctx.uiClient).toBe(nextUiClient);
|
||||
expect(ctx.cline).toBe(nextCline);
|
||||
});
|
||||
|
||||
it("remains detached when a stopped hub cannot be replaced", async () => {
|
||||
const oldCline = createClineClient();
|
||||
const oldUiClient = createUiClient();
|
||||
const nextCline = createClineClient();
|
||||
const nextUiClient = createUiClient({
|
||||
connectError: new Error("replacement unavailable"),
|
||||
});
|
||||
mocks.createCline.mockResolvedValue(nextCline);
|
||||
mocks.createUiClient.mockReturnValue(nextUiClient);
|
||||
const { HubContext } = await import("./state");
|
||||
const ctx = new HubContext();
|
||||
ctx.hubUrl = "ws://127.0.0.1:25463/hub";
|
||||
ctx.hubAuthToken = "old-token";
|
||||
ctx.hubManagedLocally = true;
|
||||
ctx.cline = oldCline as never;
|
||||
ctx.uiClient = oldUiClient as never;
|
||||
const { restartHub } = await import("./hub");
|
||||
|
||||
await expect(restartHub(ctx)).rejects.toThrow("replacement unavailable");
|
||||
|
||||
expect(oldUiClient.close).toHaveBeenCalledOnce();
|
||||
expect(oldCline.dispose).toHaveBeenCalledOnce();
|
||||
expect(nextUiClient.close).toHaveBeenCalledOnce();
|
||||
expect(nextCline.dispose).toHaveBeenCalledOnce();
|
||||
expect(ctx.uiClient).toBeUndefined();
|
||||
expect(ctx.cline).toBeUndefined();
|
||||
expect(ctx.hubManagedLocally).toBe(false);
|
||||
expect(mocks.broadcastHubState).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not claim a restart when the current hub cannot stop", async () => {
|
||||
mocks.stopLocalHubServerGracefully.mockResolvedValue(false);
|
||||
mocks.probeHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
});
|
||||
const oldCline = createClineClient();
|
||||
const oldUiClient = createUiClient();
|
||||
const { HubContext } = await import("./state");
|
||||
const ctx = new HubContext();
|
||||
ctx.hubUrl = "ws://127.0.0.1:25463/hub";
|
||||
ctx.hubAuthToken = "old-token";
|
||||
ctx.hubManagedLocally = true;
|
||||
ctx.cline = oldCline as never;
|
||||
ctx.uiClient = oldUiClient as never;
|
||||
const { restartHub } = await import("./hub");
|
||||
|
||||
await expect(restartHub(ctx)).rejects.toThrow(
|
||||
"Unable to stop the current Cline Hub.",
|
||||
);
|
||||
|
||||
expect(mocks.probeHubServer).toHaveBeenCalledWith(ctx.hubUrl, {
|
||||
authToken: "old-token",
|
||||
});
|
||||
expect(mocks.ensureDetachedHubServer).not.toHaveBeenCalled();
|
||||
expect(oldUiClient.close).not.toHaveBeenCalled();
|
||||
expect(oldCline.dispose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reattaches when the current hub crashed before it could stop", async () => {
|
||||
mocks.stopLocalHubServerGracefully.mockResolvedValue(false);
|
||||
const oldCline = createClineClient();
|
||||
const oldUiClient = createUiClient();
|
||||
const nextCline = createClineClient();
|
||||
const nextUiClient = createUiClient();
|
||||
mocks.createCline.mockResolvedValue(nextCline);
|
||||
mocks.createUiClient.mockReturnValue(nextUiClient);
|
||||
const { HubContext } = await import("./state");
|
||||
const ctx = new HubContext();
|
||||
ctx.hubUrl = "ws://127.0.0.1:25463/hub";
|
||||
ctx.hubAuthToken = "old-token";
|
||||
ctx.hubManagedLocally = true;
|
||||
ctx.cline = oldCline as never;
|
||||
ctx.uiClient = oldUiClient as never;
|
||||
const { restartHub } = await import("./hub");
|
||||
|
||||
await restartHub(ctx);
|
||||
|
||||
expect(mocks.probeHubServer).toHaveBeenCalledWith(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
{ authToken: "old-token" },
|
||||
);
|
||||
expect(mocks.ensureDetachedHubServer).toHaveBeenCalledWith("/workspace", {
|
||||
preserveDashboard: true,
|
||||
});
|
||||
expect(nextUiClient.connect).toHaveBeenCalledOnce();
|
||||
expect(oldUiClient.close).toHaveBeenCalledOnce();
|
||||
expect(oldCline.dispose).toHaveBeenCalledOnce();
|
||||
expect(ctx.uiClient).toBe(nextUiClient);
|
||||
expect(ctx.cline).toBe(nextCline);
|
||||
});
|
||||
|
||||
it("does not restart or replace a custom hub attachment", async () => {
|
||||
const { HubContext } = await import("./state");
|
||||
const ctx = new HubContext();
|
||||
ctx.hubUrl = "ws://custom.example.test/hub";
|
||||
ctx.hubAuthToken = "custom-token";
|
||||
ctx.hubManagedLocally = false;
|
||||
const { restartHub } = await import("./hub");
|
||||
|
||||
await expect(restartHub(ctx)).rejects.toThrow(
|
||||
"Custom hubs must be restarted externally",
|
||||
);
|
||||
|
||||
expect(mocks.stopLocalHubServerGracefully).not.toHaveBeenCalled();
|
||||
expect(mocks.ensureDetachedHubServer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,18 @@
|
||||
import {
|
||||
CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV,
|
||||
ClineCore,
|
||||
ensureDetachedHubServer,
|
||||
type HubServerDiscoveryRecord,
|
||||
HubUIClient,
|
||||
probeHubServer,
|
||||
readHubDashboardDiscovery,
|
||||
readHubDiscovery,
|
||||
rememberRecoverableLocalHubUrl,
|
||||
resolveDefaultHubOwnerContext,
|
||||
resolveHubDashboardDiscoveryPath,
|
||||
stopLocalHubServerGracefully,
|
||||
toHubHealthUrl,
|
||||
writeHubDashboardDiscovery,
|
||||
} from "@cline/core";
|
||||
import type { HubUINotifyPayload } from "@cline/shared";
|
||||
import { handleSessionEvent } from "./agent-events";
|
||||
@@ -24,6 +32,30 @@ import { broadcastHubState } from "./state-payloads";
|
||||
import type { SessionContext } from "./types";
|
||||
import { asString, basename, isActiveSession, isVisibleClient } from "./utils";
|
||||
|
||||
function resolveDashboardDiscoveryPath(): string {
|
||||
return (
|
||||
process.env[CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV]?.trim() ||
|
||||
resolveHubDashboardDiscoveryPath(resolveDefaultHubOwnerContext())
|
||||
);
|
||||
}
|
||||
|
||||
async function refreshDashboardDiscoveryHubUrl(hubUrl: string): Promise<void> {
|
||||
const discoveryPath = resolveDashboardDiscoveryPath();
|
||||
const discovered = await readHubDashboardDiscovery(discoveryPath);
|
||||
if (
|
||||
!discovered ||
|
||||
discovered.pid !== process.pid ||
|
||||
discovered.hubUrl === hubUrl
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await writeHubDashboardDiscovery(discoveryPath, {
|
||||
...discovered,
|
||||
hubUrl,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function syncHubHealth(ctx: HubContext): Promise<void> {
|
||||
if (!ctx.hubUrl) {
|
||||
ctx.hubHealthy = false;
|
||||
@@ -89,12 +121,74 @@ export async function syncHubClientsAndSessions(
|
||||
if (mostRecent) ctx.lastSessionContext = mostRecent;
|
||||
}
|
||||
|
||||
export async function attachHub(ctx: HubContext): Promise<void> {
|
||||
const hub = await ensureDetachedHubServer(workspaceRoot);
|
||||
ctx.hubUrl = hub.url;
|
||||
ctx.hubAuthToken = hub.authToken;
|
||||
export interface HubAttachmentOverride {
|
||||
hubUrl?: string;
|
||||
authToken?: string;
|
||||
preserveDashboard?: boolean;
|
||||
}
|
||||
|
||||
ctx.cline = await ClineCore.create({
|
||||
function sameHubEndpoint(left: string, right: string): boolean {
|
||||
const leftUrl = new URL(left);
|
||||
const rightUrl = new URL(right);
|
||||
leftUrl.search = "";
|
||||
leftUrl.hash = "";
|
||||
rightUrl.search = "";
|
||||
rightUrl.hash = "";
|
||||
return leftUrl.toString() === rightUrl.toString();
|
||||
}
|
||||
|
||||
async function resolveHubAttachmentOverride(
|
||||
override?: HubAttachmentOverride,
|
||||
): Promise<
|
||||
{ hubUrl: string; authToken: string; managedLocally: boolean } | undefined
|
||||
> {
|
||||
const rawHubUrl = override?.hubUrl?.trim();
|
||||
if (!rawHubUrl) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = new URL(rawHubUrl);
|
||||
const queryToken = parsed.searchParams.get("authToken")?.trim();
|
||||
parsed.searchParams.delete("authToken");
|
||||
parsed.hash = "";
|
||||
const owner = resolveDefaultHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
const managedLocally = Boolean(
|
||||
discovery?.url && sameHubEndpoint(parsed.toString(), discovery.url),
|
||||
);
|
||||
const authToken =
|
||||
override?.authToken?.trim() ||
|
||||
queryToken ||
|
||||
(managedLocally ? discovery?.authToken.trim() : undefined);
|
||||
if (!authToken) {
|
||||
throw new Error(
|
||||
"Hub auth token is required when connecting the dashboard to a custom hub URL.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
hubUrl: parsed.toString(),
|
||||
authToken,
|
||||
managedLocally,
|
||||
};
|
||||
}
|
||||
|
||||
export async function attachHub(
|
||||
ctx: HubContext,
|
||||
override?: HubAttachmentOverride,
|
||||
): Promise<void> {
|
||||
const resolvedOverride = await resolveHubAttachmentOverride(override);
|
||||
const hub = resolvedOverride
|
||||
? {
|
||||
url: rememberRecoverableLocalHubUrl(
|
||||
resolvedOverride.hubUrl,
|
||||
resolvedOverride.authToken,
|
||||
),
|
||||
authToken: resolvedOverride.authToken,
|
||||
}
|
||||
: await ensureDetachedHubServer(workspaceRoot, {
|
||||
preserveDashboard: override?.preserveDashboard ?? true,
|
||||
});
|
||||
|
||||
const nextCline = await ClineCore.create({
|
||||
clientName: "cline-hub",
|
||||
backendMode: "hub",
|
||||
capabilities: {
|
||||
@@ -102,21 +196,39 @@ export async function attachHub(ctx: HubContext): Promise<void> {
|
||||
requestToolApprovalFromWebview(ctx, request),
|
||||
},
|
||||
hub: {
|
||||
endpoint: ctx.hubUrl,
|
||||
authToken: ctx.hubAuthToken,
|
||||
endpoint: hub.url,
|
||||
authToken: hub.authToken,
|
||||
clientType: "cline-hub-chat",
|
||||
displayName: "Cline Hub Chat",
|
||||
workspaceRoot,
|
||||
},
|
||||
});
|
||||
|
||||
ctx.uiClient = new HubUIClient({
|
||||
address: ctx.hubUrl,
|
||||
authToken: ctx.hubAuthToken,
|
||||
const nextUiClient = new HubUIClient({
|
||||
address: hub.url,
|
||||
authToken: hub.authToken,
|
||||
clientType: "cline-hub-server",
|
||||
displayName: "Cline Hub Server",
|
||||
});
|
||||
await ctx.uiClient.connect();
|
||||
try {
|
||||
await nextUiClient.connect();
|
||||
} catch (error) {
|
||||
try {
|
||||
nextUiClient.close();
|
||||
} catch {
|
||||
// Preserve the connection error while cleanup remains best-effort.
|
||||
}
|
||||
await nextCline.dispose().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
|
||||
await detachHub(ctx);
|
||||
ctx.hubUrl = hub.url;
|
||||
ctx.hubAuthToken = hub.authToken;
|
||||
ctx.hubManagedLocally =
|
||||
resolvedOverride === undefined || resolvedOverride.managedLocally;
|
||||
ctx.cline = nextCline;
|
||||
ctx.uiClient = nextUiClient;
|
||||
|
||||
ctx.uiClient.subscribeUI({
|
||||
onNotify(payload: HubUINotifyPayload) {
|
||||
@@ -219,6 +331,9 @@ export async function attachHub(ctx: HubContext): Promise<void> {
|
||||
|
||||
await syncHubClientsAndSessions(ctx);
|
||||
await syncHubHealth(ctx);
|
||||
await refreshDashboardDiscoveryHubUrl(hub.url).catch((error) => {
|
||||
console.warn("Unable to refresh dashboard discovery:", error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function detachHub(ctx: HubContext): Promise<void> {
|
||||
@@ -242,6 +357,7 @@ export async function detachHub(ctx: HubContext): Promise<void> {
|
||||
// ignore
|
||||
}
|
||||
ctx.cline = undefined;
|
||||
ctx.hubManagedLocally = false;
|
||||
ctx.clients.clear();
|
||||
ctx.sessions.clear();
|
||||
ctx.hubStartedAt = undefined;
|
||||
@@ -249,20 +365,40 @@ export async function detachHub(ctx: HubContext): Promise<void> {
|
||||
ctx.initialHubEventEmitted = false;
|
||||
}
|
||||
|
||||
async function isAttachedHubReachable(ctx: HubContext): Promise<boolean> {
|
||||
if (!ctx.hubUrl) {
|
||||
return false;
|
||||
}
|
||||
const record = await probeHubServer(ctx.hubUrl, {
|
||||
authToken: ctx.hubAuthToken,
|
||||
}).catch(() => undefined);
|
||||
return Boolean(record);
|
||||
}
|
||||
|
||||
export async function restartHub(ctx: HubContext): Promise<void> {
|
||||
if (!ctx.hubManagedLocally) {
|
||||
throw new Error(
|
||||
"Custom hubs must be restarted externally before reconnecting the dashboard.",
|
||||
);
|
||||
}
|
||||
ctx.broadcast({
|
||||
type: "notification",
|
||||
title: "Hub restarting",
|
||||
body: "Shutting down and respawning hub...",
|
||||
severity: "warn",
|
||||
});
|
||||
await detachHub(ctx);
|
||||
try {
|
||||
await stopLocalHubServerGracefully();
|
||||
} catch (error) {
|
||||
const stopped = await stopLocalHubServerGracefully({
|
||||
preserveDashboard: true,
|
||||
}).catch((error) => {
|
||||
console.warn("stopLocalHubServerGracefully failed:", error);
|
||||
return false;
|
||||
});
|
||||
if (!stopped && (await isAttachedHubReachable(ctx))) {
|
||||
throw new Error("Unable to stop the current Cline Hub.");
|
||||
}
|
||||
await attachHub(ctx);
|
||||
await detachHub(ctx);
|
||||
broadcastHubState(ctx);
|
||||
await attachHub(ctx, { preserveDashboard: true });
|
||||
broadcastHubState(ctx);
|
||||
ctx.broadcast({
|
||||
type: "notification",
|
||||
|
||||
@@ -25,6 +25,7 @@ export function hubStatePayload(ctx: HubContext): WebviewHubState {
|
||||
return {
|
||||
type: "hub_state",
|
||||
connected: Boolean(ctx.cline && ctx.uiClient),
|
||||
restartable: ctx.hubManagedLocally,
|
||||
hubUrl: ctx.hubUrl,
|
||||
hubStartedAt: ctx.hubStartedAt,
|
||||
coreVersion: ctx.coreVersion,
|
||||
|
||||
@@ -27,6 +27,7 @@ export class HubContext {
|
||||
hubUrl = "";
|
||||
hubAuthToken = "";
|
||||
hubHealthy = false;
|
||||
hubManagedLocally = false;
|
||||
cline: ClineCore | undefined;
|
||||
uiClient: HubUIClient | undefined;
|
||||
hubStartedAt: string | undefined;
|
||||
|
||||
@@ -12,7 +12,8 @@ export type ProviderSettingsUpdate = Partial<
|
||||
>;
|
||||
|
||||
export interface BrowserConfig {
|
||||
inviteRequired: boolean;
|
||||
bridgeUrl: string;
|
||||
dashboardWebUrl: string;
|
||||
publicUrl: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { buildInviteUrl, resolveClineHubServerOptions } from "./options";
|
||||
import {
|
||||
buildDashboardLaunchUrl,
|
||||
resolveClineHubServerOptions,
|
||||
} from "./options";
|
||||
|
||||
function expectEqual<T>(actual: T, expected: T, label: string): void {
|
||||
if (actual !== expected) {
|
||||
@@ -21,7 +24,9 @@ const defaults = resolveClineHubServerOptions({});
|
||||
expectEqual(defaults.host, "127.0.0.1", "default host");
|
||||
expectEqual(defaults.port, 8787, "default port");
|
||||
expectEqual(defaults.publicUrl, "http://127.0.0.1:8787", "default public URL");
|
||||
expectEqual(defaults.roomSecret, undefined, "default room secret");
|
||||
if (!defaults.roomSecret) {
|
||||
throw new Error("default room secret: expected a generated secret");
|
||||
}
|
||||
|
||||
const lan = resolveClineHubServerOptions({
|
||||
HOST: "0.0.0.0",
|
||||
@@ -36,8 +41,8 @@ expectEqual(lan.publicUrl, "https://example.ngrok-free.app", "LAN public URL");
|
||||
expectEqual(lan.roomSecret, "invite-123", "LAN room secret");
|
||||
expectEqual(lan.workspaceRoot, "/tmp/workspace", "workspace root");
|
||||
expectEqual(
|
||||
buildInviteUrl(lan.publicUrl, lan.roomSecret),
|
||||
"https://example.ngrok-free.app/?roomSecret=invite-123",
|
||||
buildDashboardLaunchUrl(lan.publicUrl, lan.publicUrl, lan.roomSecret),
|
||||
"https://example.ngrok-free.app/#bridgeUrl=https%3A%2F%2Fexample.ngrok-free.app&roomSecret=invite-123",
|
||||
"invite URL",
|
||||
);
|
||||
|
||||
@@ -53,15 +58,15 @@ expectEqual(
|
||||
"direct IP public URL gets dashboard port",
|
||||
);
|
||||
expectEqual(
|
||||
buildInviteUrl(tailscale.publicUrl, tailscale.roomSecret),
|
||||
"http://100.82.5.118:8787/?roomSecret=invite-123",
|
||||
buildDashboardLaunchUrl(
|
||||
tailscale.publicUrl,
|
||||
tailscale.publicUrl,
|
||||
tailscale.roomSecret,
|
||||
),
|
||||
"http://100.82.5.118:8787/#bridgeUrl=http%3A%2F%2F100.82.5.118%3A8787&roomSecret=invite-123",
|
||||
"invite URL for direct IP public URL",
|
||||
);
|
||||
|
||||
expectThrows(
|
||||
() => resolveClineHubServerOptions({ HOST: "0.0.0.0" }),
|
||||
"non-local bind without ROOM_SECRET",
|
||||
);
|
||||
expectThrows(
|
||||
() => resolveClineHubServerOptions({ CLINE_HUB_DASHBOARD_PORT: "70000" }),
|
||||
"invalid dashboard port",
|
||||
|
||||
@@ -214,6 +214,7 @@ export type WebviewHubEvent = {
|
||||
export type WebviewHubState = {
|
||||
type: "hub_state";
|
||||
connected: boolean;
|
||||
restartable: boolean;
|
||||
hubUrl?: string;
|
||||
hubStartedAt?: string;
|
||||
coreVersion?: string;
|
||||
@@ -230,6 +231,7 @@ export type WebviewHubState = {
|
||||
export type WebviewInboundMessage =
|
||||
| { type: "ready" }
|
||||
| { type: "restart_hub" }
|
||||
| { type: "connect_hub"; hubUrl: string; authToken?: string }
|
||||
| {
|
||||
type: "desktopCommand";
|
||||
id: string;
|
||||
@@ -273,6 +275,22 @@ export type WebviewInboundMessage =
|
||||
export type WebviewOutboundMessage =
|
||||
| { type: "status"; text: string }
|
||||
| { type: "error"; text: string }
|
||||
| {
|
||||
type: "hub_connection_result";
|
||||
ok: true;
|
||||
hubUrl: string;
|
||||
}
|
||||
| {
|
||||
type: "hub_connection_result";
|
||||
ok: false;
|
||||
error: string;
|
||||
}
|
||||
| { type: "hub_restart_result"; ok: true }
|
||||
| {
|
||||
type: "hub_restart_result";
|
||||
ok: false;
|
||||
error: string;
|
||||
}
|
||||
| {
|
||||
type: "desktopCommandResult";
|
||||
id: string;
|
||||
|
||||
@@ -63,6 +63,8 @@ import type {
|
||||
import { PageFrame, PageHeader } from "./components/views/page-layout";
|
||||
import type { CustomizationSection } from "./components/views/settings/extensions-view";
|
||||
import type { SettingsSection } from "./components/views/settings/settings-view";
|
||||
import { sameHubUrl } from "./lib/hub-url";
|
||||
import { locationPath, pathWithLocationHash } from "./lib/navigation-url";
|
||||
import { syncHubTheme } from "./lib/theme";
|
||||
import { postToHost } from "./vscode";
|
||||
|
||||
@@ -136,6 +138,7 @@ const CUSTOMIZATION_VIEW_SECTIONS = {
|
||||
const EMPTY_HUB_STATE: WebviewHubState = {
|
||||
type: "hub_state",
|
||||
connected: false,
|
||||
restartable: false,
|
||||
clients: [],
|
||||
connectors: [],
|
||||
sessions: [],
|
||||
@@ -240,14 +243,15 @@ function replaceLegacyCustomizationRoute(): void {
|
||||
return;
|
||||
}
|
||||
const nextPath = routePath(VIEW_PATHS.rules);
|
||||
if (currentPathWithSearch() !== nextPath) {
|
||||
window.history.replaceState(null, "", nextPath);
|
||||
const nextLocation = pathWithLocationHash(nextPath, window.location);
|
||||
if (currentLocationPath() !== nextLocation) {
|
||||
window.history.replaceState(null, "", nextLocation);
|
||||
}
|
||||
}
|
||||
|
||||
function currentPathWithSearch(): string {
|
||||
function currentLocationPath(): string {
|
||||
if (typeof window === "undefined") return "/";
|
||||
return `${window.location.pathname}${window.location.search}`;
|
||||
return locationPath(window.location);
|
||||
}
|
||||
|
||||
function ViewLoading() {
|
||||
@@ -469,14 +473,20 @@ function Shell({
|
||||
}
|
||||
|
||||
function HomeView({
|
||||
actionError,
|
||||
connectPending,
|
||||
hubState,
|
||||
onConnectHub,
|
||||
onOpenSession,
|
||||
onRestartHub,
|
||||
onViewSessions,
|
||||
restartPending,
|
||||
recentSessions,
|
||||
}: {
|
||||
actionError?: string;
|
||||
connectPending: boolean;
|
||||
hubState: WebviewHubState;
|
||||
onConnectHub: (hubUrl: string) => void;
|
||||
onOpenSession: (sessionId: string) => void;
|
||||
onRestartHub: () => void;
|
||||
onViewSessions: () => void;
|
||||
@@ -491,6 +501,11 @@ function HomeView({
|
||||
recentSessions.length > 0 ? recentSessions : activeSessions
|
||||
).slice(0, 2);
|
||||
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
|
||||
const [hubUrlInput, setHubUrlInput] = useState(() => hubState.hubUrl ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
setHubUrlInput(hubState.hubUrl ?? "");
|
||||
}, [hubState.hubUrl]);
|
||||
|
||||
const copyText = useCallback((value?: string) => {
|
||||
if (!value || typeof navigator === "undefined") return;
|
||||
@@ -502,6 +517,18 @@ function HomeView({
|
||||
onRestartHub();
|
||||
};
|
||||
|
||||
const submitHubUrl = () => {
|
||||
const nextHubUrl = hubUrlInput.trim();
|
||||
if (!nextHubUrl) return;
|
||||
if (
|
||||
hubState.connected &&
|
||||
hubState.hubUrl &&
|
||||
sameHubUrl(nextHubUrl, hubState.hubUrl)
|
||||
)
|
||||
return;
|
||||
onConnectHub(nextHubUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageFrame>
|
||||
<PageHeader
|
||||
@@ -534,10 +561,16 @@ function HomeView({
|
||||
</span>
|
||||
</button>
|
||||
<Button
|
||||
disabled={!hubState.connected || restartPending}
|
||||
disabled={
|
||||
!hubState.connected || !hubState.restartable || restartPending
|
||||
}
|
||||
onClick={() => setRestartDialogOpen(true)}
|
||||
size="sm"
|
||||
title="Restart Cline Hub"
|
||||
title={
|
||||
hubState.restartable
|
||||
? "Restart Cline Hub"
|
||||
: "Custom hubs must be restarted externally"
|
||||
}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-7 rounded px-2 text-xs"
|
||||
@@ -550,6 +583,37 @@ function HomeView({
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="mb-5 max-w-[52rem]">
|
||||
<form
|
||||
className="flex items-center gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submitHubUrl();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
aria-label="Hub URL"
|
||||
className="h-8"
|
||||
onChange={(event) => setHubUrlInput(event.target.value)}
|
||||
placeholder="ws://127.0.0.1:25463/hub?authToken=..."
|
||||
value={hubUrlInput}
|
||||
/>
|
||||
<Button
|
||||
className="h-8 rounded px-2"
|
||||
disabled={connectPending}
|
||||
size="sm"
|
||||
type="submit"
|
||||
>
|
||||
<LinkIcon className="size-3.5" />
|
||||
<span>{connectPending ? "Connecting" : "Connect"}</span>
|
||||
</Button>
|
||||
</form>
|
||||
{actionError ? (
|
||||
<p className="mt-2 text-sm text-destructive" role="alert">
|
||||
{actionError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<AlertDialog
|
||||
open={restartDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
@@ -572,7 +636,9 @@ function HomeView({
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={!hubState.connected || restartPending}
|
||||
disabled={
|
||||
!hubState.connected || !hubState.restartable || restartPending
|
||||
}
|
||||
onClick={confirmRestartHub}
|
||||
variant="destructive"
|
||||
>
|
||||
@@ -1118,6 +1184,8 @@ function App() {
|
||||
readCurrentSettingsSection(),
|
||||
);
|
||||
const [hubState, setHubState] = useState<WebviewHubState>(EMPTY_HUB_STATE);
|
||||
const [connectPending, setConnectPending] = useState(false);
|
||||
const [hubActionError, setHubActionError] = useState<string | undefined>();
|
||||
const [restartPending, setRestartPending] = useState(false);
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<
|
||||
string | undefined
|
||||
@@ -1160,6 +1228,16 @@ function App() {
|
||||
}
|
||||
if (message.type === "sessions") {
|
||||
setRecentSessions(message.sessions);
|
||||
return;
|
||||
}
|
||||
if (message.type === "hub_connection_result") {
|
||||
setConnectPending(false);
|
||||
setHubActionError(message.ok ? undefined : message.error);
|
||||
return;
|
||||
}
|
||||
if (message.type === "hub_restart_result") {
|
||||
setRestartPending(false);
|
||||
setHubActionError(message.ok ? undefined : message.error);
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", handleMessage);
|
||||
@@ -1169,9 +1247,16 @@ function App() {
|
||||
|
||||
const restartHub = useCallback(() => {
|
||||
setRestartPending(true);
|
||||
setHubActionError(undefined);
|
||||
postToHost({ type: "restart_hub" });
|
||||
}, []);
|
||||
|
||||
const connectHub = useCallback((hubUrl: string) => {
|
||||
setConnectPending(true);
|
||||
setHubActionError(undefined);
|
||||
postToHost({ type: "connect_hub", hubUrl });
|
||||
}, []);
|
||||
|
||||
const navigate = useCallback((nextView: View) => {
|
||||
if (nextView === "chat") {
|
||||
setSelectedSessionId(undefined);
|
||||
@@ -1183,8 +1268,9 @@ function App() {
|
||||
setSelectedSessionId(undefined);
|
||||
}
|
||||
const nextPath = routePath(VIEW_PATHS[nextView]);
|
||||
if (currentPathWithSearch() !== nextPath) {
|
||||
window.history.pushState(null, "", nextPath);
|
||||
const nextLocation = pathWithLocationHash(nextPath, window.location);
|
||||
if (currentLocationPath() !== nextLocation) {
|
||||
window.history.pushState(null, "", nextLocation);
|
||||
}
|
||||
setView(nextView);
|
||||
}, []);
|
||||
@@ -1192,8 +1278,9 @@ function App() {
|
||||
const openSession = useCallback((sessionId: string) => {
|
||||
setSelectedSessionId(sessionId);
|
||||
const nextPath = chatPath(sessionId);
|
||||
if (currentPathWithSearch() !== nextPath) {
|
||||
window.history.pushState(null, "", nextPath);
|
||||
const nextLocation = pathWithLocationHash(nextPath, window.location);
|
||||
if (currentLocationPath() !== nextLocation) {
|
||||
window.history.pushState(null, "", nextLocation);
|
||||
}
|
||||
setView("chat");
|
||||
}, []);
|
||||
@@ -1201,8 +1288,9 @@ function App() {
|
||||
const updateChatSessionRoute = useCallback((sessionId?: string) => {
|
||||
setSelectedSessionId(sessionId);
|
||||
const nextPath = chatPath(sessionId);
|
||||
if (currentPathWithSearch() !== nextPath) {
|
||||
window.history.replaceState(null, "", nextPath);
|
||||
const nextLocation = pathWithLocationHash(nextPath, window.location);
|
||||
if (currentLocationPath() !== nextLocation) {
|
||||
window.history.replaceState(null, "", nextLocation);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -1328,7 +1416,10 @@ function App() {
|
||||
}
|
||||
return (
|
||||
<HomeView
|
||||
actionError={hubActionError}
|
||||
connectPending={connectPending}
|
||||
hubState={hubState}
|
||||
onConnectHub={connectHub}
|
||||
onOpenSession={openSession}
|
||||
onRestartHub={restartHub}
|
||||
onViewSessions={() => navigate("sessions")}
|
||||
@@ -1337,7 +1428,10 @@ function App() {
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
connectPending,
|
||||
hubActionError,
|
||||
hubState,
|
||||
connectHub,
|
||||
deleteSession,
|
||||
navigate,
|
||||
openSession,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sameHubUrl } from "./hub-url";
|
||||
|
||||
describe("sameHubUrl", () => {
|
||||
it("ignores fragments that are not sent to the hub", () => {
|
||||
expect(
|
||||
sameHubUrl(
|
||||
"ws://127.0.0.1:25463/hub#dashboard",
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("treats an auth token change as a new connection target", () => {
|
||||
expect(
|
||||
sameHubUrl(
|
||||
"ws://127.0.0.1:25463/hub?authToken=new-token",
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
sameHubUrl(
|
||||
"ws://127.0.0.1:25463/hub?authToken=new-token",
|
||||
"ws://127.0.0.1:25463/hub?authToken=old-token",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
export function sameHubUrl(left: string, right: string): boolean {
|
||||
try {
|
||||
const leftUrl = new URL(left);
|
||||
const rightUrl = new URL(right);
|
||||
leftUrl.hash = "";
|
||||
rightUrl.hash = "";
|
||||
return leftUrl.toString() === rightUrl.toString();
|
||||
} catch {
|
||||
return left.trim() === right.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { locationPath, pathWithLocationHash } from "./navigation-url";
|
||||
|
||||
const inviteHash =
|
||||
"#bridgeUrl=ws%3A%2F%2F127.0.0.1%3A8787&roomSecret=dashboard-secret";
|
||||
|
||||
describe("dashboard navigation URLs", () => {
|
||||
it("includes the invite fragment in the current location", () => {
|
||||
expect(
|
||||
locationPath({
|
||||
pathname: "/sessions",
|
||||
search: "?filter=active",
|
||||
hash: inviteHash,
|
||||
}),
|
||||
).toBe(`/sessions?filter=active${inviteHash}`);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"/",
|
||||
"/settings",
|
||||
"/chat?sessionId=session-1",
|
||||
])("preserves bridge credentials while navigating to %s", (path) => {
|
||||
expect(pathWithLocationHash(path, { hash: inviteHash })).toBe(
|
||||
`${path}${inviteHash}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
interface BrowserLocationParts {
|
||||
pathname: string;
|
||||
search: string;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
export function locationPath(location: BrowserLocationParts): string {
|
||||
return `${location.pathname}${location.search}${location.hash}`;
|
||||
}
|
||||
|
||||
export function pathWithLocationHash(
|
||||
path: string,
|
||||
location: Pick<BrowserLocationParts, "hash">,
|
||||
): string {
|
||||
return `${path}${location.hash}`;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const browserConnectionKey = "cline-hub-browser-connection";
|
||||
|
||||
function stubBrowserWindow(
|
||||
url: string,
|
||||
persistedConnection?: Record<string, unknown>,
|
||||
): Map<string, string> {
|
||||
const storage = new Map<string, string>();
|
||||
if (persistedConnection) {
|
||||
storage.set(browserConnectionKey, JSON.stringify(persistedConnection));
|
||||
}
|
||||
vi.stubGlobal("window", {
|
||||
location: new URL(url),
|
||||
localStorage: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
},
|
||||
});
|
||||
return storage;
|
||||
}
|
||||
|
||||
describe("browser dashboard connection target", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("uses the current invite secret without persisting rotating credentials", async () => {
|
||||
const storage = stubBrowserWindow(
|
||||
"https://cline.bot/dashboard#bridgeUrl=http%3A%2F%2F127.0.0.1%3A8787&roomSecret=fresh-secret",
|
||||
{
|
||||
bridgeUrl: "http://127.0.0.1:9999",
|
||||
roomSecret: "stale-secret",
|
||||
},
|
||||
);
|
||||
const { readBrowserConnectionTarget, writeBrowserConnectionTarget } =
|
||||
await import("./vscode");
|
||||
|
||||
expect(readBrowserConnectionTarget()).toEqual({
|
||||
bridgeUrl: "http://127.0.0.1:8787",
|
||||
roomSecret: "fresh-secret",
|
||||
});
|
||||
|
||||
writeBrowserConnectionTarget({ bridgeUrl: "http://127.0.0.1:8787" });
|
||||
expect(JSON.parse(storage.get(browserConnectionKey) ?? "{}")).toEqual({
|
||||
bridgeUrl: "http://127.0.0.1:8787",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores a room secret left by an older dashboard process", async () => {
|
||||
stubBrowserWindow("https://cline.bot/dashboard", {
|
||||
bridgeUrl: "http://127.0.0.1:8787",
|
||||
roomSecret: "stale-secret",
|
||||
});
|
||||
const { readBrowserConnectionTarget } = await import("./vscode");
|
||||
|
||||
expect(readBrowserConnectionTarget()).toEqual({
|
||||
bridgeUrl: "http://127.0.0.1:8787",
|
||||
roomSecret: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -19,16 +19,100 @@ let cachedApi: VsCodeApi | undefined;
|
||||
let browserSocket: WebSocket | undefined;
|
||||
const pendingMessages: WebviewInboundMessage[] = [];
|
||||
const stateKey = "cline-hub-webview-state";
|
||||
const browserConnectionKey = "cline-hub-browser-connection";
|
||||
|
||||
type BrowserConnectionTarget = {
|
||||
bridgeUrl?: string;
|
||||
roomSecret?: string;
|
||||
};
|
||||
|
||||
type PersistedBrowserConnectionTarget = Pick<
|
||||
BrowserConnectionTarget,
|
||||
"bridgeUrl"
|
||||
>;
|
||||
|
||||
function dispatchHostMessage(message: WebviewOutboundMessage): void {
|
||||
window.dispatchEvent(new MessageEvent("message", { data: message }));
|
||||
}
|
||||
|
||||
function readBrowserRoomSecret(): string | undefined {
|
||||
const roomSecret = new URLSearchParams(window.location.search)
|
||||
.get("roomSecret")
|
||||
?.trim();
|
||||
return roomSecret || undefined;
|
||||
function readFragmentParams(): URLSearchParams {
|
||||
return new URLSearchParams(window.location.hash.replace(/^#/, ""));
|
||||
}
|
||||
|
||||
function readPersistedBrowserConnection(): PersistedBrowserConnectionTarget {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(browserConnectionKey);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
const bridgeUrl =
|
||||
typeof parsed.bridgeUrl === "string" ? parsed.bridgeUrl.trim() : "";
|
||||
return bridgeUrl ? { bridgeUrl } : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function readBrowserConnectionTarget(): BrowserConnectionTarget {
|
||||
if (typeof window === "undefined") return {};
|
||||
const fragment = readFragmentParams();
|
||||
const search = new URLSearchParams(window.location.search);
|
||||
const persisted = readPersistedBrowserConnection();
|
||||
return {
|
||||
bridgeUrl:
|
||||
fragment.get("bridgeUrl")?.trim() ||
|
||||
fragment.get("bridge")?.trim() ||
|
||||
search.get("bridgeUrl")?.trim() ||
|
||||
persisted.bridgeUrl,
|
||||
// Per-process secrets rotate whenever the dashboard restarts, so a
|
||||
// persisted value can only be stale (and should not live in storage).
|
||||
roomSecret:
|
||||
fragment.get("roomSecret")?.trim() ||
|
||||
search.get("roomSecret")?.trim() ||
|
||||
undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function writeBrowserConnectionTarget(
|
||||
target: PersistedBrowserConnectionTarget,
|
||||
): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const bridgeUrl =
|
||||
target.bridgeUrl?.trim() ||
|
||||
readPersistedBrowserConnection().bridgeUrl?.trim();
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
browserConnectionKey,
|
||||
JSON.stringify(bridgeUrl ? { bridgeUrl } : {}),
|
||||
);
|
||||
} catch {
|
||||
// Browser persistence is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBrowserSocketUrl(): string {
|
||||
const target = readBrowserConnectionTarget();
|
||||
const bridgeUrl = target.bridgeUrl?.trim();
|
||||
if (bridgeUrl) {
|
||||
writeBrowserConnectionTarget({ bridgeUrl });
|
||||
}
|
||||
const base = bridgeUrl ? new URL(bridgeUrl) : new URL(window.location.href);
|
||||
const protocol =
|
||||
base.protocol === "https:"
|
||||
? "wss:"
|
||||
: base.protocol === "http:"
|
||||
? "ws:"
|
||||
: "";
|
||||
if (!protocol) {
|
||||
throw new Error(`Unsupported dashboard bridge protocol: ${base.protocol}`);
|
||||
}
|
||||
base.protocol = protocol;
|
||||
base.pathname = "/browser";
|
||||
base.search = "";
|
||||
base.hash = "";
|
||||
if (target.roomSecret?.trim()) {
|
||||
base.searchParams.set("roomSecret", target.roomSecret.trim());
|
||||
}
|
||||
return base.toString();
|
||||
}
|
||||
|
||||
function createBrowserSocket(): WebSocket {
|
||||
@@ -40,15 +124,7 @@ function createBrowserSocket(): WebSocket {
|
||||
return browserSocket;
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const params = new URLSearchParams();
|
||||
const roomSecret = readBrowserRoomSecret();
|
||||
if (roomSecret) {
|
||||
params.set("roomSecret", roomSecret);
|
||||
}
|
||||
const query = params.toString();
|
||||
const socketUrl = `${protocol}//${window.location.host}/browser${query ? `?${query}` : ""}`;
|
||||
browserSocket = new WebSocket(socketUrl);
|
||||
browserSocket = new WebSocket(resolveBrowserSocketUrl());
|
||||
browserSocket.addEventListener("open", () => {
|
||||
for (const message of pendingMessages.splice(0)) {
|
||||
browserSocket?.send(JSON.stringify(message));
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
"../../sdk/packages/core/src/*",
|
||||
"../../sdk/packages/core/src/*/index.ts"
|
||||
],
|
||||
"@cline/llms": ["../../sdk/packages/llms/src/index.ts"],
|
||||
"@cline/llms/*": [
|
||||
"../../sdk/packages/llms/src/*",
|
||||
"../../sdk/packages/llms/src/*/index.ts"
|
||||
],
|
||||
"@cline/shared": ["../../sdk/packages/shared/src/index.ts"],
|
||||
"@cline/shared/*": [
|
||||
"../../sdk/packages/shared/src/*",
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
captureExtensionActivated,
|
||||
createConfiguredTelemetryService,
|
||||
createLocalHubScheduleRuntimeHandlers,
|
||||
ensureHubWebSocketServer,
|
||||
ensureHubServer,
|
||||
type ITelemetryService,
|
||||
Llms,
|
||||
NodeHubClient,
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type RuntimeCapabilities,
|
||||
readHubDiscovery,
|
||||
rememberRecoverableLocalHubUrl,
|
||||
resolveSharedHubOwnerContext,
|
||||
resolveDefaultHubOwnerContext,
|
||||
type ToolPolicy,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
@@ -698,7 +698,7 @@ class CoreChatWebviewController implements vscode.Disposable {
|
||||
}
|
||||
|
||||
private async discoverOrStartHub(): Promise<HubResolution | undefined> {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveDefaultHubOwnerContext();
|
||||
|
||||
if (this.hubUrl) {
|
||||
const healthy = await probeHubServer(this.hubUrl, {
|
||||
@@ -715,8 +715,7 @@ class CoreChatWebviewController implements vscode.Disposable {
|
||||
const discovered = await this.probeDiscoveredHub(owner.discoveryPath);
|
||||
if (discovered) return discovered;
|
||||
|
||||
await ensureHubWebSocketServer({
|
||||
owner,
|
||||
await ensureHubServer({
|
||||
allowPortFallback: true,
|
||||
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
|
||||
});
|
||||
|
||||
@@ -3,8 +3,7 @@ import {
|
||||
type ConfiguredTelemetryHandle,
|
||||
createConfiguredTelemetryHandle,
|
||||
createLocalHubScheduleRuntimeHandlers,
|
||||
ensureHubWebSocketServer,
|
||||
resolveSharedHubOwnerContext,
|
||||
ensureHubServer,
|
||||
} from "@cline/core/hub";
|
||||
import {
|
||||
createClineTelemetryServiceConfig,
|
||||
@@ -71,8 +70,7 @@ async function main(): Promise<void> {
|
||||
const config = parseConfig(process.argv);
|
||||
const telemetryHandle = createDaemonTelemetry(config.telemetryMetadata);
|
||||
|
||||
const ensured = await ensureHubWebSocketServer({
|
||||
owner: resolveSharedHubOwnerContext(),
|
||||
const ensured = await ensureHubServer({
|
||||
runtimeHandlers: createLocalHubScheduleRuntimeHandlers({
|
||||
telemetry: telemetryHandle?.telemetry,
|
||||
}),
|
||||
|
||||
+20
-6
@@ -142,12 +142,13 @@ event payload and `source` field.
|
||||
1. Host constructs a `RuntimeHost` through `@cline/core`.
|
||||
2. `@cline/core` selects `HubRuntimeHost` or `RemoteRuntimeHost` through `packages/core/src/runtime/host.ts`.
|
||||
3. When no compatible local hub is already discovered, `@cline/core` can spawn a detached hub daemon and reconnect through discovery.
|
||||
4. Hosts attach and detach from shared sessions without stopping the authority runtime, so another client can keep streaming or resume the same session later.
|
||||
5. The hub-hosted runtime executes the agent loop using `@cline/agents` and `@cline/llms`.
|
||||
6. `@cline/core` hub services broker sessions, events, approvals, schedules, and client-owned runtime capabilities such as session-local tool executors.
|
||||
7. Hub event forwarding preserves structured streaming lifecycle boundaries: text/reasoning deltas, final text/reasoning completion, tool start/finish, and agent done events are translated across the hub transport so host UIs can reliably close loading/streaming state.
|
||||
8. Hub client adapters exported from `@cline/core/hub` (`NodeHubClient`, `HubSessionClient`, `HubUIClient`, `connectToHub`) translate command/reply and event streams into host-facing APIs.
|
||||
9. Hub `session.get` records include both canonical root-session usage and explicit aggregate usage from the hub-owned `RuntimeHost`, so attached clients can intentionally render either root-only or root-plus-teammate costs without replaying event streams.
|
||||
4. CLI-launched hub daemons receive a dashboard launch spec from `apps/cli`. After the daemon starts, it stops any discovered dashboard process, spawns a fresh `cline dashboard serve` process, and records that dashboard in hub dashboard discovery.
|
||||
5. Hosts attach and detach from shared sessions without stopping the authority runtime, so another client can keep streaming or resume the same session later.
|
||||
6. The hub-hosted runtime executes the agent loop using `@cline/agents` and `@cline/llms`.
|
||||
7. `@cline/core` hub services broker sessions, events, approvals, schedules, and client-owned runtime capabilities such as session-local tool executors.
|
||||
8. Hub event forwarding preserves structured streaming lifecycle boundaries: text/reasoning deltas, final text/reasoning completion, tool start/finish, and agent done events are translated across the hub transport so host UIs can reliably close loading/streaming state.
|
||||
9. Hub client adapters exported from `@cline/core/hub` (`NodeHubClient`, `HubSessionClient`, `HubUIClient`, `connectToHub`) translate command/reply and event streams into host-facing APIs.
|
||||
10. Hub `session.get` records include both canonical root-session usage and explicit aggregate usage from the hub-owned `RuntimeHost`, so attached clients can intentionally render either root-only or root-plus-teammate costs without replaying event streams.
|
||||
|
||||
Workspace bootstrap is owned by the runtime that executes the session. Hub
|
||||
clients preserve an omitted `cwd` and `workspaceRoot` across the transport so
|
||||
@@ -165,6 +166,19 @@ Detached daemon startup retries transient `ETXTBSY` spawn failures before
|
||||
polling discovery. This covers package-manager updates that replace the CLI
|
||||
binary immediately before a command restarts the shared hub.
|
||||
|
||||
The local hub also owns the browser dashboard lifecycle when a dashboard launch
|
||||
spec is available. `cline dashboard` is only a controller: it ensures a detached
|
||||
dashboard exists, opens the discovered invite URL, and exits. The hidden
|
||||
`cline dashboard serve` process hosts the dashboard and writes
|
||||
owner-permissioned dashboard discovery; `cline dashboard stop` and
|
||||
`cline dashboard restart` use that same discovery record for manual lifecycle
|
||||
control. Dashboard children never inherit the hub-daemon marker environment, so
|
||||
they start the dashboard command instead of recursively entering the hub daemon.
|
||||
Dashboard-initiated hub restarts preserve the current dashboard through both
|
||||
the old daemon's authenticated shutdown and the replacement daemon's startup,
|
||||
then transactionally swap the dashboard's hub clients after the new connection
|
||||
is established.
|
||||
|
||||
Local hub discovery also carries the authentication contract for the shared
|
||||
daemon. On startup, the hub server generates a cryptographically random
|
||||
per-process auth token, stores it in the owner discovery record, and writes that
|
||||
|
||||
@@ -3,16 +3,12 @@ import type {
|
||||
HubReplyEnvelope,
|
||||
HubTransportFrame,
|
||||
} from "@cline/shared";
|
||||
import { resolveClineBuildEnv } from "@cline/shared";
|
||||
import { createHubServerUrl, readHubDiscovery } from "../discovery";
|
||||
import {
|
||||
type HubEndpointOverrides,
|
||||
resolveHubEndpointOptions,
|
||||
} from "../discovery/defaults";
|
||||
import {
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
} from "../discovery/workspace";
|
||||
import { resolveDefaultHubOwnerContext } from "../discovery/workspace";
|
||||
|
||||
export interface HubConnection {
|
||||
send(envelope: HubCommandEnvelope): Promise<HubReplyEnvelope>;
|
||||
@@ -72,12 +68,6 @@ function sameHubEndpoint(left: string, right: string): boolean {
|
||||
return leftUrl.toString() === rightUrl.toString();
|
||||
}
|
||||
|
||||
function resolveDefaultHubOwnerContext() {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
async function resolveHubUrlAuthToken(url: URL): Promise<string | undefined> {
|
||||
const queryToken = url.searchParams.get("authToken")?.trim();
|
||||
url.searchParams.delete("authToken");
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
HubTransportError,
|
||||
isHubReconnectableTransportError,
|
||||
NodeHubClient,
|
||||
requestHubShutdown,
|
||||
} from "../client";
|
||||
|
||||
type SocketListener = (...args: unknown[]) => void;
|
||||
@@ -144,6 +145,34 @@ class FakeWebSocket {
|
||||
}
|
||||
}
|
||||
|
||||
describe("requestHubShutdown", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("marks dashboard-initiated shutdowns for preservation", async () => {
|
||||
const fetchMock = vi.fn(async () => ({ ok: true }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
requestHubShutdown("ws://127.0.0.1:25463/hub", "hub-token", {
|
||||
preserveDashboard: true,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
new URL("http://127.0.0.1:25463/shutdown"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: "Bearer hub-token",
|
||||
"x-cline-preserve-dashboard": "1",
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NodeHubClient", () => {
|
||||
describe("subscription re-registration", () => {
|
||||
afterEach(() => {
|
||||
@@ -564,6 +593,10 @@ describe("NodeHubClient", () => {
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery-recovery.json",
|
||||
}),
|
||||
resolveDefaultHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
}),
|
||||
}));
|
||||
vi.doMock("../discovery", async () => {
|
||||
const actual =
|
||||
@@ -719,6 +752,10 @@ describe("NodeHubClient", () => {
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery-explicit.json",
|
||||
}),
|
||||
resolveDefaultHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
}),
|
||||
}));
|
||||
vi.doMock("../discovery", async () => {
|
||||
const actual =
|
||||
@@ -790,6 +827,10 @@ describe("resolveCompatibleLocalHubUrl", () => {
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
}),
|
||||
resolveDefaultHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
}),
|
||||
}));
|
||||
vi.doMock("../discovery", async () => {
|
||||
const actual =
|
||||
@@ -831,6 +872,10 @@ describe("resolveCompatibleLocalHubUrl", () => {
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
}),
|
||||
resolveDefaultHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
}),
|
||||
}));
|
||||
vi.doMock("../discovery", async () => {
|
||||
const actual =
|
||||
@@ -883,6 +928,10 @@ describe("resolveCompatibleLocalHubUrl", () => {
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
}),
|
||||
resolveDefaultHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
}),
|
||||
}));
|
||||
vi.doMock("../discovery", async () => {
|
||||
const actual =
|
||||
@@ -933,6 +982,10 @@ describe("resolveCompatibleLocalHubUrl", () => {
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
}),
|
||||
resolveDefaultHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
}),
|
||||
}));
|
||||
vi.doMock("../discovery", async () => {
|
||||
const actual =
|
||||
@@ -1003,6 +1056,10 @@ describe("resolveCompatibleLocalHubUrl", () => {
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
}),
|
||||
resolveDefaultHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
}),
|
||||
}));
|
||||
vi.doMock("../discovery", async () => {
|
||||
const actual =
|
||||
@@ -1066,6 +1123,16 @@ describe("resolveCompatibleLocalHubUrl", () => {
|
||||
ownerId: "shared",
|
||||
discoveryPath: "/tmp/shared-hub-discovery.json",
|
||||
}),
|
||||
resolveDefaultHubOwnerContext: () =>
|
||||
process.env.CLINE_BUILD_ENV === "development"
|
||||
? {
|
||||
ownerId: "shared",
|
||||
discoveryPath: "/tmp/shared-hub-discovery.json",
|
||||
}
|
||||
: {
|
||||
ownerId: "production",
|
||||
discoveryPath: "/tmp/production-hub-discovery.json",
|
||||
},
|
||||
}));
|
||||
vi.doMock("../discovery", async () => {
|
||||
const actual =
|
||||
@@ -1123,6 +1190,10 @@ describe("resolveCompatibleLocalHubUrl", () => {
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
}),
|
||||
resolveDefaultHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
}),
|
||||
}));
|
||||
vi.doMock("../daemon", () => ({
|
||||
spawnDetachedHubServerWithRetry: vi.fn(async () => undefined),
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
type HubReplyEnvelope,
|
||||
type HubTransportFrame,
|
||||
isHubProtocolCompatible,
|
||||
resolveClineBuildEnv,
|
||||
resolveHubCommandTimeoutMs,
|
||||
} from "@cline/shared";
|
||||
import {
|
||||
@@ -20,10 +19,7 @@ import {
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
} from "../discovery";
|
||||
import {
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
} from "../discovery/workspace";
|
||||
import { resolveDefaultHubOwnerContext } from "../discovery/workspace";
|
||||
|
||||
type PendingReply = {
|
||||
resolve: (reply: HubReplyEnvelope) => void;
|
||||
@@ -35,12 +31,6 @@ type SubscriptionEntry = {
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
function resolveDefaultHubOwnerContext(): HubOwnerContext {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
type WebSocketLike = {
|
||||
readyState: number;
|
||||
send(data: string): void;
|
||||
@@ -1032,6 +1022,7 @@ export async function ensureCompatibleLocalHubUrl(
|
||||
export async function requestHubShutdown(
|
||||
url: string,
|
||||
authToken?: string,
|
||||
options: { preserveDashboard?: boolean } = {},
|
||||
): Promise<boolean> {
|
||||
const parsed = new URL(url);
|
||||
const resolvedAuthToken =
|
||||
@@ -1045,16 +1036,22 @@ export async function requestHubShutdown(
|
||||
parsed.hash = "";
|
||||
const response = await fetch(parsed, {
|
||||
method: "POST",
|
||||
headers: resolvedAuthToken
|
||||
? { authorization: `Bearer ${resolvedAuthToken}` }
|
||||
: undefined,
|
||||
headers: {
|
||||
...(resolvedAuthToken
|
||||
? { authorization: `Bearer ${resolvedAuthToken}` }
|
||||
: {}),
|
||||
...(options.preserveDashboard
|
||||
? { "x-cline-preserve-dashboard": "1" }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
return response.ok;
|
||||
}
|
||||
|
||||
export async function stopLocalHubServerGracefully(
|
||||
owner: HubOwnerContext = resolveDefaultHubOwnerContext(),
|
||||
options: { owner?: HubOwnerContext; preserveDashboard?: boolean } = {},
|
||||
): Promise<boolean> {
|
||||
const owner = options.owner ?? resolveDefaultHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
if (!discovery?.url) {
|
||||
return false;
|
||||
@@ -1063,9 +1060,10 @@ export async function stopLocalHubServerGracefully(
|
||||
const stopped = await requestHubShutdown(
|
||||
discovery.url,
|
||||
discovery.authToken,
|
||||
{ preserveDashboard: options.preserveDashboard },
|
||||
);
|
||||
if (stopped) {
|
||||
return true;
|
||||
return await waitForHubToRetire(discovery.url);
|
||||
}
|
||||
} catch {
|
||||
// Fall through so callers can apply a stronger fallback.
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
spawn,
|
||||
clearHubDashboardDiscovery,
|
||||
isHubDashboardPidAlive,
|
||||
readHubDashboardDiscovery,
|
||||
} = vi.hoisted(() => ({
|
||||
spawn: vi.fn(() => ({ unref: vi.fn() })),
|
||||
clearHubDashboardDiscovery: vi.fn(async () => undefined),
|
||||
isHubDashboardPidAlive: vi.fn(() => false),
|
||||
readHubDashboardDiscovery: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
spawn,
|
||||
}));
|
||||
|
||||
vi.mock("../dashboard-discovery", () => ({
|
||||
CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV: "CLINE_HUB_DASHBOARD_DISCOVERY_PATH",
|
||||
clearHubDashboardDiscovery,
|
||||
isHubDashboardPidAlive,
|
||||
readHubDashboardDiscovery,
|
||||
}));
|
||||
|
||||
describe("managed hub dashboard process", () => {
|
||||
beforeEach(() => {
|
||||
spawn.mockReset();
|
||||
spawn.mockImplementation(() => ({ unref: vi.fn() }));
|
||||
clearHubDashboardDiscovery.mockClear();
|
||||
isHubDashboardPidAlive.mockReset();
|
||||
isHubDashboardPidAlive.mockReturnValue(false);
|
||||
readHubDashboardDiscovery.mockReset();
|
||||
readHubDashboardDiscovery.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("spawns the configured dashboard command without the hub daemon marker", async () => {
|
||||
const { restartManagedHubDashboardProcess } = await import(
|
||||
"./dashboard-process"
|
||||
);
|
||||
|
||||
await restartManagedHubDashboardProcess({
|
||||
discoveryPath: "/tmp/dashboard.json",
|
||||
cwd: "/workspace",
|
||||
env: {
|
||||
CLINE_HUB_DASHBOARD_LAUNCHER: "bun",
|
||||
CLINE_HUB_DASHBOARD_ARGS: JSON.stringify([
|
||||
"cline",
|
||||
"dashboard",
|
||||
"serve",
|
||||
]),
|
||||
CLINE_RUN_AS_HUB_DAEMON: "1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
"bun",
|
||||
["cline", "dashboard", "serve"],
|
||||
expect.objectContaining({
|
||||
cwd: "/workspace",
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
env: expect.objectContaining({
|
||||
CLINE_HUB_DASHBOARD_DISCOVERY_PATH: "/tmp/dashboard.json",
|
||||
CLINE_NO_INTERACTIVE: "1",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const call = spawn.mock.calls[0] as unknown as
|
||||
| [string, string[], { env?: NodeJS.ProcessEnv }]
|
||||
| undefined;
|
||||
const env = call?.[2].env;
|
||||
expect(env?.CLINE_RUN_AS_HUB_DAEMON).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does nothing without a configured dashboard command", async () => {
|
||||
const { restartManagedHubDashboardProcess } = await import(
|
||||
"./dashboard-process"
|
||||
);
|
||||
|
||||
await restartManagedHubDashboardProcess({
|
||||
discoveryPath: "/tmp/dashboard.json",
|
||||
cwd: "/workspace",
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves the existing dashboard for a hub restart", async () => {
|
||||
readHubDashboardDiscovery.mockResolvedValue({
|
||||
pid: 4242,
|
||||
listenUrl: "http://127.0.0.1:8787/",
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
inviteUrl: "http://127.0.0.1:8787",
|
||||
startedAt: "2026-06-22T20:00:00.000Z",
|
||||
updatedAt: "2026-06-22T20:00:00.000Z",
|
||||
});
|
||||
const { restartManagedHubDashboardProcess } = await import(
|
||||
"./dashboard-process"
|
||||
);
|
||||
|
||||
await restartManagedHubDashboardProcess({
|
||||
discoveryPath: "/tmp/dashboard.json",
|
||||
cwd: "/workspace",
|
||||
env: {
|
||||
CLINE_HUB_DASHBOARD_LAUNCHER: "bun",
|
||||
CLINE_HUB_DASHBOARD_ARGS: JSON.stringify([
|
||||
"cline",
|
||||
"dashboard",
|
||||
"serve",
|
||||
]),
|
||||
CLINE_HUB_PRESERVE_DASHBOARD: "1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(readHubDashboardDiscovery).not.toHaveBeenCalled();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps discovery and rejects when the dashboard ignores SIGTERM", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
isHubDashboardPidAlive.mockReturnValue(true);
|
||||
readHubDashboardDiscovery.mockResolvedValue({
|
||||
pid: 4242,
|
||||
listenUrl: "http://127.0.0.1:8787/",
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
inviteUrl: "http://127.0.0.1:8787",
|
||||
startedAt: "2026-06-22T20:00:00.000Z",
|
||||
updatedAt: "2026-06-22T20:00:00.000Z",
|
||||
});
|
||||
const { stopManagedHubDashboardProcess } = await import(
|
||||
"./dashboard-process"
|
||||
);
|
||||
|
||||
const stopping = stopManagedHubDashboardProcess("/tmp/dashboard.json");
|
||||
const expectedRejection = expect(stopping).rejects.toThrow(
|
||||
"Timed out waiting for dashboard process 4242 to stop.",
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(3_000);
|
||||
|
||||
await expectedRejection;
|
||||
expect(clearHubDashboardDiscovery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears discovery after stopping the dashboard that owns it", async () => {
|
||||
vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
isHubDashboardPidAlive.mockReturnValue(false);
|
||||
const dashboard = {
|
||||
pid: 4242,
|
||||
listenUrl: "http://127.0.0.1:8787/",
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
inviteUrl: "http://127.0.0.1:8787",
|
||||
startedAt: "2026-06-22T20:00:00.000Z",
|
||||
updatedAt: "2026-06-22T20:00:00.000Z",
|
||||
};
|
||||
readHubDashboardDiscovery.mockResolvedValue(dashboard);
|
||||
const { stopManagedHubDashboardProcess } = await import(
|
||||
"./dashboard-process"
|
||||
);
|
||||
|
||||
await expect(
|
||||
stopManagedHubDashboardProcess("/tmp/dashboard.json"),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(clearHubDashboardDiscovery).toHaveBeenCalledWith(
|
||||
"/tmp/dashboard.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not clear discovery replaced while the old dashboard exits", async () => {
|
||||
vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
isHubDashboardPidAlive.mockReturnValue(false);
|
||||
const oldDashboard = {
|
||||
pid: 4242,
|
||||
listenUrl: "http://127.0.0.1:8787/",
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
inviteUrl: "http://127.0.0.1:8787",
|
||||
startedAt: "2026-06-22T20:00:00.000Z",
|
||||
updatedAt: "2026-06-22T20:00:00.000Z",
|
||||
};
|
||||
readHubDashboardDiscovery
|
||||
.mockResolvedValueOnce(oldDashboard)
|
||||
.mockResolvedValueOnce({ ...oldDashboard, pid: 4343 });
|
||||
const { stopManagedHubDashboardProcess } = await import(
|
||||
"./dashboard-process"
|
||||
);
|
||||
|
||||
await expect(
|
||||
stopManagedHubDashboardProcess("/tmp/dashboard.json"),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(process.kill).toHaveBeenCalledWith(4242, "SIGTERM");
|
||||
expect(clearHubDashboardDiscovery).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { CLINE_RUN_AS_HUB_DAEMON_ENV } from "@cline/shared";
|
||||
import {
|
||||
CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV,
|
||||
clearHubDashboardDiscovery,
|
||||
type HubDashboardDiscoveryRecord,
|
||||
isHubDashboardPidAlive,
|
||||
readHubDashboardDiscovery,
|
||||
} from "../dashboard-discovery";
|
||||
|
||||
const DASHBOARD_LAUNCHER_ENV = "CLINE_HUB_DASHBOARD_LAUNCHER";
|
||||
const DASHBOARD_ARGS_ENV = "CLINE_HUB_DASHBOARD_ARGS";
|
||||
const DASHBOARD_STOP_TIMEOUT_MS = 3_000;
|
||||
const DASHBOARD_STOP_POLL_MS = 100;
|
||||
|
||||
/**
|
||||
* Set on a replacement hub daemon when the dashboard initiating the restart
|
||||
* must remain alive and attached to the new daemon.
|
||||
*/
|
||||
export const CLINE_HUB_PRESERVE_DASHBOARD_ENV = "CLINE_HUB_PRESERVE_DASHBOARD";
|
||||
|
||||
async function waitForPidToExit(pid: number): Promise<boolean> {
|
||||
const deadline = Date.now() + DASHBOARD_STOP_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
if (!isHubDashboardPidAlive(pid)) {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, DASHBOARD_STOP_POLL_MS));
|
||||
}
|
||||
return !isHubDashboardPidAlive(pid);
|
||||
}
|
||||
|
||||
function parseDashboardArgs(env: NodeJS.ProcessEnv): string[] | undefined {
|
||||
const raw = env[DASHBOARD_ARGS_ENV]?.trim();
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
const args = parsed.filter((value): value is string => {
|
||||
return typeof value === "string";
|
||||
});
|
||||
return args.length > 0 ? args : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function clearDashboardDiscoveryIfOwned(
|
||||
discoveryPath: string,
|
||||
expected: HubDashboardDiscoveryRecord,
|
||||
): Promise<void> {
|
||||
const current = await readHubDashboardDiscovery(discoveryPath);
|
||||
if (
|
||||
current?.pid === expected.pid &&
|
||||
current.startedAt === expected.startedAt
|
||||
) {
|
||||
await clearHubDashboardDiscovery(discoveryPath).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopManagedHubDashboardProcess(
|
||||
discoveryPath: string,
|
||||
): Promise<boolean> {
|
||||
const discovered = await readHubDashboardDiscovery(discoveryPath);
|
||||
if (!discovered?.pid) {
|
||||
await clearHubDashboardDiscovery(discoveryPath).catch(() => undefined);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.kill(discovered.pid, "SIGTERM");
|
||||
} catch (error) {
|
||||
if (isHubDashboardPidAlive(discovered.pid)) {
|
||||
throw error;
|
||||
}
|
||||
await clearDashboardDiscoveryIfOwned(discoveryPath, discovered);
|
||||
return false;
|
||||
}
|
||||
const stopped = await waitForPidToExit(discovered.pid);
|
||||
if (!stopped) {
|
||||
throw new Error(
|
||||
`Timed out waiting for dashboard process ${discovered.pid} to stop.`,
|
||||
);
|
||||
}
|
||||
await clearDashboardDiscoveryIfOwned(discoveryPath, discovered);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function restartManagedHubDashboardProcess(options: {
|
||||
discoveryPath: string;
|
||||
cwd: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<void> {
|
||||
const env = options.env ?? process.env;
|
||||
if (env[CLINE_HUB_PRESERVE_DASHBOARD_ENV]?.trim() === "1") {
|
||||
return;
|
||||
}
|
||||
const launcher = env[DASHBOARD_LAUNCHER_ENV]?.trim();
|
||||
const args = parseDashboardArgs(env);
|
||||
if (!launcher || !args) {
|
||||
return;
|
||||
}
|
||||
await stopManagedHubDashboardProcess(options.discoveryPath);
|
||||
const childEnv: NodeJS.ProcessEnv = {
|
||||
...env,
|
||||
[CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV]: options.discoveryPath,
|
||||
CLINE_NO_INTERACTIVE: "1",
|
||||
};
|
||||
delete childEnv[CLINE_RUN_AS_HUB_DAEMON_ENV];
|
||||
const child = spawn(launcher, args, {
|
||||
cwd: options.cwd,
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
env: childEnv,
|
||||
windowsHide: true,
|
||||
});
|
||||
child.unref();
|
||||
}
|
||||
@@ -2,14 +2,18 @@ import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV } from "../dashboard-discovery";
|
||||
|
||||
const {
|
||||
mockCreateLocalHubScheduleRuntimeHandlers,
|
||||
mockInitVcr,
|
||||
mockResolveDefaultHubOwnerContext,
|
||||
mockResolveHubEndpointOptions,
|
||||
mockResolveProductionHubOwnerContext,
|
||||
mockResolveSharedHubOwnerContext,
|
||||
mockRestartManagedHubDashboardProcess,
|
||||
mockStartHubWebSocketServer,
|
||||
mockStopManagedHubDashboardProcess,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCreateLocalHubScheduleRuntimeHandlers: vi.fn(() => ({
|
||||
startSession: vi.fn(),
|
||||
@@ -25,6 +29,10 @@ const {
|
||||
pathname: options.pathname ?? "/hub",
|
||||
}),
|
||||
),
|
||||
mockResolveDefaultHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "production",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
|
||||
})),
|
||||
mockResolveProductionHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "production",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
|
||||
@@ -33,9 +41,14 @@ const {
|
||||
ownerId: "shared",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/owners/shared.json",
|
||||
})),
|
||||
mockRestartManagedHubDashboardProcess: vi.fn(async () => undefined),
|
||||
mockStartHubWebSocketServer: vi.fn(async () => ({
|
||||
close: vi.fn(async () => undefined),
|
||||
shutdownRequested: new Promise<{ preserveDashboard: boolean }>(
|
||||
() => undefined,
|
||||
),
|
||||
})),
|
||||
mockStopManagedHubDashboardProcess: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
const {
|
||||
@@ -65,11 +78,18 @@ vi.mock("../daemon/runtime-handlers", () => ({
|
||||
mockCreateLocalHubScheduleRuntimeHandlers,
|
||||
}));
|
||||
|
||||
vi.mock("../daemon/dashboard-process", () => ({
|
||||
CLINE_HUB_PRESERVE_DASHBOARD_ENV: "CLINE_HUB_PRESERVE_DASHBOARD",
|
||||
restartManagedHubDashboardProcess: mockRestartManagedHubDashboardProcess,
|
||||
stopManagedHubDashboardProcess: mockStopManagedHubDashboardProcess,
|
||||
}));
|
||||
|
||||
vi.mock("../discovery/defaults", () => ({
|
||||
resolveHubEndpointOptions: mockResolveHubEndpointOptions,
|
||||
}));
|
||||
|
||||
vi.mock("../discovery/workspace", () => ({
|
||||
resolveDefaultHubOwnerContext: mockResolveDefaultHubOwnerContext,
|
||||
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
|
||||
}));
|
||||
@@ -84,6 +104,8 @@ vi.mock("./telemetry", () => ({
|
||||
|
||||
const originalArgv = [...process.argv];
|
||||
const originalCwd = process.cwd();
|
||||
const originalDashboardDiscoveryPath =
|
||||
process.env[CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV];
|
||||
|
||||
describe("hub daemon entry", () => {
|
||||
const tempDirs: string[] = [];
|
||||
@@ -91,14 +113,23 @@ describe("hub daemon entry", () => {
|
||||
afterEach(() => {
|
||||
process.argv = [...originalArgv];
|
||||
process.chdir(originalCwd);
|
||||
if (originalDashboardDiscoveryPath === undefined) {
|
||||
delete process.env[CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV];
|
||||
} else {
|
||||
process.env[CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV] =
|
||||
originalDashboardDiscoveryPath;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
mockCreateLocalHubScheduleRuntimeHandlers.mockClear();
|
||||
mockInitVcr.mockClear();
|
||||
mockResolveDefaultHubOwnerContext.mockClear();
|
||||
mockResolveHubEndpointOptions.mockClear();
|
||||
mockResolveProductionHubOwnerContext.mockClear();
|
||||
mockResolveSharedHubOwnerContext.mockClear();
|
||||
mockRestartManagedHubDashboardProcess.mockClear();
|
||||
mockStartHubWebSocketServer.mockClear();
|
||||
mockStopManagedHubDashboardProcess.mockClear();
|
||||
mockCreateHubDaemonTelemetry.mockClear();
|
||||
mockDaemonTelemetryDispose.mockClear();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
@@ -136,6 +167,7 @@ describe("hub daemon entry", () => {
|
||||
owner: expect.objectContaining({ ownerId: "production" }),
|
||||
telemetry: mockDaemonTelemetryService,
|
||||
cronOptions: { workspaceRoot: cwd },
|
||||
prepareShutdown: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
expect(mockCreateLocalHubScheduleRuntimeHandlers).toHaveBeenCalledOnce();
|
||||
@@ -144,6 +176,55 @@ describe("hub daemon entry", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the dashboard discovery path override for managed lifecycle", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "cline-hub-entry-test-"));
|
||||
tempDirs.push(cwd);
|
||||
process.argv = ["node", "entry.js", "--cwd", cwd];
|
||||
process.env[CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV] =
|
||||
"/tmp/overridden-dashboard.json";
|
||||
vi.spyOn(process, "on").mockImplementation(() => process);
|
||||
|
||||
await import("./entry");
|
||||
await vi.waitFor(() => {
|
||||
expect(mockRestartManagedHubDashboardProcess).toHaveBeenCalledWith({
|
||||
discoveryPath: "/tmp/overridden-dashboard.json",
|
||||
cwd,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("registers shutdown handlers before restarting the managed dashboard", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "cline-hub-entry-test-"));
|
||||
tempDirs.push(cwd);
|
||||
process.argv = ["node", "entry.js", "--cwd", cwd];
|
||||
let finishDashboardRestart: (() => void) | undefined;
|
||||
mockRestartManagedHubDashboardProcess.mockReturnValueOnce(
|
||||
new Promise<undefined>((resolve) => {
|
||||
finishDashboardRestart = () => resolve(undefined);
|
||||
}),
|
||||
);
|
||||
const processOn = vi.spyOn(process, "on").mockImplementation(() => process);
|
||||
|
||||
await import("./entry");
|
||||
await vi.waitFor(() => {
|
||||
expect(mockRestartManagedHubDashboardProcess).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
for (const signal of [
|
||||
"SIGINT",
|
||||
"SIGTERM",
|
||||
"uncaughtException",
|
||||
"unhandledRejection",
|
||||
]) {
|
||||
expect(processOn).toHaveBeenCalledWith(signal, expect.any(Function));
|
||||
}
|
||||
const firstHandlerRegistration = processOn.mock.invocationCallOrder[0];
|
||||
const dashboardRestart =
|
||||
mockRestartManagedHubDashboardProcess.mock.invocationCallOrder[0];
|
||||
expect(firstHandlerRegistration).toBeLessThan(dashboardRestart);
|
||||
finishDashboardRestart?.();
|
||||
});
|
||||
|
||||
it("disposes telemetry and exits when server startup fails", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "cline-hub-entry-test-"));
|
||||
tempDirs.push(cwd);
|
||||
@@ -163,4 +244,103 @@ describe("hub daemon entry", () => {
|
||||
});
|
||||
expect(mockDaemonTelemetryDispose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops the managed dashboard after an HTTP shutdown request", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "cline-hub-entry-test-"));
|
||||
tempDirs.push(cwd);
|
||||
process.argv = ["node", "entry.js", "--cwd", cwd];
|
||||
vi.spyOn(process, "on").mockImplementation(() => process);
|
||||
const exitSpy = vi
|
||||
.spyOn(process, "exit")
|
||||
.mockImplementation(() => undefined as never);
|
||||
const close = vi.fn(async () => undefined);
|
||||
mockStartHubWebSocketServer.mockResolvedValueOnce({
|
||||
close,
|
||||
shutdownRequested: Promise.resolve({ preserveDashboard: false }),
|
||||
} as never);
|
||||
|
||||
await import("./entry");
|
||||
await vi.waitFor(() => {
|
||||
expect(exitSpy).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
expect(mockStopManagedHubDashboardProcess).toHaveBeenCalledWith(
|
||||
join("/tmp/cline-data/locks/hub", "dashboard.json"),
|
||||
);
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(mockDaemonTelemetryDispose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves the managed dashboard for a dashboard-initiated hub restart", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "cline-hub-entry-test-"));
|
||||
tempDirs.push(cwd);
|
||||
process.argv = ["node", "entry.js", "--cwd", cwd];
|
||||
vi.spyOn(process, "on").mockImplementation(() => process);
|
||||
const exitSpy = vi
|
||||
.spyOn(process, "exit")
|
||||
.mockImplementation(() => undefined as never);
|
||||
const close = vi.fn(async () => undefined);
|
||||
mockStartHubWebSocketServer.mockResolvedValueOnce({
|
||||
close,
|
||||
shutdownRequested: Promise.resolve({ preserveDashboard: true }),
|
||||
} as never);
|
||||
|
||||
await import("./entry");
|
||||
await vi.waitFor(() => {
|
||||
expect(exitSpy).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
expect(mockStopManagedHubDashboardProcess).not.toHaveBeenCalled();
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(mockDaemonTelemetryDispose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves the dashboard if fatal shutdown starts during a preserved shutdown", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "cline-hub-entry-test-"));
|
||||
tempDirs.push(cwd);
|
||||
process.argv = ["node", "entry.js", "--cwd", cwd];
|
||||
const handlers = new Map<string, (...args: unknown[]) => void>();
|
||||
vi.spyOn(process, "on").mockImplementation((event, listener) => {
|
||||
handlers.set(String(event), listener as (...args: unknown[]) => void);
|
||||
return process;
|
||||
});
|
||||
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
const exitSpy = vi
|
||||
.spyOn(process, "exit")
|
||||
.mockImplementation(() => undefined as never);
|
||||
let requestShutdown:
|
||||
| ((request: { preserveDashboard: boolean }) => void)
|
||||
| undefined;
|
||||
const shutdownRequested = new Promise<{ preserveDashboard: boolean }>(
|
||||
(resolve) => {
|
||||
requestShutdown = resolve;
|
||||
},
|
||||
);
|
||||
const normalClosePending = new Promise<void>(() => undefined);
|
||||
const close = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(normalClosePending)
|
||||
.mockResolvedValueOnce(undefined);
|
||||
mockStartHubWebSocketServer.mockResolvedValueOnce({
|
||||
close,
|
||||
shutdownRequested,
|
||||
} as never);
|
||||
|
||||
await import("./entry");
|
||||
await vi.waitFor(() => {
|
||||
expect(handlers.has("unhandledRejection")).toBe(true);
|
||||
});
|
||||
requestShutdown?.({ preserveDashboard: true });
|
||||
await vi.waitFor(() => {
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
handlers.get("unhandledRejection")?.(new Error("teardown failed"));
|
||||
await vi.waitFor(() => {
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
expect(close).toHaveBeenCalledTimes(2);
|
||||
expect(mockStopManagedHubDashboardProcess).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { AgentRuntimeAbortError } from "@cline/agents";
|
||||
import { initVcr, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { createLocalHubScheduleRuntimeHandlers } from "../daemon/runtime-handlers";
|
||||
import { resolveHubEndpointOptions } from "../discovery/defaults";
|
||||
import { initVcr } from "@cline/shared";
|
||||
import {
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
} from "../discovery/workspace";
|
||||
CLINE_HUB_PRESERVE_DASHBOARD_ENV,
|
||||
restartManagedHubDashboardProcess,
|
||||
stopManagedHubDashboardProcess,
|
||||
} from "../daemon/dashboard-process";
|
||||
import { createLocalHubScheduleRuntimeHandlers } from "../daemon/runtime-handlers";
|
||||
import {
|
||||
CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV,
|
||||
resolveHubDashboardDiscoveryPath,
|
||||
} from "../dashboard-discovery";
|
||||
import { resolveHubEndpointOptions } from "../discovery/defaults";
|
||||
import { resolveDefaultHubOwnerContext } from "../discovery/workspace";
|
||||
import { startHubWebSocketServer } from "../server";
|
||||
import { createHubDaemonTelemetry } from "./telemetry";
|
||||
|
||||
@@ -62,23 +68,60 @@ async function main(): Promise<void> {
|
||||
pathname: options.pathname,
|
||||
});
|
||||
|
||||
const owner = resolveDefaultHubOwnerContext();
|
||||
const dashboardDiscoveryPath =
|
||||
process.env[CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV]?.trim() ||
|
||||
resolveHubDashboardDiscoveryPath(owner);
|
||||
|
||||
const daemonTelemetry = createHubDaemonTelemetry();
|
||||
|
||||
const preserveDashboardDuringStartup =
|
||||
process.env[CLINE_HUB_PRESERVE_DASHBOARD_ENV]?.trim() === "1";
|
||||
let dashboardStartupSettled = false;
|
||||
let preserveDashboardOnShutdown = false;
|
||||
let dashboardRestartPromise: Promise<void> | undefined;
|
||||
let resolveDashboardRestartScheduled: (() => void) | undefined;
|
||||
const dashboardRestartScheduled = new Promise<void>((resolve) => {
|
||||
resolveDashboardRestartScheduled = resolve;
|
||||
});
|
||||
const rememberPreserveDashboard = (preserve: boolean): void => {
|
||||
if (
|
||||
preserve ||
|
||||
(!dashboardStartupSettled && preserveDashboardDuringStartup)
|
||||
) {
|
||||
preserveDashboardOnShutdown = true;
|
||||
}
|
||||
};
|
||||
let dashboardShutdownPromise: Promise<void> | undefined;
|
||||
const prepareDashboardShutdown = (
|
||||
shutdownOptions: { preserveDashboard?: boolean } = {},
|
||||
): Promise<void> => {
|
||||
rememberPreserveDashboard(shutdownOptions.preserveDashboard === true);
|
||||
dashboardShutdownPromise ??= (async () => {
|
||||
await dashboardRestartScheduled;
|
||||
await dashboardRestartPromise;
|
||||
if (!preserveDashboardOnShutdown) {
|
||||
await stopManagedHubDashboardProcess(dashboardDiscoveryPath).catch(
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
})();
|
||||
return dashboardShutdownPromise;
|
||||
};
|
||||
|
||||
let server: Awaited<ReturnType<typeof startHubWebSocketServer>>;
|
||||
try {
|
||||
server = await startHubWebSocketServer({
|
||||
host: endpoint.host,
|
||||
port: endpoint.port,
|
||||
pathname: endpoint.pathname,
|
||||
owner:
|
||||
resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext(),
|
||||
owner,
|
||||
telemetry: daemonTelemetry.telemetry,
|
||||
runtimeHandlers: createLocalHubScheduleRuntimeHandlers({
|
||||
telemetry: daemonTelemetry.telemetry,
|
||||
}),
|
||||
cronOptions: { workspaceRoot: options.cwd },
|
||||
prepareShutdown: prepareDashboardShutdown,
|
||||
});
|
||||
} catch (error) {
|
||||
// Flush before the top-level catch exits so failed daemon starts are
|
||||
@@ -87,7 +130,18 @@ async function main(): Promise<void> {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const shutdown = async (): Promise<void> => {
|
||||
const shutdownRequestPromise = server.shutdownRequested;
|
||||
|
||||
let shutdownStarted = false;
|
||||
const shutdown = async (
|
||||
shutdownOptions: { preserveDashboard?: boolean } = {},
|
||||
): Promise<void> => {
|
||||
rememberPreserveDashboard(shutdownOptions.preserveDashboard === true);
|
||||
if (shutdownStarted) {
|
||||
return;
|
||||
}
|
||||
shutdownStarted = true;
|
||||
await prepareDashboardShutdown(shutdownOptions);
|
||||
await server.close();
|
||||
await daemonTelemetry.dispose().catch(() => undefined);
|
||||
process.exit(0);
|
||||
@@ -99,11 +153,12 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
fatalShutdownStarted = true;
|
||||
rememberPreserveDashboard(false);
|
||||
const message =
|
||||
error instanceof Error ? error.stack || error.message : String(error);
|
||||
process.stderr.write(`[hub-daemon] ${label}: ${message}\n`);
|
||||
void server
|
||||
.close()
|
||||
void prepareDashboardShutdown()
|
||||
.then(() => server.close())
|
||||
.catch((closeError) => {
|
||||
const closeMessage =
|
||||
closeError instanceof Error
|
||||
@@ -142,9 +197,25 @@ async function main(): Promise<void> {
|
||||
shutdownFatal("unhandledRejection", reason);
|
||||
});
|
||||
|
||||
await new Promise<void>(() => {
|
||||
// keep daemon process alive
|
||||
});
|
||||
dashboardRestartPromise = restartManagedHubDashboardProcess({
|
||||
discoveryPath: dashboardDiscoveryPath,
|
||||
cwd: options.cwd,
|
||||
})
|
||||
.catch((error) => {
|
||||
const message =
|
||||
error instanceof Error ? error.stack || error.message : String(error);
|
||||
process.stderr.write(
|
||||
`[hub-daemon] dashboard restart failed: ${message}\n`,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
dashboardStartupSettled = true;
|
||||
});
|
||||
resolveDashboardRestartScheduled?.();
|
||||
await dashboardRestartPromise;
|
||||
|
||||
const shutdownRequest = await shutdownRequestPromise;
|
||||
await shutdown(shutdownRequest);
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ const {
|
||||
openSync,
|
||||
rememberRecoverableLocalHubUrl,
|
||||
verifyHubConnection,
|
||||
resolveDefaultHubOwnerContext,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
createHubServerUrl,
|
||||
@@ -25,6 +26,9 @@ const {
|
||||
openSync: vi.fn(() => 17),
|
||||
rememberRecoverableLocalHubUrl: vi.fn((url: string) => url),
|
||||
verifyHubConnection: vi.fn(),
|
||||
resolveDefaultHubOwnerContext: vi.fn(() => ({
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
})),
|
||||
resolveProductionHubOwnerContext: vi.fn(() => ({
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
})),
|
||||
@@ -77,6 +81,7 @@ vi.mock("../client", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../discovery/workspace", () => ({
|
||||
resolveDefaultHubOwnerContext,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
}));
|
||||
@@ -209,6 +214,41 @@ describe("ensureDetachedHubServer", () => {
|
||||
expect(openSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks a replacement daemon to preserve the active dashboard", async () => {
|
||||
const { CLINE_HUB_PRESERVE_DASHBOARD_ENV, spawnDetachedHubServer } =
|
||||
await import(".");
|
||||
|
||||
spawnDetachedHubServer("/workspace", { preserveDashboard: true });
|
||||
|
||||
const spawnOptions = (spawn as unknown as { mock: { calls: unknown[][] } })
|
||||
.mock.calls[0]?.[2] as { env?: NodeJS.ProcessEnv } | undefined;
|
||||
expect(spawnOptions?.env?.[CLINE_HUB_PRESERVE_DASHBOARD_ENV]).toBe("1");
|
||||
});
|
||||
|
||||
it("clears an inherited preserve marker for ordinary hub starts", async () => {
|
||||
const previous = process.env.CLINE_HUB_PRESERVE_DASHBOARD;
|
||||
process.env.CLINE_HUB_PRESERVE_DASHBOARD = "1";
|
||||
try {
|
||||
const { CLINE_HUB_PRESERVE_DASHBOARD_ENV, spawnDetachedHubServer } =
|
||||
await import(".");
|
||||
|
||||
spawnDetachedHubServer("/workspace");
|
||||
|
||||
const spawnOptions = (
|
||||
spawn as unknown as { mock: { calls: unknown[][] } }
|
||||
).mock.calls[0]?.[2] as { env?: NodeJS.ProcessEnv } | undefined;
|
||||
expect(
|
||||
spawnOptions?.env?.[CLINE_HUB_PRESERVE_DASHBOARD_ENV],
|
||||
).toBeUndefined();
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.CLINE_HUB_PRESERVE_DASHBOARD;
|
||||
} else {
|
||||
process.env.CLINE_HUB_PRESERVE_DASHBOARD = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("does not prewarm another detached daemon from inside the hub daemon process", async () => {
|
||||
process.env[CLINE_RUN_AS_HUB_DAEMON_ENV] = "1";
|
||||
|
||||
|
||||
@@ -28,9 +28,16 @@ import {
|
||||
resolveHubEndpointOptions,
|
||||
} from "../discovery/defaults";
|
||||
import {
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveDefaultHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
} from "../discovery/workspace";
|
||||
import { CLINE_HUB_PRESERVE_DASHBOARD_ENV } from "./dashboard-process";
|
||||
|
||||
export {
|
||||
CLINE_HUB_PRESERVE_DASHBOARD_ENV,
|
||||
restartManagedHubDashboardProcess,
|
||||
stopManagedHubDashboardProcess,
|
||||
} from "./dashboard-process";
|
||||
|
||||
const HUB_STARTUP_TIMEOUT_MS = 8_000;
|
||||
const HUB_STARTUP_POLL_MS = 200;
|
||||
@@ -59,12 +66,6 @@ function openDetachedHubLogFile(): { fd: number; logPath: string } | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDefaultHubOwnerContext() {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
function isCompatibleHubRecord(record: HubServerProbeRecord): boolean {
|
||||
return isHubProtocolCompatible(record).compatible;
|
||||
}
|
||||
@@ -168,7 +169,7 @@ function resolveDaemonEntryPath(): string {
|
||||
|
||||
function resolveLaunchCommand(
|
||||
workspaceRoot: string,
|
||||
endpoint: HubEndpointOverrides,
|
||||
options: DetachedHubServerOptions,
|
||||
): {
|
||||
launcher: string;
|
||||
args: string[];
|
||||
@@ -176,6 +177,7 @@ function resolveLaunchCommand(
|
||||
env: NodeJS.ProcessEnv;
|
||||
} {
|
||||
const daemonEntryPath = resolveDaemonEntryPath();
|
||||
const { preserveDashboard, ...endpoint } = options;
|
||||
const execPath = process.execPath?.trim();
|
||||
if (!execPath) {
|
||||
throw new Error("unable to resolve runtime executable for hub daemon");
|
||||
@@ -190,15 +192,21 @@ function resolveLaunchCommand(
|
||||
...(useDevelopmentConditions ? ["--conditions=development"] : []),
|
||||
daemonEntryPath,
|
||||
];
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...withResolvedClineBuildEnv(process.env),
|
||||
CLINE_NO_INTERACTIVE: "1",
|
||||
[CLINE_RUN_AS_HUB_DAEMON_ENV]: "1",
|
||||
};
|
||||
if (preserveDashboard) {
|
||||
env[CLINE_HUB_PRESERVE_DASHBOARD_ENV] = "1";
|
||||
} else {
|
||||
delete env[CLINE_HUB_PRESERVE_DASHBOARD_ENV];
|
||||
}
|
||||
return {
|
||||
launcher: execPath,
|
||||
args: [...entryArgs, "--cwd", workspaceRoot, ...endpointArgs(endpoint)],
|
||||
cwd: workspaceRoot,
|
||||
env: {
|
||||
...withResolvedClineBuildEnv(process.env),
|
||||
CLINE_NO_INTERACTIVE: "1",
|
||||
[CLINE_RUN_AS_HUB_DAEMON_ENV]: "1",
|
||||
},
|
||||
env,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -216,12 +224,12 @@ function isTextFileBusyError(error: unknown): boolean {
|
||||
|
||||
export function spawnDetachedHubServer(
|
||||
workspaceRoot: string,
|
||||
endpoint: HubEndpointOverrides = {},
|
||||
options: DetachedHubServerOptions = {},
|
||||
): void {
|
||||
if (isHubDaemonProcess()) {
|
||||
return;
|
||||
}
|
||||
const command = resolveLaunchCommand(workspaceRoot, endpoint);
|
||||
const command = resolveLaunchCommand(workspaceRoot, options);
|
||||
const logFile = openDetachedHubLogFile();
|
||||
try {
|
||||
const child = spawn(command.launcher, command.args, {
|
||||
@@ -243,11 +251,11 @@ export function spawnDetachedHubServer(
|
||||
|
||||
export async function spawnDetachedHubServerWithRetry(
|
||||
workspaceRoot: string,
|
||||
endpoint: HubEndpointOverrides = {},
|
||||
options: DetachedHubServerOptions = {},
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
spawnDetachedHubServer(workspaceRoot, endpoint);
|
||||
spawnDetachedHubServer(workspaceRoot, options);
|
||||
return;
|
||||
} catch (error) {
|
||||
const delay = HUB_SPAWN_RETRY_DELAYS_MS[attempt];
|
||||
@@ -345,11 +353,14 @@ export interface DetachedHubResolution {
|
||||
authToken: string;
|
||||
}
|
||||
|
||||
export interface DetachedHubServerOptions extends HubEndpointOverrides {
|
||||
allowPortFallback?: boolean;
|
||||
preserveDashboard?: boolean;
|
||||
}
|
||||
|
||||
export async function ensureDetachedHubServer(
|
||||
workspaceRoot: string,
|
||||
endpointOverrides: HubEndpointOverrides & {
|
||||
allowPortFallback?: boolean;
|
||||
} = {},
|
||||
endpointOverrides: DetachedHubServerOptions = {},
|
||||
): Promise<DetachedHubResolution> {
|
||||
const owner = resolveDefaultHubOwnerContext();
|
||||
const hasExplicitEndpoint =
|
||||
@@ -440,7 +451,10 @@ export async function ensureDetachedHubServer(
|
||||
const spawnEndpoint = shouldUseFallbackPort
|
||||
? { ...endpoint, port: 0 }
|
||||
: endpoint;
|
||||
await spawnDetachedHubServerWithRetry(workspaceRoot, spawnEndpoint);
|
||||
await spawnDetachedHubServerWithRetry(workspaceRoot, {
|
||||
...spawnEndpoint,
|
||||
preserveDashboard: endpointOverrides.preserveDashboard,
|
||||
});
|
||||
const deadline = Date.now() + HUB_STARTUP_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const nextDiscovery = await readHubDiscovery(owner.discoveryPath);
|
||||
|
||||
@@ -5,6 +5,7 @@ const {
|
||||
mockEnsureHubWebSocketServer,
|
||||
mockResolveHubEndpointOptions,
|
||||
mockResolveClineBuildEnv,
|
||||
mockResolveDefaultHubOwnerContext,
|
||||
mockResolveProductionHubOwnerContext,
|
||||
mockResolveSharedHubOwnerContext,
|
||||
mockStartHubWebSocketServer,
|
||||
@@ -22,6 +23,11 @@ const {
|
||||
}),
|
||||
),
|
||||
mockResolveClineBuildEnv: vi.fn(() => "production"),
|
||||
mockResolveDefaultHubOwnerContext: vi.fn(() =>
|
||||
mockResolveClineBuildEnv() === "production"
|
||||
? mockResolveProductionHubOwnerContext()
|
||||
: mockResolveSharedHubOwnerContext(),
|
||||
),
|
||||
mockResolveProductionHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "production",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
|
||||
@@ -42,6 +48,7 @@ vi.mock("../discovery/defaults", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../discovery/workspace", () => ({
|
||||
resolveDefaultHubOwnerContext: mockResolveDefaultHubOwnerContext,
|
||||
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
|
||||
}));
|
||||
@@ -61,6 +68,7 @@ describe("ensureHubServer", () => {
|
||||
mockResolveHubEndpointOptions.mockClear();
|
||||
mockResolveClineBuildEnv.mockClear();
|
||||
mockResolveClineBuildEnv.mockReturnValue("production");
|
||||
mockResolveDefaultHubOwnerContext.mockClear();
|
||||
mockResolveProductionHubOwnerContext.mockClear();
|
||||
mockResolveSharedHubOwnerContext.mockClear();
|
||||
mockStartHubWebSocketServer.mockClear();
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { resolveClineBuildEnv } from "@cline/shared";
|
||||
import { resolveHubEndpointOptions } from "../discovery/defaults";
|
||||
import {
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
} from "../discovery/workspace";
|
||||
import { resolveDefaultHubOwnerContext } from "../discovery/workspace";
|
||||
import {
|
||||
type EnsuredHubWebSocketServerResult,
|
||||
type EnsureHubWebSocketServerOptions,
|
||||
@@ -22,12 +19,6 @@ export interface StartHubServerOptions
|
||||
export interface EnsureHubServerOptions
|
||||
extends Omit<EnsureHubWebSocketServerOptions, "owner"> {}
|
||||
|
||||
function resolveDefaultHubOwnerContext() {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
function shouldAllowDefaultPortFallback(hasExplicitPort: boolean): boolean {
|
||||
return resolveClineBuildEnv() !== "production" && !hasExplicitPort;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { mkdtemp, readdir, readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isHubDashboardPidAlive,
|
||||
readHubDashboardDiscovery,
|
||||
writeHubDashboardDiscovery,
|
||||
} from "./dashboard-discovery";
|
||||
|
||||
describe("hub dashboard discovery", () => {
|
||||
it("atomically replaces discovery records through a private temp file", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "cline-dashboard-discovery-"));
|
||||
const discoveryPath = join(dir, "dashboard.json");
|
||||
const first = {
|
||||
pid: 111,
|
||||
listenUrl: "http://127.0.0.1:8787/",
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
inviteUrl: "http://127.0.0.1:8787",
|
||||
startedAt: "2026-06-22T20:00:00.000Z",
|
||||
updatedAt: "2026-06-22T20:00:00.000Z",
|
||||
};
|
||||
const second = {
|
||||
...first,
|
||||
pid: 222,
|
||||
updatedAt: "2026-06-22T20:00:01.000Z",
|
||||
};
|
||||
|
||||
await writeHubDashboardDiscovery(discoveryPath, first);
|
||||
await writeHubDashboardDiscovery(discoveryPath, second);
|
||||
|
||||
await expect(readHubDashboardDiscovery(discoveryPath)).resolves.toEqual(
|
||||
second,
|
||||
);
|
||||
expect(await readFile(discoveryPath, "utf8")).toContain('"pid": 222');
|
||||
expect(
|
||||
(await readdir(dir)).filter((entry) => entry.endsWith(".tmp")),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports invalid dashboard pids as not alive", () => {
|
||||
expect(isHubDashboardPidAlive(undefined)).toBe(false);
|
||||
expect(isHubDashboardPidAlive(0)).toBe(false);
|
||||
expect(isHubDashboardPidAlive(-1)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import type { HubOwnerContext } from "./discovery";
|
||||
|
||||
const DASHBOARD_DISCOVERY_FILENAME = "dashboard.json";
|
||||
export const CLINE_HUB_DASHBOARD_DISCOVERY_PATH_ENV =
|
||||
"CLINE_HUB_DASHBOARD_DISCOVERY_PATH";
|
||||
|
||||
export interface HubDashboardDiscoveryRecord {
|
||||
pid: number;
|
||||
listenUrl: string;
|
||||
publicUrl: string;
|
||||
inviteUrl: string;
|
||||
hubUrl?: string;
|
||||
startedAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function resolveHubDashboardDiscoveryPath(
|
||||
owner: HubOwnerContext,
|
||||
): string {
|
||||
return join(dirname(owner.discoveryPath), DASHBOARD_DISCOVERY_FILENAME);
|
||||
}
|
||||
|
||||
export function isHubDashboardPidAlive(pid: number | undefined): boolean {
|
||||
if (!Number.isInteger(pid) || !pid || pid <= 0) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error instanceof Error && "code" in error
|
||||
? String((error as NodeJS.ErrnoException).code) === "EPERM"
|
||||
: false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readHubDashboardDiscovery(
|
||||
discoveryPath: string,
|
||||
): Promise<HubDashboardDiscoveryRecord | undefined> {
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
await readFile(discoveryPath, "utf8"),
|
||||
) as Partial<HubDashboardDiscoveryRecord>;
|
||||
if (
|
||||
typeof parsed.pid !== "number" ||
|
||||
typeof parsed.listenUrl !== "string" ||
|
||||
typeof parsed.publicUrl !== "string" ||
|
||||
typeof parsed.inviteUrl !== "string" ||
|
||||
typeof parsed.startedAt !== "string" ||
|
||||
typeof parsed.updatedAt !== "string"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
pid: parsed.pid,
|
||||
listenUrl: parsed.listenUrl,
|
||||
publicUrl: parsed.publicUrl,
|
||||
inviteUrl: parsed.inviteUrl,
|
||||
hubUrl: typeof parsed.hubUrl === "string" ? parsed.hubUrl : undefined,
|
||||
startedAt: parsed.startedAt,
|
||||
updatedAt: parsed.updatedAt,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeHubDashboardDiscovery(
|
||||
discoveryPath: string,
|
||||
record: HubDashboardDiscoveryRecord,
|
||||
): Promise<void> {
|
||||
const discoveryDir = dirname(discoveryPath);
|
||||
const tempPath = join(
|
||||
discoveryDir,
|
||||
`.${basename(discoveryPath)}.${process.pid}.${Date.now()}.tmp`,
|
||||
);
|
||||
await mkdir(discoveryDir, { recursive: true });
|
||||
try {
|
||||
await writeFile(tempPath, `${JSON.stringify(record, null, 2)}\n`, {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
await chmod(tempPath, 0o600);
|
||||
await rename(tempPath, discoveryPath);
|
||||
} catch (error) {
|
||||
await rm(tempPath, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearHubDashboardDiscovery(
|
||||
discoveryPath: string,
|
||||
): Promise<void> {
|
||||
await rm(discoveryPath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { CLINE_BUILD_ENV_ENV } from "@cline/shared";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveDefaultHubOwnerContext,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
} from "./workspace";
|
||||
|
||||
type EnvSnapshot = {
|
||||
CLINE_BUILD_ENV: string | undefined;
|
||||
CLINE_DATA_DIR: string | undefined;
|
||||
CLINE_HUB_DISCOVERY_PATH: string | undefined;
|
||||
};
|
||||
|
||||
function captureEnv(): EnvSnapshot {
|
||||
return {
|
||||
CLINE_BUILD_ENV: process.env[CLINE_BUILD_ENV_ENV],
|
||||
CLINE_DATA_DIR: process.env.CLINE_DATA_DIR,
|
||||
CLINE_HUB_DISCOVERY_PATH: process.env.CLINE_HUB_DISCOVERY_PATH,
|
||||
};
|
||||
}
|
||||
|
||||
function restoreEnv(snapshot: EnvSnapshot): void {
|
||||
if (snapshot.CLINE_BUILD_ENV === undefined) {
|
||||
delete process.env[CLINE_BUILD_ENV_ENV];
|
||||
} else {
|
||||
process.env[CLINE_BUILD_ENV_ENV] = snapshot.CLINE_BUILD_ENV;
|
||||
}
|
||||
if (snapshot.CLINE_DATA_DIR === undefined) {
|
||||
delete process.env.CLINE_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_DATA_DIR = snapshot.CLINE_DATA_DIR;
|
||||
}
|
||||
if (snapshot.CLINE_HUB_DISCOVERY_PATH === undefined) {
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
} else {
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = snapshot.CLINE_HUB_DISCOVERY_PATH;
|
||||
}
|
||||
}
|
||||
|
||||
describe("resolveDefaultHubOwnerContext", () => {
|
||||
let snapshot: EnvSnapshot = captureEnv();
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv(snapshot);
|
||||
});
|
||||
|
||||
it("uses the production singleton owner in production builds", () => {
|
||||
snapshot = captureEnv();
|
||||
process.env[CLINE_BUILD_ENV_ENV] = "production";
|
||||
process.env.CLINE_DATA_DIR = "/tmp/cline-data";
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
|
||||
expect(resolveDefaultHubOwnerContext()).toEqual(
|
||||
resolveProductionHubOwnerContext(),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the shared owner in development builds", () => {
|
||||
snapshot = captureEnv();
|
||||
process.env[CLINE_BUILD_ENV_ENV] = "development";
|
||||
process.env.CLINE_DATA_DIR = "/tmp/cline-data";
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
|
||||
expect(resolveDefaultHubOwnerContext()).toEqual(
|
||||
resolveSharedHubOwnerContext(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { join } from "node:path";
|
||||
import { resolveClineBuildEnv } from "@cline/shared";
|
||||
import { normalizeWorkspacePath } from "../../services/workspace/workspace-manifest";
|
||||
import {
|
||||
type HubOwnerContext,
|
||||
@@ -33,3 +34,9 @@ export function resolveProductionHubOwnerContext(): HubOwnerContext {
|
||||
join(resolveClineDataDir(), "locks", "hub", "production.json"),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveDefaultHubOwnerContext(): HubOwnerContext {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export * from "./client/ui-client";
|
||||
export * from "./daemon";
|
||||
export * from "./daemon/runtime-handlers";
|
||||
export * from "./daemon/start-shared-server";
|
||||
export * from "./dashboard-discovery";
|
||||
export * from "./discovery";
|
||||
export * from "./discovery/defaults";
|
||||
export * from "./discovery/workspace";
|
||||
|
||||
@@ -11,6 +11,10 @@ import type {
|
||||
import type { CoreSettingsService } from "../../settings";
|
||||
import type { HubOwnerContext } from "../discovery";
|
||||
|
||||
export interface HubShutdownRequest {
|
||||
preserveDashboard: boolean;
|
||||
}
|
||||
|
||||
export interface HubWebSocketServerOptions {
|
||||
host?: string;
|
||||
port?: number;
|
||||
@@ -48,6 +52,11 @@ export interface HubWebSocketServerOptions {
|
||||
* Ignored when `sessionHost` is supplied.
|
||||
*/
|
||||
logger?: BasicLogger;
|
||||
/**
|
||||
* Host-owned cleanup that must finish before an authenticated shutdown
|
||||
* request retires the server listener and discovery record.
|
||||
*/
|
||||
prepareShutdown?: (request: HubShutdownRequest) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface HubWebSocketServer {
|
||||
@@ -55,6 +64,8 @@ export interface HubWebSocketServer {
|
||||
port: number;
|
||||
url: string;
|
||||
authToken: string;
|
||||
/** Resolves when the authenticated HTTP shutdown endpoint is invoked. */
|
||||
shutdownRequested: Promise<HubShutdownRequest>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -311,12 +311,22 @@ export async function startHubWebSocketServer(
|
||||
const sockets = new Set<TrackedNodeWebSocket>();
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let closePromise: Promise<void> | undefined;
|
||||
let shutdownPreparationPromise: Promise<void> | undefined;
|
||||
let resolveShutdownRequested:
|
||||
| ((request: { preserveDashboard: boolean }) => void)
|
||||
| undefined;
|
||||
const shutdownRequested = new Promise<{ preserveDashboard: boolean }>(
|
||||
(resolve) => {
|
||||
resolveShutdownRequested = resolve;
|
||||
},
|
||||
);
|
||||
|
||||
const closeServer = async (): Promise<void> => {
|
||||
if (closePromise) {
|
||||
return closePromise;
|
||||
}
|
||||
closePromise = (async () => {
|
||||
await shutdownPreparationPromise?.catch(() => undefined);
|
||||
if (heartbeatTimer) {
|
||||
clearInterval(heartbeatTimer);
|
||||
heartbeatTimer = undefined;
|
||||
@@ -419,6 +429,14 @@ export async function startHubWebSocketServer(
|
||||
res.statusCode = 202;
|
||||
res.setHeader("content-type", "application/json");
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
const shutdownRequest = {
|
||||
preserveDashboard: req.headers["x-cline-preserve-dashboard"] === "1",
|
||||
};
|
||||
shutdownPreparationPromise ??= Promise.resolve().then(() =>
|
||||
options.prepareShutdown?.(shutdownRequest),
|
||||
);
|
||||
resolveShutdownRequested?.(shutdownRequest);
|
||||
resolveShutdownRequested = undefined;
|
||||
queueMicrotask(() => {
|
||||
void closeServer();
|
||||
});
|
||||
@@ -554,6 +572,7 @@ export async function startHubWebSocketServer(
|
||||
port,
|
||||
url,
|
||||
authToken,
|
||||
shutdownRequested,
|
||||
close: closeServer,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -207,12 +207,18 @@ describe("hub server startup", () => {
|
||||
|
||||
it("shuts down active server through the shutdown endpoint", async () => {
|
||||
const owner = createInMemoryHubOwnerContext("hub-server-test-shutdown");
|
||||
let releaseShutdownPreparation: (() => void) | undefined;
|
||||
const shutdownPreparation = new Promise<void>((resolve) => {
|
||||
releaseShutdownPreparation = resolve;
|
||||
});
|
||||
const prepareShutdown = vi.fn(() => shutdownPreparation);
|
||||
const result = await ensureHubWebSocketServer({
|
||||
owner,
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
pathname: "/hub",
|
||||
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
|
||||
prepareShutdown,
|
||||
});
|
||||
const server = requireServer(result.server);
|
||||
servers.add(server);
|
||||
@@ -225,11 +231,34 @@ describe("hub server startup", () => {
|
||||
}
|
||||
expect(discovery.authToken).toMatch(/^[a-f0-9]{64}$/);
|
||||
const authToken = discovery.authToken;
|
||||
const shutdownRequested = expect(server.shutdownRequested).resolves.toEqual(
|
||||
{
|
||||
preserveDashboard: true,
|
||||
},
|
||||
);
|
||||
const response = await fetch(shutdownUrl, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${authToken}` },
|
||||
headers: {
|
||||
authorization: `Bearer ${authToken}`,
|
||||
"x-cline-preserve-dashboard": "1",
|
||||
},
|
||||
});
|
||||
expect(response.status).toBe(202);
|
||||
await shutdownRequested;
|
||||
try {
|
||||
await vi.waitFor(() => {
|
||||
expect(prepareShutdown).toHaveBeenCalledWith({
|
||||
preserveDashboard: true,
|
||||
});
|
||||
});
|
||||
expect(await readHubDiscovery(owner.discoveryPath)).toBeDefined();
|
||||
const health = await fetch(
|
||||
new URL("/health", toHubHealthUrl(result.url)),
|
||||
);
|
||||
expect(health.status).toBe(200);
|
||||
} finally {
|
||||
releaseShutdownPreparation?.();
|
||||
}
|
||||
|
||||
for (let index = 0; index < 50; index += 1) {
|
||||
if ((await readHubDiscovery(owner.discoveryPath)) === undefined) {
|
||||
|
||||
@@ -3,8 +3,8 @@ export {
|
||||
ClineOrgIndividualInferenceSubscriptionError,
|
||||
ClinePassLimitError,
|
||||
extractClinePassLimitMessage,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClineNotSubscribedMessage,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClinePassSubscriptionUrl,
|
||||
isClineNotSubscribedError,
|
||||
isClineNotSubscribedMessage,
|
||||
|
||||
@@ -34,8 +34,8 @@ export {
|
||||
ClineOrgIndividualInferenceSubscriptionError,
|
||||
ClinePassLimitError,
|
||||
extractClinePassLimitMessage,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClineNotSubscribedMessage,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClinePassSubscriptionUrl,
|
||||
isClineNotSubscribedError,
|
||||
isClineNotSubscribedMessage,
|
||||
|
||||
Reference in New Issue
Block a user