Files
CherryHQ-cherry-studio/scripts/download-binaries.js
LiuVaayne efecdd5007 refactor(binary-manager): unify CLI binary acquisition behind mise-backed BinaryManager (#15184)
### 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>
2026-06-24 15:15:25 +08:00

398 lines
14 KiB
JavaScript

/**
* Downloads mise, bun, and uv binaries for the target platform during build.
* Called from before-pack.js (and the dev script) to bundle binaries into resources/binaries/.
*
* Usage:
* node scripts/download-binaries.js [platform] [arch]
* e.g. node scripts/download-binaries.js darwin arm64
*/
const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
const { execFileSync } = require('child_process')
// ── Tool definitions ─────────────────────────────────────────────────
// Each tool declares: version, per-platform packages, and how to build
// the download URL / extract the archive.
//
// Package fields:
// url — full download URL
// archive — 'none' (bare binary) | 'zip' | 'tar.gz'
// binaries — list of binary filenames to extract
// strip — for zip: glob prefix per binary; for tar.gz: --strip-components depth
// sha256 — checksum of the downloaded file (binary itself or archive)
const MISE_VERSION = '2026.5.11'
const BUN_VERSION = '1.3.14'
const UV_VERSION = '0.11.16'
const RG_VERSION = '14.1.1'
function miseUrl(file) {
return `https://github.com/jdx/mise/releases/download/v${MISE_VERSION}/${file}`
}
function bunUrl(asset) {
return `https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/${asset}.zip`
}
function uvUrl(asset, ext) {
return `https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/${asset}.${ext}`
}
function rgUrl(asset, ext) {
return `https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/ripgrep-${RG_VERSION}-${asset}.${ext}`
}
const TOOLS = [
{
name: 'mise',
version: MISE_VERSION,
versionFile: '.mise-version',
required: true,
packages: {
'darwin-arm64': {
url: miseUrl(`mise-v${MISE_VERSION}-macos-arm64`),
archive: 'none',
binaries: ['mise'],
sha256: '1f404ecafe0a2ecc34bae661661b99e9cb06dba0f03f0e906ae4528b57d37e6c'
},
'darwin-x64': {
url: miseUrl(`mise-v${MISE_VERSION}-macos-x64`),
archive: 'none',
binaries: ['mise'],
sha256: '0a2383b0ca7e3cea2e68796917506e79b74f06a1a64501c7f83e14f2520b43f0'
},
'linux-x64': {
url: miseUrl(`mise-v${MISE_VERSION}-linux-x64`),
archive: 'none',
binaries: ['mise'],
sha256: '9bb41ae4dbe2bcdfdbe36cf3c737a8bdb72035c03af3b7218a70780988f62b9b'
},
'linux-arm64': {
url: miseUrl(`mise-v${MISE_VERSION}-linux-arm64`),
archive: 'none',
binaries: ['mise'],
sha256: 'a588ea2fec11f6383bd24998f5ede89100f70f1f47943b9ea30c88e4048ea91f'
},
'win32-x64': {
url: miseUrl(`mise-v${MISE_VERSION}-windows-x64.exe`),
archive: 'none',
binaries: ['mise.exe'],
sha256: '580401ddbc9977f94db85bbea51323f5aea6953dbe2a452cb49c2adcf1d8f7c0'
},
'win32-arm64': {
url: miseUrl(`mise-v${MISE_VERSION}-windows-arm64.exe`),
archive: 'none',
binaries: ['mise.exe'],
sha256: 'd29b9909d2aa1c85e4a43b9b4be24b2015423628ae29b15d7e677ab00fccd47e'
}
}
},
{
name: 'bun',
version: BUN_VERSION,
versionFile: '.bun-version',
packages: {
'darwin-arm64': {
url: bunUrl('bun-darwin-aarch64'),
archive: 'zip',
binaries: ['bun'],
strip: 'bun-darwin-aarch64',
sha256: 'd8b96221828ad6f97ac7ac0ab7e95872341af763001e8803e8267652c2652620'
},
'darwin-x64': {
url: bunUrl('bun-darwin-x64'),
archive: 'zip',
binaries: ['bun'],
strip: 'bun-darwin-x64',
sha256: '4183df3374623e5bab315c547cfa0974533cd457d86b73b639f7a87974cd6633'
},
'linux-arm64': {
url: bunUrl('bun-linux-aarch64'),
archive: 'zip',
binaries: ['bun'],
strip: 'bun-linux-aarch64',
sha256: 'a27ffb63a8310375836e0d6f668ae17fa8d8d18b88c37c821c65331973a19a3b'
},
'linux-x64': {
url: bunUrl('bun-linux-x64'),
archive: 'zip',
binaries: ['bun'],
strip: 'bun-linux-x64',
sha256: '951ee2aee855f08595aeec6225226a298d3fea83a3dcd6465c09cbccdf7e848f'
},
'win32-x64': {
url: bunUrl('bun-windows-x64'),
archive: 'zip',
binaries: ['bun.exe'],
strip: 'bun-windows-x64',
sha256: '0a0620930b6675d7ba440e81f4e0e00d3cfbe096c4b140d3fff02205e9e18922'
},
'win32-arm64': {
url: bunUrl('bun-windows-aarch64'),
archive: 'zip',
binaries: ['bun.exe'],
strip: 'bun-windows-aarch64',
sha256: '89841f5a57f2348b67ec0839b718f4bf4ea7d07c371c9ba4b77b6c790f918953'
}
}
},
{
name: 'uv',
version: UV_VERSION,
versionFile: '.uv-version',
packages: {
'darwin-arm64': {
url: uvUrl('uv-aarch64-apple-darwin', 'tar.gz'),
archive: 'tar.gz',
binaries: ['uv', 'uvx'],
sha256: '2b25be1af546be330b340b0a76b99f989daa6d92678fdffb87438e661e9d88fb'
},
'darwin-x64': {
url: uvUrl('uv-x86_64-apple-darwin', 'tar.gz'),
archive: 'tar.gz',
binaries: ['uv', 'uvx'],
sha256: '6b91ae3de155f51bd1f5b74814821c79f016a176561f252cd9ddfb976939af2e'
},
'linux-arm64': {
url: uvUrl('uv-aarch64-unknown-linux-gnu', 'tar.gz'),
archive: 'tar.gz',
binaries: ['uv', 'uvx'],
sha256: '8c9d0f0ee98166ae6ab198747519ba6f25db29d185bd2ae5960ecebc91a5c22a'
},
'linux-x64': {
url: uvUrl('uv-x86_64-unknown-linux-gnu', 'tar.gz'),
archive: 'tar.gz',
binaries: ['uv', 'uvx'],
sha256: '74947fe2c03315cf07e82ab3acc703eddef01aba4d5232a98e4c6825ec116131'
},
'win32-x64': {
url: uvUrl('uv-x86_64-pc-windows-msvc', 'zip'),
archive: 'zip',
binaries: ['uv.exe', 'uvx.exe'],
sha256: 'dd9d6d6554bfab265bfa98aa8e8a406c5c3a7b97582f93de1f4d48d9154a0395'
},
'win32-arm64': {
url: uvUrl('uv-aarch64-pc-windows-msvc', 'zip'),
archive: 'zip',
binaries: ['uv.exe', 'uvx.exe'],
sha256: 'e4f8e70eb21f0f4efd2eeb159ab289f9a16057d59881a4475758be4ce39bc8c5'
}
}
},
{
name: 'rg',
version: RG_VERSION,
versionFile: '.rg-version',
packages: {
'darwin-arm64': {
url: rgUrl('aarch64-apple-darwin', 'tar.gz'),
archive: 'tar.gz',
binaries: ['rg'],
sha256: '24ad76777745fbff131c8fbc466742b011f925bfa4fffa2ded6def23b5b937be'
},
'darwin-x64': {
url: rgUrl('x86_64-apple-darwin', 'tar.gz'),
archive: 'tar.gz',
binaries: ['rg'],
sha256: 'fc87e78f7cb3fea12d69072e7ef3b21509754717b746368fd40d88963630e2b3'
},
'linux-arm64': {
url: rgUrl('aarch64-unknown-linux-gnu', 'tar.gz'),
archive: 'tar.gz',
binaries: ['rg'],
sha256: 'c827481c4ff4ea10c9dc7a4022c8de5db34a5737cb74484d62eb94a95841ab2f'
},
'linux-x64': {
url: rgUrl('x86_64-unknown-linux-musl', 'tar.gz'),
archive: 'tar.gz',
binaries: ['rg'],
sha256: '4cf9f2741e6c465ffdb7c26f38056a59e2a2544b51f7cc128ef28337eeae4d8e'
},
'win32-x64': {
url: rgUrl('x86_64-pc-windows-msvc', 'zip'),
archive: 'zip',
binaries: ['rg.exe'],
strip: `ripgrep-${RG_VERSION}-x86_64-pc-windows-msvc`,
sha256: 'd0f534024c42afd6cb4d38907c25cd2b249b79bbe6cc1dbee8e3e37c2b6e25a1'
},
'win32-arm64': {
url: rgUrl('x86_64-pc-windows-msvc', 'zip'),
archive: 'zip',
binaries: ['rg.exe'],
strip: `ripgrep-${RG_VERSION}-x86_64-pc-windows-msvc`,
sha256: 'd0f534024c42afd6cb4d38907c25cd2b249b79bbe6cc1dbee8e3e37c2b6e25a1'
}
}
}
]
// ── Core logic ───────────────────────────────────────────────────────
function verifyHash(filePath, expected) {
const hash = crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex')
if (hash !== expected) {
fs.unlinkSync(filePath)
throw new Error(`SHA256 mismatch: expected ${expected}, got ${hash}`)
}
}
function chmodExec(filePath) {
if (process.platform !== 'win32') fs.chmodSync(filePath, 0o755)
}
function isUpToDate(binaryPaths, versionPath, expectedVersion) {
if (!fs.existsSync(versionPath)) return false
if (binaryPaths.some((binaryPath) => !fs.existsSync(binaryPath))) return false
return fs.readFileSync(versionPath, 'utf8').trim() === expectedVersion
}
function download(url, dest) {
console.log(` Downloading: ${url}`)
execFileSync('curl', ['-fSL', '--retry', '3', '-o', dest, url], { stdio: 'inherit' })
}
function extract(archivePath, archive, outputDir, pkg) {
if (archive === 'zip') {
if (process.platform === 'win32') {
const tmpExtract = path.join(outputDir, '__extract_tmp')
fs.mkdirSync(tmpExtract, { recursive: true })
try {
execFileSync(
'powershell',
['-NoProfile', '-Command', `Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpExtract}' -Force`],
{ stdio: 'inherit' }
)
for (const b of pkg.binaries) {
const src = pkg.strip ? path.join(tmpExtract, pkg.strip, b) : path.join(tmpExtract, b)
fs.copyFileSync(src, path.join(outputDir, b))
}
} finally {
fs.rmSync(tmpExtract, { recursive: true, force: true })
}
} else {
const globs = pkg.binaries.map((b) => (pkg.strip ? `${pkg.strip}/${b}` : b))
execFileSync('unzip', ['-o', '-j', archivePath, ...globs, '-d', outputDir], { stdio: 'inherit' })
}
} else if (archive === 'tar.gz') {
// Extract to a tmp dir and copy only the listed binaries — tarballs often
// ship LICENSE/README/man/completions that would otherwise bloat the bundle
// and collide across tools when two of them share `outputDir`.
const tmpExtract = path.join(outputDir, '__extract_tmp')
fs.mkdirSync(tmpExtract, { recursive: true })
try {
execFileSync('tar', ['xzf', archivePath, '-C', tmpExtract, '--strip-components=1'], { stdio: 'inherit' })
for (const b of pkg.binaries) {
fs.copyFileSync(path.join(tmpExtract, b), path.join(outputDir, b))
}
} finally {
fs.rmSync(tmpExtract, { recursive: true, force: true })
}
}
}
function downloadTool(tool, platformKey, outputDir) {
const pkg = tool.packages[platformKey]
if (!pkg) {
if (tool.required) {
throw new Error(`[${tool.name}] No binary for "${platformKey}". Add an entry to packages.`)
}
console.log(`[${tool.name}] No binary for "${platformKey}", skipping`)
return
}
const binaryPaths = pkg.binaries.map((binary) => path.join(outputDir, binary))
const primaryDest = binaryPaths[0]
const versionPath = path.join(outputDir, tool.versionFile)
if (isUpToDate(binaryPaths, versionPath, tool.version)) {
for (const binaryPath of binaryPaths) chmodExec(binaryPath)
console.log(`[${tool.name}] ${tool.version} already installed`)
return
}
if (pkg.archive === 'none') {
download(pkg.url, primaryDest)
verifyHash(primaryDest, pkg.sha256)
} else {
const ext = pkg.archive === 'tar.gz' ? 'tar.gz' : 'zip'
const archivePath = path.join(outputDir, `${tool.name}.${ext}`)
download(pkg.url, archivePath)
verifyHash(archivePath, pkg.sha256)
extract(archivePath, pkg.archive, outputDir, pkg)
fs.unlinkSync(archivePath)
}
for (const b of pkg.binaries) chmodExec(path.join(outputDir, b))
fs.writeFileSync(versionPath, tool.version, 'utf8')
console.log(`[${tool.name}] Installed ${pkg.binaries.join(', ')} ${tool.version}`)
}
// ── Main ─────────────────────────────────────────────────────────────
function main() {
const platform = process.argv[2] || process.platform
const arch = process.argv[3] || process.arch
const platformKey = `${platform}-${arch}`
console.log(`Downloading binaries for ${platformKey}...`)
const outputDir = path.join(__dirname, '..', 'resources', 'binaries', platformKey)
fs.mkdirSync(outputDir, { recursive: true })
for (const tool of TOOLS) {
try {
downloadTool(tool, platformKey, outputDir)
} catch (error) {
if (tool.required) {
throw error
}
console.warn(`[${tool.name}] Download failed (non-fatal): ${error.message}`)
}
}
console.log(`All binaries downloaded to ${outputDir}`)
}
/**
* Assert every bundled binary exists for the target platform. Dev keeps the
* lenient main() (non-required tools downgrade to a warning), but a release must
* never ship a half-empty resources/binaries/<platform> — a transient GitHub
* outage during download would otherwise produce a working build with no rg
* (search breaks) and no error. Call this from before-pack.js after main().
*/
function verifyBundledBinaries(platform, arch) {
const platformKey = `${platform}-${arch}`
const outputDir = path.join(__dirname, '..', 'resources', 'binaries', platformKey)
const missing = []
for (const tool of TOOLS) {
const pkg = tool.packages[platformKey]
if (!pkg) {
missing.push(`${tool.name} (no package for ${platformKey})`)
continue
}
for (const binary of pkg.binaries) {
if (!fs.existsSync(path.join(outputDir, binary))) {
missing.push(path.join(platformKey, binary))
}
}
}
if (missing.length > 0) {
throw new Error(`Bundled binaries missing after download for ${platformKey}:\n ${missing.join('\n ')}`)
}
console.log(`Verified all bundled binaries exist for ${platformKey}`)
}
module.exports = { verifyBundledBinaries }
// Only auto-download when run directly (node scripts/download-binaries.js ...).
// before-pack.js requires this module for verifyBundledBinaries without
// triggering a download for the build host's platform.
if (require.main === module) {
try {
main()
} catch (error) {
console.error('Failed to download binaries:', error.message)
process.exit(1)
}
}