mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-08 08:27:39 +08:00
### What this PR does **Before this PR**, Cherry Studio managed external CLI binaries through five uncoordinated mechanisms — each requiring its own download script, IPC channel, and on-disk layout: | Mechanism | Where it lived | How it worked | |---|---|---| | rtk extraction | `src/main/utils/rtk.ts` + `AgentBootstrapService` | Copies bundled binary to `~/.cherrystudio/bin/` | | OpenClaw installer | `resources/scripts/install-openclaw.js` | Downloads from GitHub/mirror with custom extraction | | CodeCliService | `src/main/services/CodeCliService.ts` | `bun install -g` to `~/.cherrystudio/install/global/` | | ripgrep | `node_modules/@anthropic-ai/claude-agent-sdk/vendor/` | Vendored, hardcoded path in `FileStorage` | | (old) MiseService | `src/main/services/MiseService.ts` | Bundled mise + `mise use -g` | **After this PR**, a single `BinaryManager` lifecycle service owns all third-party CLI binary acquisition. It wraps [mise](https://mise.jdx.dev) as the only acquisition backend (no custom `BinaryBackend` interface — mise's polyglot grammar already covers `npm:`, `pipx:`, `github:`, registry entries). Tools install into an isolated environment under `~/.cherrystudio/mise/` so user-level mise installs are never touched. `uv`, `bun`, `rg`, and mise itself ship bundled at build time for instant first-run availability; everything else flows through mise on demand. <img width=\"1304\" height=\"714\" alt=\"BinaryManager settings UI\" src=\"https://github.com/user-attachments/assets/7a4b78ab-5aa2-4e97-9ab7-134b20a4d78d\" /> <img width=\"1165\" height=\"748\" alt=\"Three-state managed vs bundled vs not-installed\" src=\"https://github.com/user-attachments/assets/a0dcfb7d-8bc3-4acd-b563-0fc04d99e252\" /> <img width=\"523\" height=\"328\" alt=\"Custom tool dialog\" src=\"https://github.com/user-attachments/assets/90c3ee95-7f2a-4daf-a334-f20de6ff5ca2\" /> Fixes #15183. Addresses #15370. ### Why we need it and why it was done in this way Adding a new managed CLI tool should be a one-line preset entry — not 4+ files of bespoke download/extract/IPC code. mise is a mature polyglot tool manager that already speaks the backends Cherry needs. **Tradeoffs made:** - **mise bundled at build time** (~15 MB per platform) rather than downloaded at first run — faster first-run UX, no chicken-and-egg on a fresh install. - **Fully isolated mise environment** (\`HOME\`/\`XDG_*\`/\`MISE_*\` all relocated under \`feature.binaries.data\`) — Cherry never reads or writes the user's own \`~/.config/mise/\` or \`~/.local/share/mise/\`. - **No custom \`BinaryBackend\` interface.** mise's grammar (\`npm:\`, \`pipx:\`, \`github:\`, registry) is already polyglot; wrapping it would be a shallow seam that re-implements what mise owns. Removing this abstraction makes consumers simpler (deletion test passes). - **Auth-token policy: opt-in only.** Ambient \`GITHUB_TOKEN\` / \`GH_TOKEN\` are not forwarded into mise's process env. Users who hit GitHub's unauthenticated 60 req/hr API limit can set \`CHERRY_GITHUB_TOKEN\` to raise it to 5000 req/hr without consenting to share their general shell token. - **China mirror auto-detection.** \`isUserInChina()\` toggles \`NPM_CONFIG_REGISTRY=registry.npmmirror.com\` + \`PIP_INDEX_URL=pypi.tuna.tsinghua.edu.cn\` for every npm/pipx backend transparently. **Alternatives considered:** - *Keep per-tool install scripts.* Doesn't scale — each new tool is 4+ files of duplicated logic. - *Use mise from user's \`PATH\`.* Would depend on user having mise installed and could conflict with their config. - *Custom \`BinaryBackend\` abstraction.* Shallow wrapper over mise's grammar; no second backend in sight; deletion test passes. **Scope (what's in / what's out):** - **In:** uv, bun, ripgrep, claude-code, openclaw, gh, opencode, gemini-cli, lark, kimi-cli, qwen-code, iflow-cli, github-copilot-cli — anything mise can install as a single relocatable binary. - **Out:** \`OvmsManager\` (multi-file server provisioning, hardware detection, generated config); Tesseract OCR data (not a binary; lives with \`OvOcrService\`). ### Breaking changes None at the user-facing layer. v2 data is throwaway per CLAUDE.md, so the preference-key rename (\`feature.mise.*\` → \`feature.binaries.*\`) intentionally ships without a migrator. ### Special notes for your reviewer **Architecture & docs** - \`docs/references/binary-manager/README.md\` — scope criterion, persisted/contract surface, bundled-vs-mise state contract, China mirror behavior, \`CHERRY_GITHUB_TOKEN\` opt-in, adding a new managed binary. - \`CLAUDE.md\` adds a \`**MUST READ**\` link next to Lifecycle / Window Manager / Data / Paths. - The mise-shim-wins-over-\`cherry.bin\` precedence rule is documented in the README's "State contract" section. **Code orientation** - \`src/main/services/BinaryManager.ts\` — the lifecycle service. Wraps mise via \`runMise()\`; isolated env in \`buildIsolatedEnv()\`; bundled extraction with atomic tmp+rename; per-tool try/catch so a single failure can't abort init; \`isManagedBinaryReady()\` verifies the resolved file is executable (not just that mise thinks it's installed). - \`packages/shared/data/presets/binary-tools.ts\` — \`PREDEFINED_BINARY_TOOLS\` registry; \`tool\` field is a mise spec. - \`src/renderer/src/pages/settings/McpSettings/EnvironmentDependencies.tsx\` — three-state UI (\`managed\` / \`bundled\` / \`not-installed\`) backed by \`Binary_GetState\` + \`Binary_ProbeBundled\`. - \`scripts/download-binaries.js\` — build-time downloader for mise / uv / bun / rg (HTTPS + sha256-verified, archive-aware extraction). **Migration** - \`OpenClawService.install()\` is now two lines delegating to \`BinaryManager\` — \`install-openclaw.js\` is gone. - \`CodeCliService\` no longer uses \`bun install -g\`; the \`BUN_INSTALL\` / \`~/.cherrystudio/install/global/\` path is removed. - \`FileStorage.getRipgrepBinaryPath()\` now resolves via \`getBinaryPath('rg')\`; the vendored SDK rg is no longer used. - \`extractRtkBinaries\` + the \`AgentBootstrapService\` call are deleted. \`rtkRewrite()\` degrades gracefully when rtk is absent; \`rtkAvailable\` caches with a 60s TTL so install-via-mise takes effect without restart. **Multi-round review** Two adversarial review rounds against this branch (Bug Hunter / Security / Architecture / Correctness) ran during development; both rounds' High-severity findings are addressed in commits \`70afde6af\` and \`1d864439d\`. R3 known follow-ups (architecture duplications in \`CodeCliService\`'s switch statements, etc.) are tracked as Medium and intentionally deferred. ### Checklist - [x] PR: The PR description is expressive enough and will help future contributors - [x] Code: Write code that humans can understand and Keep it simple - [x] Refactor: Leaves binary acquisition meaningfully cleaner than before (five mechanisms → one) - [x] Upgrade: v2 data is throwaway; no migrator needed and the absence is intentional - [x] Documentation: \`docs/references/binary-manager/README.md\` + \`CLAUDE.md\` Architecture section - [x] Self-review: Two multi-perspective review rounds against the branch; all Highs addressed ### Release note \`\`\`release-note NONE \`\`\` --------- Signed-off-by: Vaayne <liu.vaayne@gmail.com> Signed-off-by: Vaayne Liu <vaayne@macos.shared> Co-authored-by: fullex <106392080+0xfullex@users.noreply.github.com>
144 lines
5.5 KiB
JavaScript
144 lines
5.5 KiB
JavaScript
const { Arch } = require('electron-builder')
|
|
const { execSync } = require('child_process')
|
|
const fs = require('fs')
|
|
const path = require('path')
|
|
const { parse, stringify } = require('yaml')
|
|
|
|
const workspaceConfigPath = path.join(__dirname, '..', 'pnpm-workspace.yaml')
|
|
|
|
// if you want to add new prebuild binaries packages with different architectures, you can add them here
|
|
// please add to allX64 and allArm64 from pnpm-lock.yaml
|
|
const packages = [
|
|
'@anthropic-ai/claude-agent-sdk-darwin-arm64',
|
|
'@anthropic-ai/claude-agent-sdk-darwin-x64',
|
|
'@anthropic-ai/claude-agent-sdk-linux-arm64',
|
|
'@anthropic-ai/claude-agent-sdk-linux-arm64-musl',
|
|
'@anthropic-ai/claude-agent-sdk-linux-x64',
|
|
'@anthropic-ai/claude-agent-sdk-linux-x64-musl',
|
|
'@anthropic-ai/claude-agent-sdk-win32-arm64',
|
|
'@anthropic-ai/claude-agent-sdk-win32-x64',
|
|
'@img/sharp-darwin-arm64',
|
|
'@img/sharp-darwin-x64',
|
|
'@img/sharp-libvips-darwin-arm64',
|
|
'@img/sharp-libvips-darwin-x64',
|
|
'@img/sharp-libvips-linux-arm64',
|
|
'@img/sharp-libvips-linuxmusl-arm64',
|
|
'@img/sharp-libvips-linux-x64',
|
|
'@img/sharp-libvips-linuxmusl-x64',
|
|
'@img/sharp-linux-arm64',
|
|
'@img/sharp-linux-x64',
|
|
'@img/sharp-linuxmusl-arm64',
|
|
'@img/sharp-linuxmusl-x64',
|
|
'@img/sharp-win32-arm64',
|
|
'@img/sharp-win32-x64',
|
|
'@libsql/darwin-arm64',
|
|
'@libsql/darwin-x64',
|
|
'@libsql/linux-arm64-gnu',
|
|
'@libsql/linux-x64-gnu',
|
|
'@libsql/linux-arm64-musl',
|
|
'@libsql/linux-x64-musl',
|
|
'@libsql/win32-x64-msvc',
|
|
'@napi-rs/system-ocr-darwin-arm64',
|
|
'@napi-rs/system-ocr-darwin-x64',
|
|
'@napi-rs/system-ocr-win32-arm64-msvc',
|
|
'@napi-rs/system-ocr-win32-x64-msvc',
|
|
'@napi-rs/canvas-linux-x64-gnu',
|
|
'@napi-rs/canvas-linux-x64-musl',
|
|
'@napi-rs/canvas-linux-arm64-gnu',
|
|
'@napi-rs/canvas-linux-arm64-musl',
|
|
'@napi-rs/canvas-darwin-x64',
|
|
'@napi-rs/canvas-darwin-arm64',
|
|
'@napi-rs/canvas-win32-x64-msvc',
|
|
'@napi-rs/canvas-win32-arm64-msvc',
|
|
'@strongtz/win32-arm64-msvc'
|
|
]
|
|
|
|
const platformToArch = {
|
|
mac: 'darwin',
|
|
windows: 'win32',
|
|
linux: 'linux',
|
|
linuxmusl: 'linuxmusl'
|
|
}
|
|
|
|
exports.default = async function (context) {
|
|
const arch = context.arch === Arch.arm64 ? 'arm64' : 'x64'
|
|
const platformName = context.packager.platform.name
|
|
const platform = platformToArch[platformName]
|
|
|
|
console.log(`Downloading bundled binaries for ${platform}-${arch}...`)
|
|
execSync(`node "${path.join(__dirname, 'download-binaries.js')}" ${platform} ${arch}`, { stdio: 'inherit' })
|
|
// Fail the build rather than ship a half-empty resources/binaries/<platform>.
|
|
require('./download-binaries').verifyBundledBinaries(platform, arch)
|
|
|
|
const downloadPackages = async () => {
|
|
// Skip if target platform and architecture match current system
|
|
if (platform === process.platform && arch === process.arch) {
|
|
console.log(`Skipping install: target (${platform}/${arch}) matches current system`)
|
|
return
|
|
}
|
|
|
|
console.log(`Installing packages for target platform=${platform} arch=${arch}...`)
|
|
|
|
// Backup and modify pnpm-workspace.yaml to add target platform support
|
|
const originalWorkspaceConfig = fs.readFileSync(workspaceConfigPath, 'utf-8')
|
|
const workspaceConfig = parse(originalWorkspaceConfig)
|
|
|
|
// Add target platform to supportedArchitectures.os
|
|
if (!workspaceConfig.supportedArchitectures.os.includes(platform)) {
|
|
workspaceConfig.supportedArchitectures.os.push(platform)
|
|
}
|
|
|
|
// Add target architecture to supportedArchitectures.cpu
|
|
if (!workspaceConfig.supportedArchitectures.cpu.includes(arch)) {
|
|
workspaceConfig.supportedArchitectures.cpu.push(arch)
|
|
}
|
|
|
|
const modifiedWorkspaceConfig = stringify(workspaceConfig)
|
|
console.log('Modified workspace config:', modifiedWorkspaceConfig)
|
|
fs.writeFileSync(workspaceConfigPath, modifiedWorkspaceConfig)
|
|
|
|
try {
|
|
execSync(`pnpm install`, { stdio: 'inherit' })
|
|
} finally {
|
|
// Restore original pnpm-workspace.yaml
|
|
fs.writeFileSync(workspaceConfigPath, originalWorkspaceConfig)
|
|
}
|
|
}
|
|
|
|
await downloadPackages()
|
|
|
|
const excludePackages = async (packagesToExclude) => {
|
|
// 从项目根目录的 electron-builder.yml 读取 files 配置,避免多次覆盖配置导致出错
|
|
const electronBuilderConfigPath = path.join(__dirname, '..', 'electron-builder.yml')
|
|
const electronBuilderConfig = parse(fs.readFileSync(electronBuilderConfigPath, 'utf-8'))
|
|
let filters = electronBuilderConfig.files
|
|
|
|
// add filters for other architectures (exclude them)
|
|
filters.push(...packagesToExclude)
|
|
|
|
context.packager.config.files[0].filter = filters
|
|
}
|
|
|
|
const arm64KeepPackages = packages.filter((p) => p.includes('arm64') && p.includes(platform))
|
|
const arm64ExcludePackages = packages
|
|
.filter((p) => !arm64KeepPackages.includes(p))
|
|
.map((p) => '!node_modules/' + p + '/**')
|
|
|
|
const x64KeepPackages = packages.filter((p) => p.includes('x64') && p.includes(platform))
|
|
const x64ExcludePackages = packages
|
|
.filter((p) => !x64KeepPackages.includes(p))
|
|
.map((p) => '!node_modules/' + p + '/**')
|
|
|
|
const currentPlatformKey = `${platform}-${arch}`
|
|
const allBinaryPlatforms = ['darwin-arm64', 'darwin-x64', 'linux-x64', 'linux-arm64', 'win32-x64', 'win32-arm64']
|
|
const excludeBundledBinaryFilters = allBinaryPlatforms
|
|
.filter((p) => p !== currentPlatformKey)
|
|
.map((p) => '!resources/binaries/' + p + '/**')
|
|
|
|
if (context.arch === Arch.arm64) {
|
|
await excludePackages([...arm64ExcludePackages, ...excludeBundledBinaryFilters])
|
|
} else {
|
|
await excludePackages([...x64ExcludePackages, ...excludeBundledBinaryFilters])
|
|
}
|
|
}
|