mirror of
https://github.com/cline/cline.git
synced 2026-09-21 05:10:09 +08:00
* feat: Enforce a production singleton Cline Hub This PR changes local Hub startup/discovery so production uses one stable daemon per user machine instead of silently creating additional hubs on random ports. Replace resolveSharedHubOwnerContext with resolveProductionHubOwnerContext across doctor and hub server lifecycle management to scope hub discovery to the production owner. Additionally: - Preserve and propagate auth tokens when retiring incompatible hubs - Throw a clear error when a compatible hub is already running but its discovery record is missing, guiding users to run 'cline doctor fix' - Gate port fallback behind an explicit allowPortFallback override - Update tests to mock the new production hub owner context * patches * fix * hasExplicitPort * Restored daemon cron startup, made discovery auth tokens required again, and fixed graceful hub stop/restart paths to use the selected production/shared owner context. * clean up * patches * fix Polynomial regular expression * test * fix: require explicit hub port fallback in production * fix(cli): stop pgrep from parsing the hub daemon marker as an option pgrep treats the "--cline-hub-daemon" pattern as an unknown long option and exits 2, so doctor never found stale daemons from compiled-binary installs, which are exactly the processes 'cline doctor fix' is told to clean up. Pass "--" before the pattern to end option parsing. * fix(hub): retire legacy shared-owner hubs on production startup Pre-singleton production builds tracked the local hub under the shared owner discovery path and spawned daemons on random fallback ports. The production owner context never reads that path, so upgrades would leave those daemons running indefinitely with no way to reuse or stop them. Retire the recorded legacy hub (its record carries the auth token and pid needed for a graceful stop) and clear the legacy record before resolving the production hub. * refactor(hub): simplify stale discovery clearing, share capability list shouldClearStaleHubDiscovery was only ever called with discoveredVerified=false (the true assignment sits on a return path), so the expected-hub probe and compatibility check had no effect and the condition reduced to "a discovery record exists and was not reused". Replace it with a plain conditional and drop the tests that exercised unreachable states. Also move the hub capability list into a typed HUB_CAPABILITIES constant in @cline/shared next to HubCapabilityName so the server cannot drift from the type. --------- Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
127 lines
3.3 KiB
TypeScript
127 lines
3.3 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const {
|
|
mockClearHubDiscovery,
|
|
mockEnsureDetachedHubServer,
|
|
mockProbeHubServer,
|
|
mockReadHubDiscovery,
|
|
mockResolveProductionHubOwnerContext,
|
|
mockResolveSharedHubOwnerContext,
|
|
mockStopLocalHubServerGracefully,
|
|
} = vi.hoisted(() => ({
|
|
mockClearHubDiscovery: vi.fn(),
|
|
mockEnsureDetachedHubServer: vi.fn(),
|
|
mockProbeHubServer: vi.fn(),
|
|
mockReadHubDiscovery: vi.fn(),
|
|
mockResolveProductionHubOwnerContext: vi.fn(() => ({
|
|
ownerId: "hub-production",
|
|
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
|
|
})),
|
|
mockResolveSharedHubOwnerContext: vi.fn(() => ({
|
|
ownerId: "hub-owner",
|
|
discoveryPath: "/tmp/cline-data/locks/hub/owners/hub-owner.json",
|
|
})),
|
|
mockStopLocalHubServerGracefully: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@cline/core", () => ({
|
|
clearHubDiscovery: mockClearHubDiscovery,
|
|
ensureDetachedHubServer: mockEnsureDetachedHubServer,
|
|
probeHubServer: mockProbeHubServer,
|
|
readHubDiscovery: mockReadHubDiscovery,
|
|
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
|
|
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
|
|
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
|
|
}));
|
|
|
|
import { createHubCommand } from "./hub";
|
|
|
|
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
|
|
|
|
describe("createHubCommand", () => {
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
if (originalBuildEnv === undefined) {
|
|
delete process.env.CLINE_BUILD_ENV;
|
|
} else {
|
|
process.env.CLINE_BUILD_ENV = originalBuildEnv;
|
|
}
|
|
});
|
|
|
|
it("includes uptime in hub status output", async () => {
|
|
vi.spyOn(Date, "now").mockReturnValue(
|
|
new Date("2026-01-01T00:01:05.000Z").getTime(),
|
|
);
|
|
mockReadHubDiscovery.mockResolvedValue({
|
|
url: "ws://127.0.0.1:25463/hub",
|
|
port: 25463,
|
|
pid: 50174,
|
|
startedAt: "2026-01-01T00:00:00.000Z",
|
|
});
|
|
mockProbeHubServer.mockResolvedValue({
|
|
url: "ws://127.0.0.1:25463/hub",
|
|
port: 25463,
|
|
pid: 50174,
|
|
startedAt: "2026-01-01T00:00:00.000Z",
|
|
});
|
|
|
|
const output: string[] = [];
|
|
let exitCode = 0;
|
|
const cmd = createHubCommand(
|
|
{
|
|
writeln: (text) => {
|
|
output.push(text ?? "");
|
|
},
|
|
writeErr: () => {},
|
|
},
|
|
(code) => {
|
|
exitCode = code;
|
|
},
|
|
);
|
|
|
|
await cmd.parseAsync(["status"], { from: "user" });
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(JSON.parse(output[0] || "")).toMatchObject({
|
|
running: true,
|
|
url: "ws://127.0.0.1:25463/hub",
|
|
pid: 50174,
|
|
startedAt: "2026-01-01T00:00:00.000Z",
|
|
uptime: "1m 5s",
|
|
});
|
|
});
|
|
|
|
it("passes the selected owner to graceful stop", async () => {
|
|
process.env.CLINE_BUILD_ENV = "development";
|
|
mockReadHubDiscovery.mockResolvedValue({
|
|
url: "ws://127.0.0.1:25466/hub",
|
|
port: 25466,
|
|
pid: 50174,
|
|
});
|
|
mockStopLocalHubServerGracefully.mockResolvedValue(true);
|
|
|
|
const output: string[] = [];
|
|
let exitCode = 0;
|
|
const cmd = createHubCommand(
|
|
{
|
|
writeln: (text) => {
|
|
output.push(text ?? "");
|
|
},
|
|
writeErr: () => {},
|
|
},
|
|
(code) => {
|
|
exitCode = code;
|
|
},
|
|
);
|
|
|
|
await cmd.parseAsync(["stop"], { from: "user" });
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(mockStopLocalHubServerGracefully).toHaveBeenCalledWith({
|
|
ownerId: "hub-owner",
|
|
discoveryPath: "/tmp/cline-data/locks/hub/owners/hub-owner.json",
|
|
});
|
|
expect(JSON.parse(output[0] || "")).toEqual({ stopped: true });
|
|
});
|
|
});
|