From 5f92120114bcf218b49a2bf165bc63b9c6357d9b Mon Sep 17 00:00:00 2001 From: musistudio Date: Sun, 5 Jul 2026 22:38:09 +0800 Subject: [PATCH] Bundle bot gateway SDK and simplify CLI delegation --- build/esbuild.config.mjs | 70 ++++++++- build/verify-packaged-app.cjs | 21 +++ electron-builder.json | 13 +- package-lock.json | 3 + packages/cli/src/cli.ts | 141 ++++-------------- packages/core/src/agents/bot-gateway/env.ts | 20 +++ .../agents/bot-gateway/qr-login-service.ts | 15 ++ .../agents/codex/cli-middleware-runtime.ts | 18 +++ packages/core/src/gateway/service.ts | 18 +++ packages/core/src/profiles/launch-service.ts | 53 ++++++- packages/core/src/profiles/service.ts | 1 + packages/electron/package.json | 3 + packages/electron/src/main/ipc.ts | 7 +- 13 files changed, 258 insertions(+), 125 deletions(-) diff --git a/build/esbuild.config.mjs b/build/esbuild.config.mjs index 86ad8b0..e94398e 100644 --- a/build/esbuild.config.mjs +++ b/build/esbuild.config.mjs @@ -1,11 +1,12 @@ import esbuild from "esbuild"; import { spawn } from "node:child_process"; -import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; -import { builtinModules } from "node:module"; +import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { builtinModules, createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const requireFromHere = createRequire(import.meta.url); export const projectRoot = path.resolve(__dirname, ".."); export const packagesRoot = path.join(projectRoot, "packages"); @@ -27,6 +28,18 @@ export const cliMainOutDir = path.join(cliDistDir, "main"); export const coreMainOutDir = path.join(coreDistDir, "main"); export const electronMainOutDir = path.join(electronDistDir, "main"); export const mainOutDir = electronMainOutDir; +export const gatewayPackageRoot = path.dirname(requireFromHere.resolve("@the-next-ai/ai-gateway/package.json")); +export const gatewayRuntimeInput = path.join(gatewayPackageRoot, "bin", "next-ai-gateway.js"); +export const electronGatewayRuntimeOutput = path.join(electronMainOutDir, "next-ai-gateway.js"); +export const botGatewaySdkPackageRoot = path.dirname(requireFromHere.resolve("@the-next-ai/bot-gateway-sdk/package.json")); +export const botGatewaySdkEntryInput = path.join(botGatewaySdkPackageRoot, "dist", "index.js"); +export const botGatewaySdkRunnerInput = path.join(botGatewaySdkPackageRoot, "bin", "bot-gateway-stdio.mjs"); +export const electronBotGatewaySdkRootDir = path.join(electronMainOutDir, "bot-gateway-sdk"); +export const electronBotGatewaySdkDistDir = path.join(electronBotGatewaySdkRootDir, "dist"); +export const electronBotGatewaySdkBinDir = path.join(electronBotGatewaySdkRootDir, "bin"); +export const electronBotGatewaySdkPackageOutput = path.join(electronBotGatewaySdkRootDir, "package.json"); +export const electronBotGatewaySdkEntryOutput = path.join(electronBotGatewaySdkDistDir, "index.js"); +export const electronBotGatewaySdkRunnerOutput = path.join(electronBotGatewaySdkBinDir, "bot-gateway-stdio.mjs"); export const rendererOutDir = path.join(uiDistDir, "renderer"); export const cliRendererOutDir = path.join(cliDistDir, "renderer"); export const coreRendererOutDir = path.join(coreDistDir, "renderer"); @@ -86,6 +99,8 @@ export function ensureDist() { mkdirSync(cliMainOutDir, { recursive: true }); mkdirSync(coreMainOutDir, { recursive: true }); mkdirSync(electronMainOutDir, { recursive: true }); + mkdirSync(electronBotGatewaySdkDistDir, { recursive: true }); + mkdirSync(electronBotGatewaySdkBinDir, { recursive: true }); mkdirSync(appAssetsDir, { recursive: true }); mkdirSync(cliMarketplacePluginsDir, { recursive: true }); mkdirSync(coreMarketplacePluginsDir, { recursive: true }); @@ -178,6 +193,18 @@ function hasScriptTag(html, scriptTag) { return sourceMatch ? html.includes(sourceMatch[1]) : html.includes(scriptTag); } +function normalizeDuplicateShebangs(source) { + const lines = source.split("\n"); + if (!lines[0]?.startsWith("#!")) { + return source; + } + let index = 1; + while (lines[index]?.startsWith("#!")) { + index += 1; + } + return [lines[0], ...lines.slice(index)].join("\n"); +} + export function createMainBuildOptions({ mode = "production", plugins = [] } = {}) { return { absWorkingDir: projectRoot, @@ -186,6 +213,7 @@ export function createMainBuildOptions({ mode = "production", plugins = [] } = { entryPoints: [ path.join(electronSourceRoot, "main", "main.ts"), path.join(electronSourceRoot, "main", "browser-preload.ts"), + gatewayRuntimeInput, path.join(coreSourceRoot, "mcp", "browser-web-search-proxy-mcp.ts"), path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"), path.join(coreSourceRoot, "mcp", "fusion-tool-fallback-mcp.ts"), @@ -316,6 +344,27 @@ export function createWebClientBridgeBuildOptions({ mode = "production", plugins }; } +export function createBotGatewaySdkBuildOptions({ mode = "production", plugins = [] } = {}) { + return { + absWorkingDir: projectRoot, + bundle: true, + entryPoints: [botGatewaySdkEntryInput], + external: [ + ...builtinModules, + ...builtinModules.map((moduleName) => `node:${moduleName}`) + ], + format: "esm", + legalComments: "none", + logLevel: "info", + minify: mode === "production", + outfile: electronBotGatewaySdkEntryOutput, + platform: "node", + plugins, + sourcemap: mode !== "production", + target: "node22" + }; +} + export function watchPlugin(name, onEnd) { return { name: `${name}-watch`, @@ -332,6 +381,7 @@ export function watchPlugin(name, onEnd) { export async function buildMain(options = {}) { const [mainBuildResult] = await Promise.all([ esbuild.build(createMainBuildOptions(options)), + buildBotGatewaySdkRuntime(options), buildCoreServer(options), buildCli(options) ]); @@ -339,6 +389,22 @@ export async function buildMain(options = {}) { validateLightweightMcpBundles(mainBuildResult.metafile); } +export async function buildBotGatewaySdkRuntime(options = {}) { + ensureDist(); + await esbuild.build(createBotGatewaySdkBuildOptions(options)); + writeFileSync( + electronBotGatewaySdkPackageOutput, + `${JSON.stringify({ private: true, type: "module" }, null, 2)}\n`, + "utf8" + ); + writeFileSync( + electronBotGatewaySdkRunnerOutput, + normalizeDuplicateShebangs(readFileSync(botGatewaySdkRunnerInput, "utf8")), + "utf8" + ); + chmodSync(electronBotGatewaySdkRunnerOutput, 0o755); +} + export async function buildCli(options = {}) { await esbuild.build(createCliBuildOptions(options)); } diff --git a/build/verify-packaged-app.cjs b/build/verify-packaged-app.cjs index 0d6371a..ee52160 100644 --- a/build/verify-packaged-app.cjs +++ b/build/verify-packaged-app.cjs @@ -9,6 +9,19 @@ const betterSqliteNativeRelativePath = path.join( "Release", "better_sqlite3.node" ); +const betterSqlitePackageRelativePath = path.join("app.asar.unpacked", "node_modules", "better-sqlite3"); +const betterSqlitePrunablePaths = [ + "deps", + "src", + "binding.gyp", + "README.md", + "docs", + "benchmark", + "benchmarks", + "test", + path.join("build", "Release", "obj"), + path.join("build", "Release", "obj.target") +]; module.exports = async function verifyPackagedApp(context) { const platform = context?.electronPlatformName; @@ -21,6 +34,7 @@ module.exports = async function verifyPackagedApp(context) { const resourcesDir = findResourcesDir(appOutDir, platform); assertFile(path.join(resourcesDir, "app.asar"), "Packaged app archive"); + cleanupBetterSqlitePackage(resourcesDir); const nativeModule = path.join(resourcesDir, betterSqliteNativeRelativePath); assertFile(nativeModule, "better-sqlite3 native module"); @@ -41,6 +55,13 @@ module.exports = async function verifyPackagedApp(context) { } }; +function cleanupBetterSqlitePackage(resourcesDir) { + const packageDir = path.join(resourcesDir, betterSqlitePackageRelativePath); + for (const relativePath of betterSqlitePrunablePaths) { + fs.rmSync(path.join(packageDir, relativePath), { force: true, recursive: true }); + } +} + function findResourcesDir(appOutDir, platform) { if (platform !== "darwin") { return path.join(appOutDir, "resources"); diff --git a/electron-builder.json b/electron-builder.json index fc3c509..9f465b7 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -5,6 +5,7 @@ "asarUnpack": [ "**/*.node" ], + "electronLanguages": ["en-US", "zh-CN", "zh-TW", "zh_CN", "zh_TW"], "npmRebuild": true, "publish": [ { @@ -15,6 +16,7 @@ } ], "directories": { + "app": "packages/electron", "output": "release/${version}" }, "afterPack": "build/verify-packaged-app.cjs", @@ -26,8 +28,15 @@ } ], "files": [ - "packages/electron/dist", - "package.json" + "dist", + "package.json", + "!node_modules/better-sqlite3/deps/**", + "!node_modules/better-sqlite3/src/**", + "!node_modules/better-sqlite3/binding.gyp", + "!node_modules/better-sqlite3/README.md", + "!node_modules/better-sqlite3/docs/**", + "!node_modules/better-sqlite3/benchmark/**", + "!node_modules/better-sqlite3/test/**" ], "mac": { "icon": "build/icon.icns", diff --git a/package-lock.json b/package-lock.json index 71997bb..d9728ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9579,6 +9579,9 @@ "name": "@claude-code-router/electron", "version": "3.0.7", "dependencies": { + "better-sqlite3": "^12.11.1" + }, + "devDependencies": { "electron-updater": "^6.8.9" } }, diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 8a957de..60cd3a8 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node -import { spawn, spawnSync } from "node:child_process"; +import { spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; -import { accessSync, constants as fsConstants, existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import path from "node:path"; import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env"; import { applyClaudeAppGatewayConfig } from "@ccr/core/agents/claude-app/gateway-service"; @@ -56,14 +56,9 @@ const serviceStartTimeoutMs = 30_000; const serviceStopTimeoutMs = 10_000; const webAuthHeader = "x-ccr-web-auth"; const webAuthQueryParam = "ccr_web_token"; +const defaultCliCommandName = "ccr"; async function main(): Promise { - const delegatedExitCode = delegateManagedDesktopCliToExternalCli(); - if (delegatedExitCode !== undefined) { - process.exitCode = delegatedExitCode; - return; - } - const options = parseArgs(process.argv.slice(2)); if (options.command === "start") { if (options.help) { @@ -455,20 +450,21 @@ async function stopService(): Promise { } function printHelp(exitCode: number): void { + const command = cliCommandName(); const output = [ "Usage:", - " ccr start [--host ] [--port ] [--open] [--no-gateway]", - " ccr ui [--host ] [--port ] [--no-gateway]", - " ccr stop", - " ccr [cli|app] [-- ]", + ` ${command} start [--host ] [--port ] [--open] [--no-gateway]`, + ` ${command} ui [--host ] [--port ] [--no-gateway]`, + ` ${command} stop`, + ` ${command} [cli|app] [-- ]`, "", "Examples:", - " ccr start", - " ccr ui", - " ccr stop", - " ccr Codex", - " ccr default-codex -- --model gpt-5-codex", - " ccr default-codex app" + ` ${command} start`, + ` ${command} ui`, + ` ${command} stop`, + ` ${command} Codex`, + ` ${command} default-codex -- --model gpt-5-codex`, + ` ${command} default-codex app` ].join("\n"); const stream = exitCode === 0 ? process.stdout : process.stderr; stream.write(`${output}\n`); @@ -476,9 +472,10 @@ function printHelp(exitCode: number): void { } function printStartHelp(exitCode: number): void { + const command = cliCommandName(); const output = [ "Usage:", - " ccr start [--host ] [--port ] [--open] [--no-gateway]", + ` ${command} start [--host ] [--port ] [--open] [--no-gateway]`, "", "Options:", " --host Management server host. Defaults to 127.0.0.1.", @@ -496,9 +493,10 @@ function printStartHelp(exitCode: number): void { } function printUiHelp(exitCode: number): void { + const command = cliCommandName(); const output = [ "Usage:", - " ccr ui [--host ] [--port ] [--no-gateway]", + ` ${command} ui [--host ] [--port ] [--no-gateway]`, "", "Starts the background CCR service if needed and opens the management UI in the default browser.", "", @@ -517,11 +515,12 @@ function printUiHelp(exitCode: number): void { } function printStopHelp(exitCode: number): void { + const command = cliCommandName(); const output = [ "Usage:", - " ccr stop", + ` ${command} stop`, "", - "Stops the background CCR service started by `ccr start`." + `Stops the background CCR service started by \`${command} start\`.` ].join("\n"); const stream = exitCode === 0 ? process.stdout : process.stderr; stream.write(`${output}\n`); @@ -529,9 +528,10 @@ function printStopHelp(exitCode: number): void { } function printWebHelp(exitCode: number): void { + const command = cliCommandName(); const output = [ "Usage:", - " ccr serve [--host ] [--port ] [--open] [--no-gateway]", + ` ${command} serve [--host ] [--port ] [--open] [--no-gateway]`, "", "Options:", " --host Management server host. Defaults to 127.0.0.1.", @@ -547,6 +547,13 @@ function printWebHelp(exitCode: number): void { process.exitCode = exitCode; } +function cliCommandName(): string { + const configured = process.env.CCR_CLI_COMMAND_NAME?.trim(); + return configured && /^[A-Za-z0-9._-]+$/.test(configured) + ? configured + : defaultCliCommandName; +} + function readServiceState(): ServiceState | undefined { const file = serviceStateFile(); if (!existsSync(file)) { @@ -597,94 +604,6 @@ function currentCliScript(): string { return __filename; } -function delegateManagedDesktopCliToExternalCli(): number | undefined { - if (!isManagedDesktopCliRuntime()) { - return undefined; - } - if (process.env.CCR_MANAGED_CLI_NO_DELEGATE === "1" || process.env.CCR_MANAGED_CLI_DELEGATED === "1") { - return undefined; - } - - const externalCcr = findExternalCcrCommand(); - if (!externalCcr) { - return undefined; - } - - const launch = profileLaunchSpawnCommand({ - args: process.argv.slice(2), - command: externalCcr - }); - const result = spawnSync(launch.command, launch.args, { - env: { - ...process.env, - CCR_MANAGED_CLI_DELEGATED: "1" - }, - stdio: "inherit", - windowsVerbatimArguments: !!launch.windowsVerbatimArguments - }); - if (result.error) { - return undefined; - } - if (typeof result.status === "number") { - return result.status; - } - return result.signal === "SIGINT" ? 130 : 1; -} - -function isManagedDesktopCliRuntime(): boolean { - const script = process.argv[1] || __filename; - return samePath(path.resolve(script), path.join(CONFIGDIR, "bin", "ccr-cli.js")); -} - -function findExternalCcrCommand(): string | undefined { - const pathKey = process.platform === "win32" - ? Object.keys(process.env).find((key) => key.toLowerCase() === "path") || "Path" - : "PATH"; - const pathValue = process.env[pathKey] || ""; - const managedBinDir = path.resolve(CONFIGDIR, "bin"); - const names = process.platform === "win32" - ? ["ccr.cmd", "ccr.exe", "ccr.bat", "ccr"] - : ["ccr"]; - - for (const rawSegment of pathValue.split(path.delimiter)) { - const dir = path.resolve(rawSegment || "."); - if (samePath(dir, managedBinDir)) { - continue; - } - for (const name of names) { - const candidate = path.join(dir, name); - if (isExecutableFile(candidate)) { - return candidate; - } - } - } - return undefined; -} - -function isExecutableFile(file: string): boolean { - try { - const stats = statSync(file); - if (!stats.isFile() && !stats.isSymbolicLink()) { - return false; - } - if (process.platform === "win32") { - return true; - } - accessSync(file, fsConstants.X_OK); - return true; - } catch { - return false; - } -} - -function samePath(left: string, right: string): boolean { - const normalizedLeft = path.normalize(left); - const normalizedRight = path.normalize(right); - return process.platform === "win32" - ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() - : normalizedLeft === normalizedRight; -} - async function waitForServiceState(pid: number | undefined, timeoutMs: number): Promise { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { diff --git a/packages/core/src/agents/bot-gateway/env.ts b/packages/core/src/agents/bot-gateway/env.ts index afc2041..1dd8e60 100644 --- a/packages/core/src/agents/bot-gateway/env.ts +++ b/packages/core/src/agents/bot-gateway/env.ts @@ -1,5 +1,6 @@ import os from "node:os"; import { createRequire } from "node:module"; +import { existsSync } from "node:fs"; import path from "node:path"; import type { AppConfig, BotGatewayRuntimeConfig, ProfileConfig, ProfileOpenSurface } from "@ccr/core/contracts/app"; import { CONFIGDIR } from "@ccr/core/config/constants"; @@ -126,6 +127,11 @@ function botGatewaySdkEnv(): Record { } function resolveBotGatewaySdkModule(): string { + const bundled = resolveBundledBotGatewaySdkModule(); + if (bundled) { + return bundled; + } + try { return path.join(path.dirname(requireFromHere.resolve("@the-next-ai/bot-gateway-sdk/package.json")), "dist", "index.js"); } catch { @@ -133,6 +139,20 @@ function resolveBotGatewaySdkModule(): string { } } +function resolveBundledBotGatewaySdkModule(): string { + const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; + const candidates = [ + path.join(__dirname, "bot-gateway-sdk", "dist", "index.js"), + ...(resourcesPath + ? [ + path.join(resourcesPath, "app.asar", "dist", "main", "bot-gateway-sdk", "dist", "index.js"), + path.join(resourcesPath, "app", "dist", "main", "bot-gateway-sdk", "dist", "index.js") + ] + : []) + ]; + return candidates.find((candidate) => existsSync(candidate)) ?? ""; +} + function normalizeBotGatewayForWebSocket(bot: BotGatewayRuntimeConfig): BotGatewayRuntimeConfig { const platform = normalizeBotGatewayPlatform(bot.platform); return { diff --git a/packages/core/src/agents/bot-gateway/qr-login-service.ts b/packages/core/src/agents/bot-gateway/qr-login-service.ts index 4f60b45..cf1f665 100644 --- a/packages/core/src/agents/bot-gateway/qr-login-service.ts +++ b/packages/core/src/agents/bot-gateway/qr-login-service.ts @@ -244,6 +244,7 @@ async function loadBotGatewaySdk(): Promise { async function importBotGatewaySdk(): Promise { const candidates = [ process.env.CCR_BOT_GATEWAY_SDK_MODULE, + resolveBundledBotGatewaySdkModule(), "@the-next-ai/bot-gateway-sdk" ].filter((value): value is string => Boolean(value?.trim())); const errors: string[] = []; @@ -261,6 +262,20 @@ async function importBotGatewaySdk(): Promise { throw new Error(`Unable to load @the-next-ai/bot-gateway-sdk. ${errors.join("; ")}`); } +function resolveBundledBotGatewaySdkModule(): string { + const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; + const candidates = [ + path.join(__dirname, "bot-gateway-sdk", "dist", "index.js"), + ...(resourcesPath + ? [ + path.join(resourcesPath, "app.asar", "dist", "main", "bot-gateway-sdk", "dist", "index.js"), + path.join(resourcesPath, "app", "dist", "main", "bot-gateway-sdk", "dist", "index.js") + ] + : []) + ]; + return candidates.find((candidate) => existsSync(candidate)) ?? ""; +} + function botGatewaySdkImportSpecifier(value: string): string { const trimmed = value.trim(); if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) { diff --git a/packages/core/src/agents/codex/cli-middleware-runtime.ts b/packages/core/src/agents/codex/cli-middleware-runtime.ts index 2f7b30b..f7149fc 100644 --- a/packages/core/src/agents/codex/cli-middleware-runtime.ts +++ b/packages/core/src/agents/codex/cli-middleware-runtime.ts @@ -3246,6 +3246,10 @@ async function importBotGatewaySdk() { if (configured) { candidates.push(configured); } + const bundled = bundledBotGatewaySdkModule(); + if (bundled) { + candidates.push(bundled); + } candidates.push("@the-next-ai/bot-gateway-sdk"); const errors = []; for (const candidate of candidates) { @@ -3262,6 +3266,20 @@ async function importBotGatewaySdk() { throw new Error("Unable to load @the-next-ai/bot-gateway-sdk. " + errors.join("; ")); } +function bundledBotGatewaySdkModule() { + const resourcesPath = process["resourcesPath"]; + const candidates = [ + path.join(__dirname, "bot-gateway-sdk", "dist", "index.js"), + ...(resourcesPath + ? [ + path.join(resourcesPath, "app.asar", "dist", "main", "bot-gateway-sdk", "dist", "index.js"), + path.join(resourcesPath, "app", "dist", "main", "bot-gateway-sdk", "dist", "index.js") + ] + : []) + ]; + return candidates.find((candidate) => fs.existsSync(candidate)) || ""; +} + function botGatewaySdkImportSpecifier(value) { const trimmed = String(value || "").trim(); if (!trimmed) return "@the-next-ai/bot-gateway-sdk"; diff --git a/packages/core/src/gateway/service.ts b/packages/core/src/gateway/service.ts index 9ef07fb..b455939 100644 --- a/packages/core/src/gateway/service.ts +++ b/packages/core/src/gateway/service.ts @@ -6071,6 +6071,11 @@ function resolveGatewayEntry(): string { return entry; } + const bundledEntry = resolveBundledGatewayEntry(); + if (bundledEntry) { + return bundledEntry; + } + for (const packageName of gatewayPackageCandidates) { try { return requireFromHere.resolve(packageName); @@ -6081,6 +6086,19 @@ function resolveGatewayEntry(): string { return requireFromHere.resolve(gatewayPackageCandidates[0]); } +function resolveBundledGatewayEntry(): string | undefined { + const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; + return [ + pathJoin(__dirname, "next-ai-gateway.js"), + ...(resourcesPath + ? [ + pathJoin(resourcesPath, "app.asar", "dist", "main", "next-ai-gateway.js"), + pathJoin(resourcesPath, "app", "dist", "main", "next-ai-gateway.js") + ] + : []) + ].find((candidate) => existsSync(candidate)); +} + function createGatewayProcessEnv(config: AppConfig, upstreamProxyUrl: string | undefined, runtimeId: string, coreAuthToken: string): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env, diff --git a/packages/core/src/profiles/launch-service.ts b/packages/core/src/profiles/launch-service.ts index 66bec32..9731b48 100644 --- a/packages/core/src/profiles/launch-service.ts +++ b/packages/core/src/profiles/launch-service.ts @@ -1,5 +1,5 @@ import { spawn, spawnSync, type ChildProcess } from "node:child_process"; -import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { assertAvailableGatewayModels, type AppConfig, type ProfileOpenCommandResult, type ProfileOpenRequest, type ProfileOpenResult, type ProfileRuntimeEntry, type ProfileRuntimeStatus, type ProfileStopResult } from "@ccr/core/contracts/app"; @@ -17,9 +17,17 @@ import { broadcastWindowsEnvironmentChanged, windowsSystemCommand } from "@ccr/c const ccrPathBlockStart = "# >>> Claude Code Router CLI >>>"; const ccrPathBlockEnd = "# <<< Claude Code Router CLI <<<"; +export const desktopCliCommandName = "ccr-app"; +const desktopCliRuntimeFileName = "ccr-cli.js"; +const desktopCliCommandNameEnv = "CCR_CLI_COMMAND_NAME"; let claudeAppBotWorker: ChildProcess | undefined; let claudeAppBotWorkerProfileId: string | undefined; +type ProfileOpenCommandOptions = { + commandName?: string; + ensureLauncher?: boolean; +}; + type ProfileAppLaunchResult = { child: ChildProcess; claudeDesignProxy?: boolean; @@ -41,14 +49,16 @@ type RunningProfileApp = ProfileRuntimeEntry & { process.once("exit", () => stopClaudeAppBotWorker()); -export async function getProfileOpenCommand(config: AppConfig, request: ProfileOpenRequest): Promise { +export async function getProfileOpenCommand(config: AppConfig, request: ProfileOpenRequest, options: ProfileOpenCommandOptions = {}): Promise { assertAvailableGatewayModels(config); await applyProfileConfig(config); const profile = findProfileForOpen(config, request.profileId); const surface = resolveProfileOpenSurface(profile, request.surface); - ensureCcrCliLauncher(); + if (options.ensureLauncher) { + ensureCcrCliLauncher(); + } return { - command: profileOpenCommand(profile, surface, "ccr", commandProfileRef(config, profile)), + command: profileOpenCommand(profile, surface, options.commandName ?? "ccr", commandProfileRef(config, profile)), profileId: profile.id, profileName: profile.name, surface @@ -1083,13 +1093,14 @@ export function ensureCcrCliLauncher(): string { const binDir = path.join(CONFIGDIR, "bin"); mkdirSync(binDir, { recursive: true }); cleanupGeneratedBinBackups(); + cleanupLegacyCcrCliLauncher(binDir); - const runtimeFile = path.join(binDir, "ccr-cli.js"); + const runtimeFile = path.join(binDir, desktopCliRuntimeFileName); const runtimeSource = findBundledCcrCliSource(); writeFileIfChanged(runtimeFile, readFileSync(runtimeSource, "utf8")); chmodSafe(runtimeFile); - const launcherFile = path.join(binDir, process.platform === "win32" ? "ccr.cmd" : "ccr"); + const launcherFile = path.join(binDir, process.platform === "win32" ? `${desktopCliCommandName}.cmd` : desktopCliCommandName); const launcherContent = process.platform === "win32" ? windowsCcrLauncher(runtimeFile) : posixCcrLauncher(runtimeFile); @@ -1100,6 +1111,29 @@ export function ensureCcrCliLauncher(): string { return launcherFile; } +function cleanupLegacyCcrCliLauncher(binDir: string): void { + const legacyLauncherFile = path.join(binDir, process.platform === "win32" ? "ccr.cmd" : "ccr"); + if (!existsSync(legacyLauncherFile)) { + return; + } + try { + const source = readFileSync(legacyLauncherFile, "utf8"); + if (!isLegacyManagedCcrCliLauncher(source)) { + return; + } + rmSync(legacyLauncherFile, { force: true }); + } catch (error) { + console.warn(`[profile] Failed to remove legacy ccr launcher: ${formatError(error)}`); + } +} + +function isLegacyManagedCcrCliLauncher(source: string): boolean { + return source.includes("CCR_CLI_NODE_PATH") && + source.includes(desktopCliRuntimeFileName) && + source.includes("ELECTRON_RUN_AS_NODE=1") && + source.includes("CCR_NODE_BIN"); +} + function findBundledCcrCliSource(): string { const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; const candidates = [ @@ -1123,6 +1157,8 @@ function posixCcrLauncher(runtimeFile: string): string { const nodePath = bundledNodePath(); return [ "#!/bin/sh", + `${desktopCliCommandNameEnv}=${shQuote(desktopCliCommandName)}`, + `export ${desktopCliCommandNameEnv}`, `CCR_CLI_NODE_PATH=${shQuote(nodePath)}`, 'if [ -n "$NODE_PATH" ]; then', ' export NODE_PATH="$CCR_CLI_NODE_PATH:$NODE_PATH"', @@ -1141,6 +1177,7 @@ function windowsCcrLauncher(runtimeFile: string): string { return [ "@echo off", "setlocal", + `set "${desktopCliCommandNameEnv}=${desktopCliCommandName}"`, `set "CCR_CLI_RUNTIME=${cmdEnvValue(runtimeFile)}"`, `set "CCR_CLI_NODE_PATH=${cmdEnvValue(nodePath)}"`, "if defined NODE_PATH (", @@ -1316,7 +1353,7 @@ function shellRcPathBlock(): string { const binDir = "$HOME/.claude-code-router/bin"; return [ ccrPathBlockStart, - "# Added by Claude Code Router. Enables the ccr command in new shells.", + "# Added by Claude Code Router. Enables the ccr-app command in new shells.", 'case ":$PATH:" in', ` *":${binDir}:"*) ;;`, ` *) export PATH="${binDir}:$PATH" ;;`, @@ -1349,7 +1386,7 @@ function ensureFishPathBlock(file: string, binDir: string): void { function fishPathBlock(): string { return [ ccrPathBlockStart, - "# Added by Claude Code Router. Enables the ccr command in new shells.", + "# Added by Claude Code Router. Enables the ccr-app command in new shells.", 'set -l ccr_bin "$HOME/.claude-code-router/bin"', "if not contains $ccr_bin $PATH", " set -gx PATH $ccr_bin $PATH", diff --git a/packages/core/src/profiles/service.ts b/packages/core/src/profiles/service.ts index c4366d1..dbf8ba0 100644 --- a/packages/core/src/profiles/service.ts +++ b/packages/core/src/profiles/service.ts @@ -1068,6 +1068,7 @@ function generatedBinBackupBaseName(entry: string): string | undefined { function isManagedGeneratedBinFile(fileName: string): boolean { const normalized = fileName.replace(/\.cmd$/i, ""); return normalized === "ccr" || + normalized === "ccr-app" || normalized === "ccr-cli.js" || normalized === codexMiddlewareRuntimeFilename() || normalized.startsWith("ccr-claude-code-api-key-") || diff --git a/packages/electron/package.json b/packages/electron/package.json index 08551a0..ed1e75c 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -5,6 +5,9 @@ "description": "Claude Code Router Electron desktop shell.", "main": "dist/main/main.js", "dependencies": { + "better-sqlite3": "^12.11.1" + }, + "devDependencies": { "electron-updater": "^6.8.9" } } diff --git a/packages/electron/src/main/ipc.ts b/packages/electron/src/main/ipc.ts index ce6a657..3b0e7ab 100644 --- a/packages/electron/src/main/ipc.ts +++ b/packages/electron/src/main/ipc.ts @@ -23,7 +23,7 @@ import { getProviderCatalogModels } from "@ccr/core/providers/model-catalog"; import { getProviderPresets } from "@ccr/core/providers/presets/index"; import { checkGatewayProviderConnectivity, probeGatewayProvider, probeGatewayProviderCandidates } from "@ccr/core/providers/probe"; import { applyProfileConfig } from "@ccr/core/profiles/service"; -import { getProfileOpenCommand, getProfileRuntimeStatus, openProfileFromCcr, stopProfileFromCcr } from "@ccr/core/profiles/launch-service"; +import { desktopCliCommandName, getProfileOpenCommand, getProfileRuntimeStatus, openProfileFromCcr, stopProfileFromCcr } from "@ccr/core/profiles/launch-service"; import { ensureProxyCertificateAuthority } from "@ccr/core/proxy/certificates"; import { proxyService } from "@ccr/core/proxy/service"; import { listMcpServerTools } from "@ccr/core/mcp/tool-discovery"; @@ -93,7 +93,10 @@ ipcMain.handle(IPC_CHANNELS.appGetOnboardingFinished, async () => { ipcMain.handle(IPC_CHANNELS.appGetPendingProviderDeepLinks, () => deepLinkService.consumePendingProviderRequests()); ipcMain.handle(IPC_CHANNELS.appGetLocalAgentProviderCandidates, () => getLocalAgentProviderCandidates()); ipcMain.handle(IPC_CHANNELS.appGetProfileOpenCommand, async (_event, request: ProfileOpenRequest) => { - return getProfileOpenCommand(await loadAppConfig(), request); + return getProfileOpenCommand(await loadAppConfig(), request, { + commandName: desktopCliCommandName, + ensureLauncher: true + }); }); ipcMain.handle(IPC_CHANNELS.appGetProfileRuntimeStatus, () => { return getProfileRuntimeStatus();