diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 86b7318..0000000 --- a/.dockerignore +++ /dev/null @@ -1,17 +0,0 @@ -.git -.DS_Store -.env -.env.* -node_modules -dist -packages/*/dist -release -release-local -logs -tmp -test-results -playwright-report -blob-report -docs/node_modules -docs/dist -docs/.astro diff --git a/.gitignore b/.gitignore index 6efc5ae..291dafe 100644 --- a/.gitignore +++ b/.gitignore @@ -13,8 +13,4 @@ release tmp release-local logs -.opencat -test-results -playwright-report -blob-report -.tmp \ No newline at end of file +.opencat \ No newline at end of file diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 4c0c868..0000000 --- a/Dockerfile +++ /dev/null @@ -1,75 +0,0 @@ -ARG NODE_IMAGE=node:22-bookworm -ARG RUNTIME_NODE_IMAGE=node:22-bookworm-slim - -FROM ${NODE_IMAGE} AS build -WORKDIR /app - -COPY package.json package-lock.json ./ -COPY packages/cli/package.json packages/cli/package.json -COPY packages/core/package.json packages/core/package.json -COPY packages/electron/package.json packages/electron/package.json -COPY packages/ui/package.json packages/ui/package.json -RUN npm ci - -COPY . . -RUN npm run build:docker - -FROM ${NODE_IMAGE} AS production-deps -WORKDIR /app -ENV NODE_ENV=production - -COPY package.json package-lock.json ./ -COPY packages/core/package.json packages/core/package.json -RUN npm ci --omit=dev --workspace=@claude-code-router/core --include-workspace-root=false \ - && npm cache clean --force - -FROM ${RUNTIME_NODE_IMAGE} AS runtime -ENV NODE_ENV=production \ - CCR_DATA_DIR=/data \ - CCR_WEB_HOST=127.0.0.1 \ - CCR_WEB_PORT=3459 \ - CCR_NGINX_PORT=8080 \ - CCR_GATEWAY_HOST=127.0.0.1 \ - CCR_GATEWAY_PORT=3456 \ - CCR_GATEWAY_CORE_PORT=3457 \ - CCR_PUBLIC_HOST=127.0.0.1 \ - CCR_PUBLIC_PORT=3458 - -WORKDIR /app - -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates libstdc++6 nginx \ - && rm -rf /var/lib/apt/lists/* \ - && rm -f /etc/nginx/sites-enabled/default /etc/nginx/conf.d/default.conf \ - && rm -rf \ - /opt/yarn-* \ - /usr/local/bin/corepack \ - /usr/local/bin/npm \ - /usr/local/bin/npx \ - /usr/local/bin/yarn \ - /usr/local/bin/yarnpkg \ - /usr/local/include/node \ - /usr/local/lib/node_modules/corepack \ - /usr/local/lib/node_modules/npm \ - /usr/local/share/doc \ - /usr/local/share/man - -COPY package.json package-lock.json ./ -COPY packages/core/package.json packages/core/package.json -COPY --from=production-deps /app/node_modules node_modules - -COPY --from=build /app/packages/core/dist packages/core/dist -COPY --from=build /app/packages/ui/dist/renderer /usr/share/nginx/html -COPY docker/entrypoint.sh /usr/local/bin/ccr-docker-entrypoint -COPY docker/pm2.config.cjs docker/pm2.config.cjs - -RUN chmod +x /usr/local/bin/ccr-docker-entrypoint \ - && mkdir -p /data /run/nginx /var/lib/nginx /var/log/nginx - -VOLUME ["/data"] -EXPOSE 8080 - -HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD node -e "fetch('http://127.0.0.1:' + (process.env.CCR_NGINX_PORT || '8080') + '/').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" - -ENTRYPOINT ["ccr-docker-entrypoint"] diff --git a/README.md b/README.md index f32ddf1..95847ab 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,19 @@ -

Claude Code Router

+

Claude Code Router Desktop

Chinese README Discord X License - Desktop downloads Documentation

-
- - - - - - - - -
- - Kimi K2.7 Code sponsor banner - -
- - Kimi Code Subscription -  ·  - API Global -  ·  - API China - -
-

- Thanks to Kimi for sponsoring this project! Kimi K2.7 Code is an open-source, coding-focused agentic model developed by Moonshot AI, with substantial gains on real-world long-horizon coding tasks and higher end-to-end success across complex software engineering workflows. It also cuts thinking-token usage by approximately 30% compared with K2.6. Inside CCR, Kimi ships as built-in provider presets: import the pay-as-you-go API or the Kimi Code subscription in one click and route your coding agent's requests to Kimi, the subscription endpoint passes straight through natively with no protocol conversion, API endpoints are adapted automatically, and your balance and subscription usage show up right in the CCR dashboard. -

-

- CCR already supports Kimi. Visit the Kimi Open Platform (中文站 | Global) to try the API, or explore the cost-effective Coding Plan. -

-
- -
- -Claude Code Router Desktop is a local gateway and desktop control panel for routing agent requests from Claude Code, Codex, ZCode, and compatible clients to the model provider you actually want to use. -

Claude Code Router Desktop screenshot

+Claude Code Router Desktop is a local gateway and desktop control panel for routing agent requests from Claude Code, Codex, ZCode, and compatible clients to the model provider you actually want to use. + ## Why Use CCR - Use one local endpoint for multiple agent tools instead of configuring every client separately. @@ -202,43 +169,6 @@ Codex support is powered by [musistudio/codexl](https://github.com/musistudio/co RunAPI - - - TeamoRouter icon -
- TeamoRouter -
- - - - code0.ai icon -
- code0.ai -
- - - - claudeapi icon -
- claudeapi -
- - - - - - Qiniu Cloud AI icon -
- Qiniu Cloud AI -
- - - - Fenno.ai icon -
- Fenno.ai -
- diff --git a/README_zh.md b/README_zh.md index 7065c54..15bdb68 100644 --- a/README_zh.md +++ b/README_zh.md @@ -1,52 +1,19 @@ -

Claude Code Router

+

Claude Code Router Desktop

English README Discord X License - 桌面端下载次数 文档

-
- - - - - - - - -
- - Kimi K2.7 Code 赞助横幅 - -
- - Kimi Code 订阅 -  ·  - API 中文站 -  ·  - API Global - -
-

- 感谢 Kimi 赞助本项目!Kimi K2.7 Code 是 Moonshot AI 推出的编程专用开源智能体模型,在真实长程编程与复杂软件工程工作流中显著提升端到端任务成功率,同时优化推理效率,相比 K2.6 平均减少约 30% 的推理 token 消耗。在 CCR 中,Kimi 已作为内置供应商预设开箱即用:无论按量付费 API 还是 Kimi Code 订阅,一键导入即可把你的编程 Agent 请求路由到 Kimi,订阅端点原生直通、无需协议转换,API 端点自动适配,账户余额与订阅用量也能直接在 CCR 面板中查看。 -

-

- CCR 已内置 Kimi 供应商预设。前往 Kimi 开放平台(中文站Global)体验 API,或了解高性价比 Coding Plan 套餐。 -

-
- -
- -Claude Code Router Desktop 是一个本地网关和桌面控制台,用来把 Claude Code、Codex、ZCode 以及兼容客户端的 Agent 请求路由到你真正想使用的模型服务。 -

Claude Code Router Desktop 项目截图

+Claude Code Router Desktop 是一个本地网关和桌面控制台,用来把 Claude Code、Codex、ZCode 以及兼容客户端的 Agent 请求路由到你真正想使用的模型服务。 + ## 为什么使用 CCR - 用一个本地入口连接多个 Agent 工具,不需要在每个客户端里重复配置 Provider。 @@ -201,43 +168,6 @@ CCR 可以完全通过桌面 UI 完成配置。首次使用建议按下面顺序 RunAPI - - - TeamoRouter 图标 -
- TeamoRouter -
- - - - code0.ai 图标 -
- code0.ai -
- - - - claudeapi 图标 -
- claudeapi -
- - - - - - 七牛云 AI 图标 -
- 七牛云 AI -
- - - - Fenno.ai 图标 -
- Fenno.ai -
- diff --git a/packages/electron/assets/logo.png b/assets/logo.png similarity index 100% rename from packages/electron/assets/logo.png rename to assets/logo.png diff --git a/packages/electron/assets/tray-cyan.png b/assets/tray-cyan.png similarity index 100% rename from packages/electron/assets/tray-cyan.png rename to assets/tray-cyan.png diff --git a/packages/electron/assets/tray-orange.png b/assets/tray-orange.png similarity index 100% rename from packages/electron/assets/tray-orange.png rename to assets/tray-orange.png diff --git a/packages/electron/assets/tray-violet.png b/assets/tray-violet.png similarity index 100% rename from packages/electron/assets/tray-violet.png rename to assets/tray-violet.png diff --git a/packages/electron/assets/tray.png b/assets/tray.png similarity index 100% rename from packages/electron/assets/tray.png rename to assets/tray.png diff --git a/build/build.mjs b/build/build.mjs index ba43565..a07412b 100644 --- a/build/build.mjs +++ b/build/build.mjs @@ -1,4 +1,4 @@ -import { buildBrowserRenderer, buildMain, buildRenderer, buildStyles, buildTrayRenderer, buildWebClientBridge, cleanDist, copyAppAssets, copyBrowserRendererHtml, copyMarketplacePlugins, copyModelCatalog, copyRendererHtml, copyTrayRendererHtml, syncUiRendererToRuntimeDists } from "./esbuild.config.mjs"; +import { buildBrowserRenderer, buildMain, buildRenderer, buildStyles, buildTrayRenderer, buildWebClientBridge, cleanDist, copyAppAssets, copyBrowserRendererHtml, copyMarketplacePlugins, copyModelCatalog, copyRendererHtml, copyTrayRendererHtml } from "./esbuild.config.mjs"; const mode = process.argv.includes("--dev") ? "development" : "production"; @@ -19,6 +19,4 @@ await Promise.all([ buildStyles({ minify: mode === "production" }) ]); -syncUiRendererToRuntimeDists(); - -console.log(`Built monorepo package assets in ${mode} mode.`); +console.log(`Built Electron app assets in ${mode} mode.`); diff --git a/build/dev.mjs b/build/dev.mjs index b91a341..a7cf8fd 100644 --- a/build/dev.mjs +++ b/build/dev.mjs @@ -5,14 +5,12 @@ import { spawn } from "node:child_process"; import { existsSync, readdirSync, readFileSync, statSync, watch } from "node:fs"; import path from "node:path"; import { + binPath, buildStyles, cleanDist, browserRendererHtmlInput, - cliSourceRoot, - coreSourceRoot, copyAppAssets, copyBrowserRendererHtml, - copyCliRuntimeToElectronDist, copyMarketplacePlugins, copyModelCatalog, copyRendererHtml, @@ -23,12 +21,12 @@ import { createRendererBuildOptions, createTrayRendererBuildOptions, createWebClientBridgeBuildOptions, + cssInput, + cssOutput, appAssetsInput, modelCatalogInput, projectRoot, - rendererRoot, rendererHtmlInput, - syncUiRendererToRuntimeDists, trayRendererHtmlInput, watchPlugin } from "./esbuild.config.mjs"; @@ -39,12 +37,7 @@ let pendingRestartReasons = []; const watchSignatures = new Map(); let shuttingDown = false; const restartDelayMs = 160; -const styleBuildDelayMs = 160; -const stylePollIntervalMs = 1000; const ignoredSignatureEntries = new Set([".DS_Store"]); -let styleBuildTimer = null; -let styleBuildInFlight = false; -let queuedStyleBuildReason = null; const ready = { browser: false, cli: false, @@ -53,32 +46,6 @@ const ready = { tray: false, webBridge: false }; -const devTarget = parseDevTarget(process.argv.slice(2)); -const enabled = { - cli: devTarget === "cli" || devTarget === "electron", - electron: devTarget === "electron", - ui: true -}; -const coreSharedSourceRoot = path.join(coreSourceRoot, "shared"); -const styleWatchRoots = [rendererRoot, coreSharedSourceRoot].filter((watchRoot) => existsSync(watchRoot)); -const activeReadyNames = new Set([ - ...(enabled.ui ? ["browser", "renderer", "tray", "webBridge"] : []), - ...(enabled.cli ? ["cli"] : []), - ...(enabled.electron ? ["main"] : []) -]); - -function parseDevTarget(args) { - const target = args[0] ?? "electron"; - if (target === "--help" || target === "-h") { - console.log("Usage: node build/dev.mjs [ui|cli|electron]"); - process.exit(0); - } - if (target === "ui" || target === "cli" || target === "electron") { - return target; - } - console.error(`Unknown dev target "${target}". Expected ui, cli, or electron.`); - process.exit(2); -} function logDev(message) { console.log(`[dev] ${new Date().toISOString()} ${message}`); @@ -90,7 +57,6 @@ function relativePath(file) { function readyState() { return Object.entries(ready) - .filter(([name]) => activeReadyNames.has(name)) .map(([name, value]) => `${name}:${value ? "ready" : "pending"}`) .join(" "); } @@ -197,63 +163,7 @@ function handleWatchedInput(label, watchedPath, eventType, filename, options, on } onChange(); - if (enabled.electron && options?.restart !== false) { - scheduleRestart(reason); - } -} - -function scheduleStyleBuild(reason) { - queuedStyleBuildReason = reason; - if (styleBuildTimer) { - clearTimeout(styleBuildTimer); - } - styleBuildTimer = setTimeout(() => { - styleBuildTimer = null; - void rebuildStyles(queuedStyleBuildReason ?? reason); - }, styleBuildDelayMs); -} - -async function rebuildStyles(reason) { - if (styleBuildInFlight) { - queuedStyleBuildReason = reason; - return; - } - - styleBuildInFlight = true; - queuedStyleBuildReason = null; - try { - logDev(`rebuilding styles: ${reason}`); - await buildStyles({ minify: false }); - syncUiRendererToRuntimeDists(); - if (enabled.electron) { - scheduleRestart(`styles rebuilt: ${reason}`); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - logDev(`style rebuild failed: ${message}`); - } finally { - styleBuildInFlight = false; - if (queuedStyleBuildReason) { - const queuedReason = queuedStyleBuildReason; - queuedStyleBuildReason = null; - scheduleStyleBuild(queuedReason); - } - } -} - -function pollStyleWatchRoots() { - for (const styleWatchRoot of styleWatchRoots) { - const label = `styles ${relativePath(styleWatchRoot)}`; - const signature = contentSignature(styleWatchRoot); - const previousSignature = watchSignatures.get(label); - if (previousSignature === signature.key) { - continue; - } - - watchSignatures.set(label, signature.key); - logDev(`watch event: ${label}; ${signature.summary}; content=changed`); - scheduleStyleBuild(label); - } + scheduleRestart(reason); } function markReady(name, reason = `${name} esbuild completed`) { @@ -261,7 +171,7 @@ function markReady(name, reason = `${name} esbuild completed`) { ready[name] = true; } logDev(`build ready: ${reason}; ${readyState()}`); - if (enabled.electron && Array.from(activeReadyNames).every((readyName) => ready[readyName])) { + if (ready.browser && ready.cli && ready.main && ready.renderer && ready.tray && ready.webBridge) { scheduleRestart(reason); } } @@ -311,153 +221,114 @@ function restartElectron() { }); } -logDev(`starting dev build target=${devTarget} ui=${enabled.ui ? "on" : "off"} cli=${enabled.cli ? "on" : "off"} electron=${enabled.electron ? "on" : "off"}`); +logDev("starting dev build"); cleanDist(); -if (enabled.electron) { - copyAppAssets(); -} -if (enabled.cli || enabled.electron) { - copyMarketplacePlugins(); - copyModelCatalog(); -} +copyAppAssets(); +copyMarketplacePlugins(); +copyModelCatalog(); copyBrowserRendererHtml(); copyRendererHtml(); copyTrayRendererHtml(); await buildStyles({ minify: false }); -syncUiRendererToRuntimeDists(); + +const tailwindProcess = spawn(binPath("tailwindcss"), ["-i", cssInput, "-o", cssOutput, "--watch"], { + cwd: projectRoot, + stdio: "inherit", + shell: process.platform === "win32" +}); +logDev(`Tailwind watcher started pid=${tailwindProcess.pid ?? "unknown"} input=${relativePath(cssInput)} output=${relativePath(cssOutput)}`); +tailwindProcess.on("exit", (code, signal) => { + logDev(`Tailwind watcher exited code=${code ?? "null"} signal=${signal ?? "null"}`); +}); rememberWatchSignature("home html", rendererHtmlInput); rememberWatchSignature("browser html", browserRendererHtmlInput); rememberWatchSignature("tray html", trayRendererHtmlInput); -for (const styleWatchRoot of styleWatchRoots) { - rememberWatchSignature(`styles ${relativePath(styleWatchRoot)}`, styleWatchRoot); -} -if (enabled.electron) { - rememberWatchSignature("app assets", appAssetsInput); -} -if ((enabled.cli || enabled.electron) && existsSync(modelCatalogInput)) { +rememberWatchSignature("app assets", appAssetsInput); +if (existsSync(modelCatalogInput)) { rememberWatchSignature("model catalog", modelCatalogInput); } const htmlWatcher = watch(rendererHtmlInput, { persistent: true }, (eventType, filename) => { - handleWatchedInput("home html", rendererHtmlInput, eventType, filename, undefined, () => { - copyRendererHtml(); - syncUiRendererToRuntimeDists(); - }); + handleWatchedInput("home html", rendererHtmlInput, eventType, filename, undefined, copyRendererHtml); }); const browserHtmlWatcher = watch(browserRendererHtmlInput, { persistent: true }, (eventType, filename) => { - handleWatchedInput("browser html", browserRendererHtmlInput, eventType, filename, undefined, () => { - copyBrowserRendererHtml(); - syncUiRendererToRuntimeDists(); - }); + handleWatchedInput("browser html", browserRendererHtmlInput, eventType, filename, undefined, copyBrowserRendererHtml); }); const trayHtmlWatcher = watch(trayRendererHtmlInput, { persistent: true }, (eventType, filename) => { - handleWatchedInput("tray html", trayRendererHtmlInput, eventType, filename, undefined, () => { - copyTrayRendererHtml(); - syncUiRendererToRuntimeDists(); - }); + handleWatchedInput("tray html", trayRendererHtmlInput, eventType, filename, undefined, copyTrayRendererHtml); }); -const stylePoller = setInterval(pollStyleWatchRoots, stylePollIntervalMs); +const appAssetsWatcher = watch(appAssetsInput, { persistent: true }, (eventType, filename) => { + handleWatchedInput("app assets", appAssetsInput, eventType, filename, { isDirectory: true }, copyAppAssets); +}); -const appAssetsWatcher = enabled.electron - ? watch(appAssetsInput, { persistent: true }, (eventType, filename) => { - handleWatchedInput("app assets", appAssetsInput, eventType, filename, { isDirectory: true }, copyAppAssets); - }) - : { close: () => undefined }; - -const modelCatalogWatcher = (enabled.cli || enabled.electron) && existsSync(modelCatalogInput) +const modelCatalogWatcher = existsSync(modelCatalogInput) ? watch(modelCatalogInput, { persistent: true }, (eventType, filename) => { handleWatchedInput("model catalog", modelCatalogInput, eventType, filename, undefined, copyModelCatalog); }) : { close: () => undefined }; -const contexts = []; +const mainContext = await esbuild.context( + createMainBuildOptions({ + mode: "development", + plugins: [watchPlugin("main", (name) => markReady(name))] + }) +); -if (enabled.electron) { - contexts.push( - await esbuild.context( - createMainBuildOptions({ - mode: "development", - plugins: [watchPlugin("main", (name) => markReady(name))] - }) - ) - ); -} +const cliContext = await esbuild.context( + createCliBuildOptions({ + mode: "development", + plugins: [watchPlugin("cli", (name) => markReady(name))] + }) +); -if (enabled.cli) { - contexts.push( - await esbuild.context( - createCliBuildOptions({ - mode: "development", - plugins: [ - watchPlugin("cli", (name) => { - if (enabled.electron) { - copyCliRuntimeToElectronDist(); - } - markReady(name); - }) - ] +const rendererContext = await esbuild.context( + createRendererBuildOptions({ + mode: "development", + plugins: [ + watchPlugin("renderer", (name) => { + copyRendererHtml(); + markReady(name); }) - ) - ); -} + ] + }) +); -if (enabled.ui) { - contexts.push( - await esbuild.context( - createRendererBuildOptions({ - mode: "development", - plugins: [ - watchPlugin("renderer", (name) => { - copyRendererHtml(); - syncUiRendererToRuntimeDists(); - markReady(name); - }) - ] +const trayRendererContext = await esbuild.context( + createTrayRendererBuildOptions({ + mode: "development", + plugins: [ + watchPlugin("tray", (name) => { + copyTrayRendererHtml(); + markReady(name); }) - ), - await esbuild.context( - createTrayRendererBuildOptions({ - mode: "development", - plugins: [ - watchPlugin("tray", (name) => { - copyTrayRendererHtml(); - syncUiRendererToRuntimeDists(); - markReady(name); - }) - ] - }) - ), - await esbuild.context( - createBrowserRendererBuildOptions({ - mode: "development", - plugins: [ - watchPlugin("browser", (name) => { - copyBrowserRendererHtml(); - syncUiRendererToRuntimeDists(); - markReady(name); - }) - ] - }) - ), - await esbuild.context( - createWebClientBridgeBuildOptions({ - mode: "development", - plugins: [ - watchPlugin("webBridge", (name) => { - syncUiRendererToRuntimeDists(); - markReady(name); - }) - ] - }) - ) - ); -} + ] + }) +); -await Promise.all(contexts.map((context) => context.watch())); +const browserRendererContext = await esbuild.context( + createBrowserRendererBuildOptions({ + mode: "development", + plugins: [ + watchPlugin("browser", (name) => { + copyBrowserRendererHtml(); + markReady(name); + }) + ] + }) +); + +const webClientBridgeContext = await esbuild.context( + createWebClientBridgeBuildOptions({ + mode: "development", + plugins: [watchPlugin("webBridge", (name) => markReady(name))] + }) +); + +await Promise.all([mainContext.watch(), cliContext.watch(), rendererContext.watch(), trayRendererContext.watch(), browserRendererContext.watch(), webClientBridgeContext.watch()]); logDev("watchers are active"); async function shutdown() { @@ -469,16 +340,13 @@ async function shutdown() { if (electronProcess) { electronProcess.kill(); } - if (styleBuildTimer) { - clearTimeout(styleBuildTimer); - } + tailwindProcess.kill(); htmlWatcher.close(); browserHtmlWatcher.close(); trayHtmlWatcher.close(); - clearInterval(stylePoller); appAssetsWatcher.close(); modelCatalogWatcher.close(); - await Promise.all(contexts.map((context) => context.dispose())); + await Promise.all([mainContext.dispose(), cliContext.dispose(), rendererContext.dispose(), trayRendererContext.dispose(), browserRendererContext.dispose(), webClientBridgeContext.dispose()]); process.exit(0); } diff --git a/build/docker-build.mjs b/build/docker-build.mjs deleted file mode 100644 index 75d4ed1..0000000 --- a/build/docker-build.mjs +++ /dev/null @@ -1,37 +0,0 @@ -import { - buildBrowserRenderer, - buildCoreServer, - buildRenderer, - buildStyles, - buildTrayRenderer, - buildWebClientBridge, - cleanDist, - copyBrowserRendererHtml, - copyMarketplacePlugins, - copyModelCatalog, - copyRendererHtml, - copyTrayRendererHtml, - syncUiRendererToRuntimeDists -} from "./esbuild.config.mjs"; - -const mode = process.argv.includes("--dev") ? "development" : "production"; - -cleanDist(); -copyMarketplacePlugins(); -copyModelCatalog(); -copyBrowserRendererHtml(); -copyRendererHtml(); -copyTrayRendererHtml(); - -await Promise.all([ - buildCoreServer({ mode }), - buildBrowserRenderer({ mode }), - buildRenderer({ mode }), - buildTrayRenderer({ mode }), - buildWebClientBridge({ mode }), - buildStyles({ minify: mode === "production" }) -]); - -syncUiRendererToRuntimeDists(); - -console.log(`Built Docker core server and UI assets in ${mode} mode.`); diff --git a/build/esbuild.config.mjs b/build/esbuild.config.mjs index e4a05f6..9bdaba7 100644 --- a/build/esbuild.config.mjs +++ b/build/esbuild.config.mjs @@ -1,63 +1,23 @@ import esbuild from "esbuild"; import { spawn } from "node:child_process"; -import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; -import { builtinModules, createRequire } from "node:module"; +import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { builtinModules } 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"); -export const cliRoot = path.join(packagesRoot, "cli"); -export const coreRoot = path.join(packagesRoot, "core"); -export const electronRoot = path.join(packagesRoot, "electron"); -export const uiRoot = path.join(packagesRoot, "ui"); -export const cliSourceRoot = path.join(cliRoot, "src"); -export const coreSourceRoot = path.join(coreRoot, "src"); -export const electronSourceRoot = path.join(electronRoot, "src"); -export const uiSourceRoot = path.join(uiRoot, "src"); -export const legacyDistDir = path.join(projectRoot, "dist"); -export const cliDistDir = path.join(cliRoot, "dist"); -export const coreDistDir = path.join(coreRoot, "dist"); -export const electronDistDir = path.join(electronRoot, "dist"); -export const uiDistDir = path.join(uiRoot, "dist"); -export const distDir = electronDistDir; -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"); -export const electronRendererOutDir = path.join(electronDistDir, "renderer"); -export const runtimeRendererOutDirs = [cliRendererOutDir, coreRendererOutDir, electronRendererOutDir]; -export const appAssetsDir = path.join(electronDistDir, "assets"); +export const distDir = path.join(projectRoot, "dist"); +export const mainOutDir = path.join(distDir, "main"); +export const rendererOutDir = path.join(distDir, "renderer"); +export const appAssetsDir = path.join(distDir, "assets"); export const rendererAssetsDir = path.join(rendererOutDir, "assets"); -export const cliMarketplacePluginsDir = path.join(cliDistDir, "marketplace", "plugins"); -export const coreMarketplacePluginsDir = path.join(coreDistDir, "marketplace", "plugins"); -export const electronMarketplacePluginsDir = path.join(electronDistDir, "marketplace", "plugins"); -export const marketplacePluginsDir = electronMarketplacePluginsDir; -export const appAssetsInput = path.join(electronRoot, "assets"); -export const modelCatalogInput = path.join(coreRoot, "models.json"); -export const cliModelCatalogOutput = path.join(cliDistDir, "models.json"); -export const coreModelCatalogOutput = path.join(coreDistDir, "models.json"); -export const electronModelCatalogOutput = path.join(electronDistDir, "models.json"); -export const modelCatalogOutput = electronModelCatalogOutput; -export const rendererRoot = uiSourceRoot; +export const marketplacePluginsDir = path.join(distDir, "marketplace", "plugins"); +export const appAssetsInput = path.join(projectRoot, "assets"); +export const modelCatalogInput = path.join(projectRoot, "models.json"); +export const modelCatalogOutput = path.join(distDir, "models.json"); +export const rendererRoot = path.join(projectRoot, "src", "renderer"); export const rendererHtmlInput = path.join(rendererRoot, "pages", "home", "index.html"); export const rendererHtmlOutput = path.join(rendererOutDir, "pages", "home", "index.html"); export const browserRendererHtmlInput = path.join(rendererRoot, "pages", "browser", "index.html"); @@ -67,14 +27,11 @@ export const trayRendererHtmlOutput = path.join(rendererOutDir, "pages", "tray", export const cssInput = path.join(rendererRoot, "styles", "globals.css"); export const cssOutput = path.join(rendererAssetsDir, "main.css"); export const webClientBridgeOutput = path.join(rendererAssetsDir, "web-client-bridge.js"); -export const electronUndiciProxyAgentInput = path.join(coreSourceRoot, "proxy", "undici-proxy-agent.ts"); -const lightweightMcpBundleNames = ["browser-web-search-proxy-mcp.js", "fusion-vision-mcp.js", "fusion-tool-fallback-mcp.js"]; +const lightweightMcpBundleNames = ["fusion-vision-mcp.js", "fusion-tool-fallback-mcp.js"]; const lightweightMcpBundleMaxBytes = 128 * 1024; const forbiddenLightweightMcpInputs = [ - { prefix: "packages/core/src/config/", reason: "config modules can pull in native storage side effects" }, - { prefix: "packages/core/src/storage/", reason: "native SQLite storage is not allowed in lightweight MCP subprocesses" }, - { prefix: "packages/electron/src/", reason: "Electron runtime modules are not allowed in lightweight MCP subprocesses" }, - { prefix: "packages/ui/src/", reason: "UI modules do not belong in stdio MCP subprocesses" }, + { prefix: "src/main/", reason: "main-process modules can pull in config, Electron, or native storage side effects" }, + { prefix: "src/renderer/", reason: "renderer modules do not belong in stdio MCP subprocesses" }, { prefix: "node_modules/better-sqlite3/", reason: "native SQLite is not allowed in lightweight MCP subprocesses" }, { prefix: "node_modules/electron/", reason: "Electron runtime modules are not allowed in lightweight MCP subprocesses" } ]; @@ -88,28 +45,15 @@ const nodeExternals = [ ]; export function cleanDist() { - rmSync(legacyDistDir, { force: true, recursive: true }); - rmSync(cliDistDir, { force: true, recursive: true }); - rmSync(coreDistDir, { force: true, recursive: true }); - rmSync(electronDistDir, { force: true, recursive: true }); - rmSync(uiDistDir, { force: true, recursive: true }); + rmSync(distDir, { force: true, recursive: true }); ensureDist(); } export function ensureDist() { - mkdirSync(cliMainOutDir, { recursive: true }); - mkdirSync(coreMainOutDir, { recursive: true }); - mkdirSync(electronMainOutDir, { recursive: true }); - mkdirSync(electronBotGatewaySdkDistDir, { recursive: true }); - mkdirSync(electronBotGatewaySdkBinDir, { recursive: true }); + mkdirSync(mainOutDir, { recursive: true }); mkdirSync(appAssetsDir, { recursive: true }); - mkdirSync(cliMarketplacePluginsDir, { recursive: true }); - mkdirSync(coreMarketplacePluginsDir, { recursive: true }); - mkdirSync(electronMarketplacePluginsDir, { recursive: true }); + mkdirSync(marketplacePluginsDir, { recursive: true }); mkdirSync(rendererAssetsDir, { recursive: true }); - for (const outputDir of runtimeRendererOutDirs) { - mkdirSync(path.join(outputDir, "assets"), { recursive: true }); - } mkdirSync(path.dirname(rendererHtmlOutput), { recursive: true }); mkdirSync(path.dirname(browserRendererHtmlOutput), { recursive: true }); mkdirSync(path.dirname(trayRendererHtmlOutput), { recursive: true }); @@ -125,16 +69,12 @@ export function copyAppAssets() { export function copyModelCatalog() { ensureDist(); if (existsSync(modelCatalogInput)) { - cpSync(modelCatalogInput, cliModelCatalogOutput); - cpSync(modelCatalogInput, coreModelCatalogOutput); - cpSync(modelCatalogInput, electronModelCatalogOutput); + cpSync(modelCatalogInput, modelCatalogOutput); } } export function copyRendererHtml() { - copyRendererPageHtml(rendererHtmlInput, rendererHtmlOutput, "main.js", { - beforeModuleScriptTags: [' '] - }); + copyRendererPageHtml(rendererHtmlInput, rendererHtmlOutput, "main.js"); } export function copyTrayRendererHtml() { @@ -149,25 +89,14 @@ export function copyMarketplacePlugins() { ensureDist(); for (const filename of ["claude-design-plugin.cjs", "cursor-proxy-plugin.cjs"]) { const source = path.join(projectRoot, "examples", "plugins", filename); + const target = path.join(marketplacePluginsDir, filename); if (existsSync(source)) { - cpSync(source, path.join(cliMarketplacePluginsDir, filename)); - cpSync(source, path.join(coreMarketplacePluginsDir, filename)); - cpSync(source, path.join(electronMarketplacePluginsDir, filename)); + cpSync(source, target); } } } -export function syncUiRendererToRuntimeDists() { - ensureDist(); - for (const outputDir of runtimeRendererOutDirs) { - rmSync(outputDir, { force: true, recursive: true }); - if (existsSync(rendererOutDir)) { - cpSync(rendererOutDir, outputDir, { recursive: true }); - } - } -} - -function copyRendererPageHtml(input, output, scriptName, options = {}) { +function copyRendererPageHtml(input, output, scriptName) { ensureDist(); const source = readFileSync(input, "utf8"); const styleTag = ' '; @@ -176,12 +105,6 @@ function copyRendererPageHtml(input, output, scriptName, options = {}) { ? source.replace(' ', scriptTag) : source.replace("", `${scriptTag}\n `); - for (const extraScriptTag of options.beforeModuleScriptTags ?? []) { - if (!hasScriptTag(html, extraScriptTag)) { - html = html.replace(scriptTag, `${extraScriptTag}\n${scriptTag}`); - } - } - if (!html.includes('href="../../assets/main.css"')) { html = html.replace("", `${styleTag}\n `); } @@ -189,38 +112,17 @@ function copyRendererPageHtml(input, output, scriptName, options = {}) { writeFileSync(output, html, "utf8"); } -function hasScriptTag(html, scriptTag) { - const sourceMatch = scriptTag.match(/\bsrc="([^"]+)"/); - 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, bundle: true, entryNames: "[name]", 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"), - path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts"), - electronUndiciProxyAgentInput, - path.join(electronSourceRoot, "main", "preload.ts") + path.join(projectRoot, "src", "main", "main.ts"), + path.join(projectRoot, "src", "main", "browser-preload.ts"), + path.join(projectRoot, "src", "server", "mcp", "fusion-vision-mcp.ts"), + path.join(projectRoot, "src", "server", "mcp", "fusion-tool-fallback-mcp.ts"), + path.join(projectRoot, "src", "main", "preload.ts") ], external: nodeExternals, format: "cjs", @@ -228,9 +130,9 @@ export function createMainBuildOptions({ mode = "production", plugins = [] } = { logLevel: "info", metafile: true, minify: mode === "production", - outdir: electronMainOutDir, + outdir: mainOutDir, platform: "node", - plugins: [packageAliasPlugin(), ...plugins], + plugins, sourcemap: mode !== "production", target: "node22" }; @@ -241,44 +143,15 @@ export function createCliBuildOptions({ mode = "production", plugins = [] } = {} absWorkingDir: projectRoot, bundle: true, entryNames: "[name]", - entryPoints: [ - path.join(cliSourceRoot, "cli.ts"), - path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"), - path.join(coreSourceRoot, "mcp", "fusion-tool-fallback-mcp.ts"), - path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts") - ], + entryPoints: [path.join(projectRoot, "src", "main", "cli.ts")], external: nodeExternals.filter((moduleName) => moduleName !== "electron"), format: "cjs", legalComments: "none", logLevel: "info", minify: mode === "production", - outdir: cliMainOutDir, + outdir: mainOutDir, platform: "node", - plugins: [forbidCliElectronPlugin(), packageAliasPlugin(), ...plugins], - sourcemap: mode !== "production", - target: "node22" - }; -} - -export function createCoreServerBuildOptions({ mode = "production", plugins = [] } = {}) { - return { - absWorkingDir: projectRoot, - bundle: true, - entryNames: "[name]", - entryPoints: [ - path.join(coreSourceRoot, "entrypoints", "server.ts"), - path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"), - path.join(coreSourceRoot, "mcp", "fusion-tool-fallback-mcp.ts"), - path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts") - ], - external: nodeExternals.filter((moduleName) => moduleName !== "electron"), - format: "cjs", - legalComments: "none", - logLevel: "info", - minify: mode === "production", - outdir: coreMainOutDir, - platform: "node", - plugins: [forbidCliElectronPlugin(), packageAliasPlugin(), ...plugins], + plugins: [forbidCliElectronPlugin(), ...plugins], sourcemap: mode !== "production", target: "node22" }; @@ -309,7 +182,7 @@ export function createRendererBuildOptions({ mode = "production", plugins = [] } minify: mode === "production", outfile: path.join(rendererAssetsDir, "main.js"), platform: "browser", - plugins: [rendererAliasPlugin(), packageAliasPlugin(), ...plugins], + plugins: [rendererAliasPlugin(), ...plugins], publicPath: "../../assets", sourcemap: mode !== "production", target: "chrome120" @@ -336,37 +209,16 @@ export function createWebClientBridgeBuildOptions({ mode = "production", plugins return { absWorkingDir: projectRoot, bundle: true, - entryPoints: [path.join(uiSourceRoot, "web-client-bridge.ts")], + entryPoints: [path.join(projectRoot, "src", "main", "web-client-bridge.ts")], format: "iife", legalComments: "none", logLevel: "info", minify: mode === "production", outfile: webClientBridgeOutput, platform: "browser", - plugins: [packageAliasPlugin(), ...plugins], - sourcemap: mode !== "production", - target: "chrome120" - }; -} - -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" + target: "chrome120" }; } @@ -386,38 +238,11 @@ 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) + esbuild.build(createCliBuildOptions(options)) ]); - copyCliRuntimeToElectronDist(); 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)); -} - -export async function buildCoreServer(options = {}) { - await esbuild.build(createCoreServerBuildOptions(options)); -} - export async function buildRenderer(options = {}) { await esbuild.build(createRendererBuildOptions(options)); } @@ -434,14 +259,6 @@ export async function buildWebClientBridge(options = {}) { await esbuild.build(createWebClientBridgeBuildOptions(options)); } -export function copyCliRuntimeToElectronDist() { - ensureDist(); - const cliRuntime = path.join(cliMainOutDir, "cli.js"); - if (existsSync(cliRuntime)) { - cpSync(cliRuntime, path.join(electronMainOutDir, "cli.js")); - } -} - export async function buildStyles({ minify = false } = {}) { ensureDist(); const args = ["-i", cssInput, "-o", cssOutput]; @@ -487,26 +304,6 @@ function rendererAliasPlugin() { }; } -function packageAliasPlugin() { - return { - name: "ccr-package-alias", - setup(build) { - build.onResolve({ filter: /^@ccr\/cli\// }, (args) => { - return { path: resolvePackageImport(cliSourceRoot, args.path.slice("@ccr/cli/".length)) }; - }); - build.onResolve({ filter: /^@ccr\/core\// }, (args) => { - return { path: resolvePackageImport(coreSourceRoot, args.path.slice("@ccr/core/".length)) }; - }); - build.onResolve({ filter: /^@ccr\/electron\// }, (args) => { - return { path: resolvePackageImport(electronSourceRoot, args.path.slice("@ccr/electron/".length)) }; - }); - build.onResolve({ filter: /^@ccr\/ui\// }, (args) => { - return { path: resolvePackageImport(uiSourceRoot, args.path.slice("@ccr/ui/".length)) }; - }); - } - }; -} - function forbidCliElectronPlugin() { return { name: "forbid-cli-electron", @@ -573,23 +370,19 @@ function normalizeBuildPath(value) { } function resolveRendererImport(importPath) { - return resolvePackageImport(rendererRoot, importPath); -} - -function resolvePackageImport(rootDir, importPath) { - const packageBasePath = path.resolve(rootDir, importPath); + const basePath = path.resolve(rendererRoot, importPath); const candidates = [ - packageBasePath, - `${packageBasePath}.tsx`, - `${packageBasePath}.ts`, - `${packageBasePath}.jsx`, - `${packageBasePath}.js`, - `${packageBasePath}.json`, - `${packageBasePath}.css`, - path.join(packageBasePath, "index.tsx"), - path.join(packageBasePath, "index.ts"), - path.join(packageBasePath, "index.jsx"), - path.join(packageBasePath, "index.js") + basePath, + `${basePath}.tsx`, + `${basePath}.ts`, + `${basePath}.jsx`, + `${basePath}.js`, + `${basePath}.json`, + `${basePath}.css`, + path.join(basePath, "index.tsx"), + path.join(basePath, "index.ts"), + path.join(basePath, "index.jsx"), + path.join(basePath, "index.js") ]; for (const candidate of candidates) { @@ -598,5 +391,5 @@ function resolvePackageImport(rootDir, importPath) { } } - return packageBasePath; + return basePath; } diff --git a/build/run-tests.mjs b/build/run-tests.mjs index aaa9cfb..a870705 100644 --- a/build/run-tests.mjs +++ b/build/run-tests.mjs @@ -31,7 +31,7 @@ function runSuite(suite) { console.log(`\nRunning ${suite} tests...`); return new Promise((resolve, reject) => { - const child = spawn(electron, ["--test", `dist/tests/${suite}/*.test.js`], { + const child = spawn(electron, ["--test", `dist/tests/${suite}/*.js`], { cwd: projectRoot, env: { ...process.env, diff --git a/build/test.mjs b/build/test.mjs index be7b4ad..8319a30 100644 --- a/build/test.mjs +++ b/build/test.mjs @@ -6,9 +6,7 @@ import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(__dirname, ".."); const testsOutDir = path.join(projectRoot, "dist", "tests"); -const rendererRoot = path.join(projectRoot, "packages", "ui", "src"); -const cliSourceRoot = path.join(projectRoot, "packages", "cli", "src"); -const coreSourceRoot = path.join(projectRoot, "packages", "core", "src"); +const rendererRoot = path.join(projectRoot, "src", "renderer"); const testSuites = [ { name: "main", testDir: path.join(projectRoot, "tests", "main") }, { name: "renderer", testDir: path.join(projectRoot, "tests", "renderer") } @@ -27,10 +25,7 @@ if (unknownSuites.length > 0) { rmSync(testsOutDir, { force: true, recursive: true }); for (const suite of selectedSuites) { - const entryPoints = [ - ...findTestFiles(suite.testDir), - ...runtimeEntryPointsForSuite(suite.name) - ]; + const entryPoints = findTestFiles(suite.testDir); if (entryPoints.length === 0) { continue; } @@ -58,21 +53,11 @@ for (const suite of selectedSuites) { logLevel: "info", outdir: path.join(testsOutDir, suite.name), platform: "node", - plugins: [rendererAliasPlugin(), packageAliasPlugin()], + plugins: [rendererAliasPlugin()], target: "node22" }); } -function runtimeEntryPointsForSuite(suiteName) { - if (suiteName !== "main") { - return []; - } - return [ - path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"), - path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts") - ]; -} - function findTestFiles(dir) { if (!existsSync(dir)) { return []; @@ -102,26 +87,8 @@ function rendererAliasPlugin() { }; } -function packageAliasPlugin() { - return { - name: "test-package-alias", - setup(build) { - build.onResolve({ filter: /^@ccr\/cli\// }, (args) => { - return { path: resolvePackageImport(cliSourceRoot, args.path.slice("@ccr/cli/".length)) }; - }); - build.onResolve({ filter: /^@ccr\/core\// }, (args) => { - return { path: resolvePackageImport(coreSourceRoot, args.path.slice("@ccr/core/".length)) }; - }); - } - }; -} - function resolveRendererImport(importPath) { - return resolvePackageImport(rendererRoot, importPath); -} - -function resolvePackageImport(rootDir, importPath) { - const basePath = path.resolve(rootDir, importPath); + const basePath = path.resolve(rendererRoot, importPath); const candidates = [ basePath, `${basePath}.tsx`, diff --git a/build/verify-packaged-app.cjs b/build/verify-packaged-app.cjs index ee52160..0d6371a 100644 --- a/build/verify-packaged-app.cjs +++ b/build/verify-packaged-app.cjs @@ -9,19 +9,6 @@ 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; @@ -34,7 +21,6 @@ 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"); @@ -55,13 +41,6 @@ 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/components.json b/components.json index a1acbd1..e6f7108 100644 --- a/components.json +++ b/components.json @@ -5,7 +5,7 @@ "tsx": true, "tailwind": { "config": "", - "css": "packages/ui/src/styles/globals.css", + "css": "src/renderer/styles/globals.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index d4a0916..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,15 +0,0 @@ -services: - ccr: - build: - context: . - dockerfile: Dockerfile - image: claude-code-router:local - ports: - # Publish only Nginx. Internal web/gateway listeners stay inside the container. - - "3458:8080" - volumes: - - ccr-data:/data - restart: unless-stopped - -volumes: - ccr-data: diff --git a/docker/README.md b/docker/README.md deleted file mode 100644 index 2b51a1f..0000000 --- a/docker/README.md +++ /dev/null @@ -1,91 +0,0 @@ -# Docker deployment - -This image runs the core server package with PM2 and serves the built UI package -through Nginx. Nginx is the only published entrypoint: it serves the UI, proxies -management API calls to the internal core server, and proxies gateway API calls -to the internal gateway listener. - -## Build and run - -```sh -docker compose up --build -``` - -Then open: - -- Web UI: -- Gateway endpoint: - -`docker-compose.yml` publishes only Nginx (`3458:8080`). Behind Nginx, the image -runs separate container-private listeners for management RPC, API gateway -routing, and the core gateway runtime. They are implementation details and are -not published or configured by the default Compose file. - -To use a different host port, change the Compose port mapping and keep the -public router endpoint in sync: - -```yaml -services: - ccr: - ports: - - "8088:8080" - environment: - CCR_PUBLIC_BASE_URL: http://127.0.0.1:8088 -``` - -The container stores config and SQLite databases under `/data`, backed by the -`ccr-data` volume in `docker-compose.yml`. - -On a fresh data volume, the Web UI starts immediately. The gateway endpoint is -available through the same Nginx entrypoint, but the gateway only starts after at -least one provider and model are configured. - -## Image scripts - -```sh -npm run docker:build -npm run docker:run -``` - -## Smoke test - -```sh -npm run test:docker -``` - -The smoke test builds the image, starts an isolated temporary container with a -special-character `CCR_WEB_AUTH_TOKEN`, verifies that only the Nginx port is -published, checks UI and RPC authentication, confirms legacy Docker config is -migrated to the public Nginx router endpoint, and removes its temporary -container and volume. Set `CCR_DOCKER_TEST_SKIP_BUILD=1` to reuse an already -built image. - -The Dockerfile uses `node:22-bookworm` for build and native SQLite dependency -installation, then copies the production dependencies into a smaller -`node:22-bookworm-slim` runtime image. To use different base images: - -```sh -docker build \ - --build-arg NODE_IMAGE=node:22-bookworm \ - --build-arg RUNTIME_NODE_IMAGE=node:22-bookworm-slim \ - -t claude-code-router:local . -``` - -## Environment - -Most deployments only need the published Nginx port mapping, `CCR_WEB_AUTH_TOKEN`, -and optionally `CCR_PUBLIC_BASE_URL` when the host-facing URL is not -`http://127.0.0.1:3458`. - -| Variable | Default | Description | -| --- | --- | --- | -| `CCR_WEB_AUTH_TOKEN` | generated | Shared management UI token used by Nginx redirects and the core server. | -| `CCR_PUBLIC_BASE_URL` | `http://127.0.0.1:3458` | Full public router endpoint override. Set this when changing the host-facing Compose port. | -| `CCR_DATA_DIR` | `/data` | Container data root. | -| `CCR_NO_GATEWAY` | `0` | Set to `1` to run only the Web UI management service. | -| `CCR_DOCKER_INIT_CONFIG` | `1` | Set to `0` to disable first-run `config.json` bootstrap. | -| `CCR_DOCKER_SYNC_PUBLIC_ENDPOINT` | `1` | Sync existing Docker config to the Nginx public router endpoint on startup. | - -The first-run bootstrap writes a minimal legacy `config.json` only when neither -`config.json` nor `config.sqlite` exists in the mounted data directory. Once the -UI saves settings into SQLite, existing persisted configuration takes priority. diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh deleted file mode 100644 index c4b8983..0000000 --- a/docker/entrypoint.sh +++ /dev/null @@ -1,207 +0,0 @@ -#!/bin/sh -set -eu - -CCR_DATA_DIR="${CCR_DATA_DIR:-/data}" -CCR_WEB_HOST="${CCR_WEB_HOST:-127.0.0.1}" -CCR_WEB_PORT="${CCR_WEB_PORT:-3459}" -CCR_NGINX_PORT="${CCR_NGINX_PORT:-8080}" -CCR_GATEWAY_HOST="${CCR_GATEWAY_HOST:-127.0.0.1}" -CCR_GATEWAY_PORT="${CCR_GATEWAY_PORT:-3456}" -CCR_GATEWAY_CORE_PORT="${CCR_GATEWAY_CORE_PORT:-3457}" -CCR_PUBLIC_HOST="${CCR_PUBLIC_HOST:-127.0.0.1}" -CCR_PUBLIC_PORT="${CCR_PUBLIC_PORT:-3458}" -CCR_PUBLIC_BASE_URL="${CCR_PUBLIC_BASE_URL:-http://${CCR_PUBLIC_HOST}:${CCR_PUBLIC_PORT}}" -CCR_NO_GATEWAY="${CCR_NO_GATEWAY:-0}" - -if [ -z "${CCR_WEB_AUTH_TOKEN:-}" ]; then - CCR_WEB_AUTH_TOKEN="$(node -e "process.stdout.write(require('node:crypto').randomBytes(32).toString('base64url'))")" -fi -CCR_WEB_AUTH_TOKEN_QUERY="$(node -e "process.stdout.write(encodeURIComponent(process.argv[1] || ''))" "${CCR_WEB_AUTH_TOKEN}")" - -export HOME="${CCR_DATA_DIR}" -export CCR_DATA_DIR -export CCR_GATEWAY_CORE_PORT -export CCR_GATEWAY_HOST -export CCR_GATEWAY_PORT -export CCR_NGINX_PORT -export CCR_NO_GATEWAY -export CCR_PUBLIC_BASE_URL -export CCR_PUBLIC_HOST -export CCR_PUBLIC_PORT -export CCR_WEB_AUTH_TOKEN -export CCR_WEB_AUTH_TOKEN_QUERY -export CCR_WEB_HOST -export CCR_WEB_PORT - -CONFIG_DIR="${HOME}/.claude-code-router" -CONFIG_FILE="${CONFIG_DIR}/config.json" -APP_CONFIG_DB_FILE="${CONFIG_DIR}/config.sqlite" - -mkdir -p "${CONFIG_DIR}" "${CONFIG_DIR}/app-data" /run/nginx /var/lib/nginx /var/log/nginx - -if [ "${CCR_DOCKER_INIT_CONFIG:-1}" != "0" ] && [ ! -f "${CONFIG_FILE}" ] && [ ! -f "${APP_CONFIG_DB_FILE}" ]; then - node - <<'NODE' -const fs = require("node:fs"); -const path = require("node:path"); - -const configDir = path.join(process.env.HOME, ".claude-code-router"); -const configFile = path.join(configDir, "config.json"); -const gatewayHost = process.env.CCR_GATEWAY_HOST || "0.0.0.0"; -const gatewayPort = Number(process.env.CCR_GATEWAY_PORT || "3456"); -const gatewayCorePort = Number(process.env.CCR_GATEWAY_CORE_PORT || "3457"); -const publicBaseUrl = (process.env.CCR_PUBLIC_BASE_URL || `http://127.0.0.1:${process.env.CCR_PUBLIC_PORT || "3458"}`).replace(/\/+$/, ""); - -fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); -fs.writeFileSync(configFile, `${JSON.stringify({ - HOST: gatewayHost, - PORT: gatewayPort, - gateway: { - coreHost: "127.0.0.1", - corePort: gatewayCorePort, - enabled: true, - host: gatewayHost, - port: gatewayPort - }, - routerEndpoint: publicBaseUrl -}, null, 2)}\n`, { mode: 0o600 }); -NODE -fi - -if [ "${CCR_DOCKER_SYNC_PUBLIC_ENDPOINT:-1}" != "0" ]; then - node - <<'NODE' -const fs = require("node:fs"); -const path = require("node:path"); - -const configDir = path.join(process.env.HOME, ".claude-code-router"); -const configFile = path.join(configDir, "config.json"); -const appConfigDbFile = path.join(configDir, "config.sqlite"); -const gatewayHost = process.env.CCR_GATEWAY_HOST || "127.0.0.1"; -const gatewayPort = Number(process.env.CCR_GATEWAY_PORT || "3456"); -const gatewayCorePort = Number(process.env.CCR_GATEWAY_CORE_PORT || "3457"); -const publicBaseUrl = (process.env.CCR_PUBLIC_BASE_URL || `http://127.0.0.1:${process.env.CCR_PUBLIC_PORT || "3458"}`).replace(/\/+$/, ""); - -function syncConfig(value) { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return value; - } - value.HOST = gatewayHost; - value.PORT = gatewayPort; - value.gateway = { - ...(value.gateway && typeof value.gateway === "object" && !Array.isArray(value.gateway) ? value.gateway : {}), - coreHost: "127.0.0.1", - corePort: gatewayCorePort, - enabled: true, - host: gatewayHost, - port: gatewayPort - }; - value.routerEndpoint = publicBaseUrl; - return value; -} - -function syncJsonFile() { - if (!fs.existsSync(configFile)) { - return; - } - const parsed = JSON.parse(fs.readFileSync(configFile, "utf8")); - fs.writeFileSync(configFile, `${JSON.stringify(syncConfig(parsed), null, 2)}\n`, { mode: 0o600 }); -} - -function syncSqliteConfig() { - if (!fs.existsSync(appConfigDbFile)) { - return; - } - let Database; - try { - Database = require("better-sqlite3"); - } catch { - return; - } - const db = new Database(appConfigDbFile); - try { - const row = db.prepare("select value_json from app_config where key = ?").get("default"); - if (!row?.value_json) { - return; - } - const parsed = JSON.parse(row.value_json); - db.prepare("update app_config set value_json = ?, updated_at = ? where key = ?") - .run(JSON.stringify(syncConfig(parsed)), new Date().toISOString(), "default"); - } finally { - db.close(); - } -} - -syncJsonFile(); -syncSqliteConfig(); -NODE -fi - -cat > /etc/nginx/conf.d/default.conf <