mirror of
https://github.com/cline/cline.git
synced 2026-10-25 10:26:36 +08:00
* Let hosts choose the hub daemon launcher and defer spawn-only setup Adds setHubDaemonLauncher so an embedding process can start the hub daemon through a bundled Cline CLI instead of its own executable, and a beforeSpawn hook on ensureDetachedHubServer that only runs when a new daemon is actually started. * Add programmatic options to cline hub ensure hub ensure/start gain --json (URL + auth token), --allow-port-fallback, --no-connectors and --login-shell-path, and the hub command accepts --discovery-path for a dedicated discovery record. These let the desktop app and SSH remotes start or reuse a hub through the CLI. Also extracts the standalone binary compile step into script/compile-binary.ts so other packages can build the CLI binary. * Run SSH remote hubs through the Cline CLI SSH remotes now upload the Cline CLI binary and start or reuse the remote hub with 'cline hub ensure --json --discovery-path ...', stopping it with 'cline hub stop'. The dedicated remote helper entrypoint is removed; cleanup records from older releases still stop through the old flag. * Run the desktop backend on the bundled Cline CLI The desktop app no longer compiles its own executable. It bundles the self-contained Cline CLI (externalBin cline-cli) plus the backend as a JS bundle that runs on the CLI's embedded runtime. At startup the backend has the CLI run 'hub ensure', which reuses a compatible healthy Hub or starts one, and any Hub it starts later also goes through the CLI binary. Linux CLI copies are bundled for SSH remotes. * Launch the desktop backend through the bundled Cline CLI The Tauri shell resolves the bundled cline-cli and the backend bundle (per-platform resource layouts) and starts 'cline-cli run' on it. The Windows installer stops users of cline-cli.exe, and also stops and removes the backend executable of earlier releases on upgrade. * Recognize the CLI-hosted desktop backend in cline doctor --fix * Update desktop publish workflow and docs for the bundled Cline CLI Telemetry selfchecks and smoke tests run the backend bundle on the bundled CLI, and the Windows signature gate verifies cline-cli.exe. * fix(desktop): address runtime packaging review findings * fix(desktop): preserve failed hub cleanup and handle exited daemons * fix(hub): distinguish missing discovery records from read failures * fix(hub): preserve discovery recovery while enforcing strict shutdown reads * fix(cli): do not count hub commands as CLI activations (#14870) * fix(cli): do not count hub commands as CLI activations The desktop app runs `cline-cli hub ensure` on every launch and the SSH remote flow runs `cline hub ensure` on every connect. Both went through runCli(), which captured user.extension_activated unconditionally, so each desktop launch was double-counted as a CLI activation and every SSH remote host (with its own distinct id) showed up as a new CLI user. Skip the activation capture for the hub subcommand; it is plumbing other clients invoke, not a user activation. Hub-owned task telemetry is unaffected since the daemon path never enters runCli(). * test(cli): clear the hub command mock between main.test.ts cases * test(cli): stop stubbing the hub command module in main.test.ts The module-wide vi.mock("./commands/hub") replaced createHubCommand with a no-op, so `cline hub dashboard` (routed through createHubCommand) never reached the dashboard mock and three dashboard tests failed. Drop the module mock and assert the activation exclusion through `hub dashboard --no-open`, which is already mocked. --------- Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
273 lines
7.8 KiB
TypeScript
273 lines
7.8 KiB
TypeScript
#!/usr/bin/env bun
|
|
|
|
import {
|
|
cpSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
rmSync,
|
|
statSync,
|
|
} from "node:fs";
|
|
import { type AddressInfo, createServer } from "node:net";
|
|
import { tmpdir } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
import { $ } from "bun";
|
|
import {
|
|
parseBuildOptions,
|
|
shouldInstallNativeVariants,
|
|
validateBuildOptions,
|
|
} from "./build-options";
|
|
import {
|
|
compileCliBinary,
|
|
installOpenTuiNativeVariants,
|
|
} from "./compile-binary";
|
|
|
|
const cliDir = resolve(import.meta.dir, "..");
|
|
const rootDir = resolve(cliDir, "../..");
|
|
process.chdir(cliDir);
|
|
|
|
const pkg = JSON.parse(readFileSync(join(cliDir, "package.json"), "utf-8"));
|
|
const version: string = pkg.version;
|
|
const repository: unknown = pkg.repository;
|
|
|
|
console.log(`Building @cline/cli v${version}`);
|
|
|
|
const buildOptions = parseBuildOptions(process.argv.slice(2));
|
|
|
|
const allTargets: {
|
|
os: string;
|
|
arch: "arm64" | "x64";
|
|
}[] = [
|
|
{ os: "linux", arch: "arm64" },
|
|
{ os: "linux", arch: "x64" },
|
|
{ os: "darwin", arch: "arm64" },
|
|
{ os: "darwin", arch: "x64" },
|
|
{ os: "win32", arch: "x64" },
|
|
{ os: "win32", arch: "arm64" },
|
|
];
|
|
|
|
const targets = buildOptions.single
|
|
? allTargets.filter(
|
|
(item) => item.os === process.platform && item.arch === process.arch,
|
|
)
|
|
: allTargets;
|
|
|
|
const opentuiVersion = pkg.dependencies["@opentui/core"];
|
|
const optionsError = validateBuildOptions({
|
|
options: buildOptions,
|
|
opentuiVersion,
|
|
targetCount: targets.length,
|
|
});
|
|
if (optionsError) {
|
|
console.error(optionsError);
|
|
process.exit(1);
|
|
}
|
|
|
|
await $`rm -rf dist`;
|
|
|
|
// Pre-install all platform variants of native packages so cross-compilation
|
|
// can resolve them. Without this, Bun only has the host platform's native
|
|
// binary and cross-compiled builds fail to resolve @opentui/core's FFI layer.
|
|
if (shouldInstallNativeVariants({ options: buildOptions, opentuiVersion })) {
|
|
await installOpenTuiNativeVariants();
|
|
}
|
|
|
|
// Build the SDK first (the CLI bundles workspace packages)
|
|
if (!buildOptions.skipSdkBuild) {
|
|
console.log("Building SDK packages...");
|
|
await $`bun run build:sdk`.cwd(rootDir);
|
|
|
|
console.log("Building CLI bundle...");
|
|
await $`bun -F @cline/cli build`.cwd(rootDir);
|
|
}
|
|
|
|
const hubWebviewSource = join(cliDir, "../cline-hub/src/webview");
|
|
const hubWebviewDist = join(cliDir, "../cline-hub/dist/webview");
|
|
const hubWebviewIndex = join(hubWebviewDist, "index.html");
|
|
|
|
function newestFileMtimeMs(dir: string): number {
|
|
let newest = 0;
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
if (
|
|
entry.name === "node_modules" ||
|
|
entry.name === "dist" ||
|
|
entry.name === ".turbo"
|
|
) {
|
|
continue;
|
|
}
|
|
const path = join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
newest = Math.max(newest, newestFileMtimeMs(path));
|
|
} else if (entry.isFile()) {
|
|
newest = Math.max(newest, statSync(path).mtimeMs);
|
|
}
|
|
}
|
|
return newest;
|
|
}
|
|
|
|
function shouldBuildHubWebview(): boolean {
|
|
if (!existsSync(hubWebviewIndex)) {
|
|
return true;
|
|
}
|
|
try {
|
|
return (
|
|
newestFileMtimeMs(hubWebviewSource) > statSync(hubWebviewIndex).mtimeMs
|
|
);
|
|
} catch {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if (shouldBuildHubWebview()) {
|
|
console.log("Building Cline Hub webview...");
|
|
await $`bun -F @cline/cline-hub build:webview`.cwd(rootDir);
|
|
}
|
|
|
|
const binaries: Record<string, string> = {};
|
|
|
|
function findFreePort(): Promise<number> {
|
|
return new Promise((resolvePort, reject) => {
|
|
const server = createServer();
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const { port } = server.address() as AddressInfo;
|
|
server.close(() => resolvePort(port));
|
|
});
|
|
});
|
|
}
|
|
|
|
function getBunTarget(
|
|
item: (typeof allTargets)[number],
|
|
): Bun.Build.CompileTarget {
|
|
const targetOs = item.os === "win32" ? "windows" : item.os;
|
|
return `bun-${targetOs}-${item.arch}` as Bun.Build.CompileTarget;
|
|
}
|
|
|
|
for (const item of targets) {
|
|
// npm treats "win32" specially in os field, but for package naming use "windows"
|
|
const displayOs = item.os === "win32" ? "windows" : item.os;
|
|
const name = `@cline/cli-${displayOs}-${item.arch}`;
|
|
const dirName = `cli-${displayOs}-${item.arch}`;
|
|
const binaryName = item.os === "win32" ? "cline.exe" : "cline";
|
|
const bunTarget = getBunTarget(item);
|
|
|
|
console.log(`\nBuilding ${name} (target: ${bunTarget})...`);
|
|
const outDir = join(cliDir, `dist/${dirName}/bin`);
|
|
mkdirSync(outDir, { recursive: true });
|
|
|
|
const outfile = join(outDir, binaryName);
|
|
|
|
try {
|
|
await compileCliBinary({ bunTarget, outfile });
|
|
} catch (error) {
|
|
console.error(`Build failed for ${dirName}:`, error);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Smoke test: only run on current platform
|
|
if (item.os === process.platform && item.arch === process.arch) {
|
|
console.log(` Smoke test: ${outfile} --version`);
|
|
try {
|
|
const output = await $`${outfile} --version`.text();
|
|
const actualVersion = output.trim();
|
|
if (actualVersion !== version) {
|
|
throw new Error(
|
|
`Expected --version to print ${version}, got ${actualVersion}`,
|
|
);
|
|
}
|
|
console.log(` Passed: ${actualVersion}`);
|
|
|
|
// `--version` only proves the binary launches. Bun's bundler has had
|
|
// compile-time regressions (chunk ordering with `splitting: true`)
|
|
// that only surface once real modules load, so also boot the hub
|
|
// daemon from this binary in an isolated data dir and confirm it
|
|
// answers a health probe before shipping.
|
|
console.log(` Smoke test: ${outfile} hub start/status/stop`);
|
|
const smokeHome = mkdtempSync(join(tmpdir(), "cline-smoke-"));
|
|
const smokeEnv = {
|
|
...process.env,
|
|
HOME: smokeHome,
|
|
USERPROFILE: smokeHome,
|
|
CLINE_DIR: join(smokeHome, ".cline"),
|
|
CLINE_DATA_DIR: join(smokeHome, "data"),
|
|
// Keep clear of a developer's real hub: its discovery record
|
|
// (which CLINE_HUB_DISCOVERY_PATH can point anywhere) and the
|
|
// default port.
|
|
CLINE_HUB_DISCOVERY_PATH: join(smokeHome, "hub-discovery.json"),
|
|
CLINE_HUB_PORT: String(await findFreePort()),
|
|
};
|
|
try {
|
|
await $`${outfile} hub start`.env(smokeEnv).quiet();
|
|
const status = JSON.parse(
|
|
await $`${outfile} hub status`.env(smokeEnv).text(),
|
|
) as { running?: boolean; coreVersion?: string };
|
|
if (!status.running || !status.coreVersion) {
|
|
throw new Error(
|
|
`Expected a healthy hub, got ${JSON.stringify(status)}`,
|
|
);
|
|
}
|
|
console.log(` Passed: hub core ${status.coreVersion}`);
|
|
} finally {
|
|
await $`${outfile} hub stop`.env(smokeEnv).quiet().nothrow();
|
|
rmSync(smokeHome, { recursive: true, force: true });
|
|
}
|
|
} catch (e) {
|
|
console.error(` Smoke test FAILED for ${name}:`, e);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Copy plugin sandbox bootstrap if it exists
|
|
const bootstrapSrc = join(
|
|
rootDir,
|
|
"sdk/packages/core/dist/extensions/plugin-sandbox-bootstrap.js",
|
|
);
|
|
if (existsSync(bootstrapSrc)) {
|
|
const bootstrapDir = join(cliDir, `dist/${dirName}/extensions`);
|
|
mkdirSync(bootstrapDir, { recursive: true });
|
|
const content = readFileSync(bootstrapSrc);
|
|
await Bun.write(join(bootstrapDir, "plugin-sandbox-bootstrap.js"), content);
|
|
}
|
|
|
|
if (existsSync(hubWebviewDist)) {
|
|
const hubWebviewDest = join(cliDir, `dist/${dirName}/cline-hub/webview`);
|
|
mkdirSync(join(cliDir, `dist/${dirName}/cline-hub`), {
|
|
recursive: true,
|
|
});
|
|
cpSync(hubWebviewDist, hubWebviewDest, { recursive: true });
|
|
}
|
|
|
|
// Generate platform package.json
|
|
await Bun.write(
|
|
join(cliDir, `dist/${dirName}/package.json`),
|
|
`${JSON.stringify(
|
|
{
|
|
name,
|
|
version,
|
|
description: `Cline CLI binary for ${displayOs} ${item.arch}`,
|
|
os: [item.os],
|
|
cpu: [item.arch],
|
|
...(repository ? { repository } : {}),
|
|
bin: {
|
|
cline: `bin/${binaryName}`,
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
);
|
|
|
|
binaries[name] = version;
|
|
console.log(` Built ${name}`);
|
|
}
|
|
|
|
console.log(`\nBuild complete. ${Object.keys(binaries).length} targets built.`);
|
|
console.log("Packages:");
|
|
for (const [name, ver] of Object.entries(binaries)) {
|
|
console.log(` ${name}@${ver}`);
|
|
}
|
|
|
|
export { binaries, version };
|