feat: 支持简化版本的 rg 下载

This commit is contained in:
claude-code-best
2026-04-06 10:31:15 +08:00
parent ced5080019
commit dee2ffd638
2 changed files with 322 additions and 2 deletions

View File

@@ -34,7 +34,8 @@
], ],
"files": [ "files": [
"dist", "dist",
"scripts/download-ripgrep.ts" "scripts/download-ripgrep.ts",
"scripts/postinstall.cjs"
], ],
"scripts": { "scripts": {
"build": "bun run build.ts", "build": "bun run build.ts",
@@ -48,7 +49,7 @@
"test": "bun test", "test": "bun test",
"check:unused": "knip-bun", "check:unused": "knip-bun",
"health": "bun run scripts/health-check.ts", "health": "bun run scripts/health-check.ts",
"postinstall": "node dist/download-ripgrep.js || bun run scripts/download-ripgrep.ts || true", "postinstall": "node scripts/postinstall.cjs",
"docs:dev": "npx mintlify dev" "docs:dev": "npx mintlify dev"
}, },
"dependencies": {}, "dependencies": {},

319
scripts/postinstall.cjs Normal file
View File

@@ -0,0 +1,319 @@
#!/usr/bin/env node
/**
* Postinstall script — runs automatically after `bun install` or `npm install`.
*
* Downloads ripgrep binary (idempotent, skips if exists).
* Works in dev mode (src/ exists), published mode (dist/ exists), with bun or node.
*
* Usage:
* node scripts/postinstall.js
* node scripts/postinstall.js --force
* bun run scripts/postinstall.js
*/
const { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, chmodSync } =
require("fs")
const { spawnSync } = require("child_process")
const { setDefaultResultOrder } = require("node:dns")
const path = require("path")
const os = require("os")
// Prefer IPv4 first — Bun on Windows sometimes fails GitHub over broken IPv6 paths.
try {
setDefaultResultOrder("ipv4first")
} catch {
/* ignore */
}
// --- Config ---
const RG_VERSION = "15.0.1"
const DEFAULT_RELEASE_BASE = `https://github.com/microsoft/ripgrep-prebuilt/releases/download/v${RG_VERSION}`
const RELEASE_BASE = (process.env.RIPGREP_DOWNLOAD_BASE ?? DEFAULT_RELEASE_BASE).replace(/\/$/, "")
const scriptDir = path.dirname(__filename)
const projectRoot = path.resolve(scriptDir, "..")
// --- Platform mapping ---
function getPlatformMapping() {
const arch = process.arch
const platform = process.platform
if (platform === "darwin") {
if (arch === "arm64") return { target: "aarch64-apple-darwin", ext: "tar.gz" }
if (arch === "x64") return { target: "x86_64-apple-darwin", ext: "tar.gz" }
throw new Error(`Unsupported macOS arch: ${arch}`)
}
if (platform === "win32") {
if (arch === "x64") return { target: "x86_64-pc-windows-msvc", ext: "zip" }
if (arch === "arm64") return { target: "aarch64-pc-windows-msvc", ext: "zip" }
throw new Error(`Unsupported Windows arch: ${arch}`)
}
if (platform === "linux") {
const isMusl = detectMusl()
if (arch === "x64") {
return { target: "x86_64-unknown-linux-musl", ext: "tar.gz" }
}
if (arch === "arm64") {
return isMusl
? { target: "aarch64-unknown-linux-musl", ext: "tar.gz" }
: { target: "aarch64-unknown-linux-gnu", ext: "tar.gz" }
}
throw new Error(`Unsupported Linux arch: ${arch}`)
}
throw new Error(`Unsupported platform: ${platform}`)
}
function detectMusl() {
const muslArch = process.arch === "x64" ? "x86_64" : "aarch64"
try {
statSync(`/lib/libc.musl-${muslArch}.so.1`)
return true
} catch {
return false
}
}
// --- Paths ---
function getVendorDir() {
if (existsSync(path.join(projectRoot, "src"))) {
return path.resolve(projectRoot, "src", "utils", "vendor", "ripgrep")
}
return path.resolve(projectRoot, "dist", "vendor", "ripgrep")
}
function getBinaryPath() {
const dir = getVendorDir()
const subdir = `${process.arch}-${process.platform}`
const binary = process.platform === "win32" ? "rg.exe" : "rg"
return path.resolve(dir, subdir, binary)
}
// --- Download helpers ---
function proxyEnvSet() {
const v = (s) => (s ?? "").trim()
return !!(v(process.env.HTTPS_PROXY) || v(process.env.HTTP_PROXY) || v(process.env.ALL_PROXY) || v(process.env.https_proxy) || v(process.env.http_proxy))
}
function tryPowerShellDownload(url, dest) {
const u = url.replace(/'/g, "''")
const d = dest.replace(/'/g, "''")
const cmd = `Invoke-WebRequest -Uri '${u}' -OutFile '${d}' -UseBasicParsing`
const result = spawnSync(
"powershell.exe",
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", cmd],
{ stdio: "pipe", windowsHide: true },
)
return result.status === 0 && existsSync(dest) && statSync(dest).size > 0
}
function tryCurlDownload(url, dest) {
const curl = process.platform === "win32" ? "curl.exe" : "curl"
const result = spawnSync(curl, ["-fsSL", "-L", "--fail", "-o", dest, url], {
stdio: "pipe",
windowsHide: true,
})
return result.status === 0 && existsSync(dest) && statSync(dest).size > 0
}
async function fetchRelease(url) {
if (proxyEnvSet()) {
// Dynamic require so it works in node without bundling issues
const undici = require("undici")
return await undici.fetch(url, {
redirect: "follow",
dispatcher: new undici.EnvHttpProxyAgent(),
})
}
// Node 18+ has global fetch, Bun has it too
return await fetch(url, { redirect: "follow" })
}
async function downloadUrlToBuffer(url) {
const response = await fetchRelease(url)
if (!response.ok) {
throw new Error(`Download failed: ${response.status} ${response.statusText}`)
}
return Buffer.from(await response.arrayBuffer())
}
async function downloadUrlToBufferWithFallback(url) {
let firstError
try {
return await downloadUrlToBuffer(url)
} catch (e) {
firstError = e
}
const tmpRoot = path.join(os.tmpdir(), `ripgrep-dl-${process.pid}-${Date.now()}`)
const tmpFile = path.join(tmpRoot, "archive")
mkdirSync(tmpRoot, { recursive: true })
try {
if (process.platform === "win32" && tryPowerShellDownload(url, tmpFile)) {
return readFileSync(tmpFile)
}
if (tryCurlDownload(url, tmpFile)) {
return readFileSync(tmpFile)
}
} finally {
rmSync(tmpRoot, { recursive: true, force: true })
}
throw firstError
}
// --- Extract ---
function findZipEntryKey(files, want) {
return Object.keys(files).find((k) => {
const norm = k.replace(/\\/g, "/")
return norm === want || norm.endsWith(`/${want}`)
})
}
async function extractZip(buffer, binaryPath, extractedBinary) {
const binaryDir = path.dirname(binaryPath)
// Try fflate first (bundled dep)
let fflateError
try {
const { unzipSync } = require("fflate")
const unzipped = unzipSync(new Uint8Array(buffer))
const key = findZipEntryKey(unzipped, extractedBinary)
if (!key) {
throw new Error(`Binary ${extractedBinary} not found in zip`)
}
writeFileSync(binaryPath, Buffer.from(unzipped[key]))
return
} catch (e) {
fflateError = e
}
// Fallback: PowerShell Expand-Archive or unzip CLI
const tmpDir = path.join(binaryDir, ".tmp-download")
rmSync(tmpDir, { recursive: true, force: true })
mkdirSync(tmpDir, { recursive: true })
try {
const assetName = `archive.zip`
const archivePath = path.join(tmpDir, assetName)
writeFileSync(archivePath, buffer)
let extracted = false
if (process.platform === "win32") {
const psCmd = `Expand-Archive -Path '${archivePath.replace(/'/g, "''")}' -DestinationPath '${tmpDir.replace(/'/g, "''")}' -Force`
const psResult = spawnSync(
"powershell.exe",
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", psCmd],
{ stdio: "pipe", windowsHide: true },
)
if (psResult.status === 0) {
extracted = true
}
}
if (!extracted) {
const result = spawnSync("unzip", ["-o", archivePath, "-d", tmpDir], { stdio: "pipe" })
if (result.status !== 0) {
const unzipErr = result.stderr?.toString().trim() || "command not found"
const fflateMsg = fflateError instanceof Error ? fflateError.message : String(fflateError)
throw new Error(`zip extraction failed (fflate: ${fflateMsg}; unzip: ${unzipErr})`)
}
}
const srcBinary = path.join(tmpDir, extractedBinary)
if (!existsSync(srcBinary)) {
throw new Error(`Binary not found at expected path: ${srcBinary}`)
}
renameSync(srcBinary, binaryPath)
} finally {
rmSync(tmpDir, { recursive: true, force: true })
}
}
async function extractTarGz(buffer, binaryPath, extractedBinary, assetName) {
const binaryDir = path.dirname(binaryPath)
const tmpDir = path.join(binaryDir, ".tmp-download")
rmSync(tmpDir, { recursive: true, force: true })
mkdirSync(tmpDir, { recursive: true })
try {
const archivePath = path.join(tmpDir, assetName)
writeFileSync(archivePath, buffer)
const result = spawnSync("tar", ["xzf", archivePath, "-C", tmpDir], { stdio: "pipe" })
if (result.status !== 0) {
throw new Error(`tar extract failed: ${result.stderr?.toString()}`)
}
const srcBinary = path.join(tmpDir, extractedBinary)
if (!existsSync(srcBinary)) {
throw new Error(`Binary not found at expected path: ${srcBinary}`)
}
renameSync(srcBinary, binaryPath)
} finally {
rmSync(tmpDir, { recursive: true, force: true })
}
}
// --- Main ---
async function downloadAndExtract() {
const { target, ext } = getPlatformMapping()
const assetName = `ripgrep-v${RG_VERSION}-${target}.${ext}`
const downloadUrl = `${RELEASE_BASE}/${assetName}`
const binaryPath = getBinaryPath()
const binaryDir = path.dirname(binaryPath)
const force = process.argv.includes("--force")
if (!force && existsSync(binaryPath)) {
const stat = statSync(binaryPath)
if (stat.size > 0) {
console.log(`[ripgrep] Binary already exists at ${binaryPath}, skipping.`)
return
}
}
console.log(`[ripgrep] Downloading v${RG_VERSION} for ${target}...`)
console.log(`[ripgrep] URL: ${downloadUrl}`)
const extractedBinary = process.platform === "win32" ? "rg.exe" : "rg"
try {
const buffer = await downloadUrlToBufferWithFallback(downloadUrl)
console.log(`[ripgrep] Downloaded ${Math.round(buffer.length / 1024)} KB`)
mkdirSync(binaryDir, { recursive: true })
if (ext === "tar.gz") {
await extractTarGz(buffer, binaryPath, extractedBinary, assetName)
} else {
await extractZip(buffer, binaryPath, extractedBinary)
}
if (process.platform !== "win32") {
chmodSync(binaryPath, 0o755)
}
console.log(`[ripgrep] Installed to ${binaryPath}`)
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
const hint =
"Check network or set HTTPS_PROXY. If GitHub is blocked, set RIPGREP_DOWNLOAD_BASE to a mirror (see script header)."
throw new Error(`${msg} ${hint}`)
}
}
async function main() {
await downloadAndExtract()
}
main().catch((error) => {
const msg = error instanceof Error ? error.message : String(error)
console.error(`[postinstall] ripgrep download failed (non-fatal): ${msg}`)
console.error(`[postinstall] You can install ripgrep manually: https://github.com/BurntSushi/ripgrep#installation`)
// Never exit with error code — postinstall must not break install
process.exit(0)
})