diff --git a/.gitignore b/.gitignore index ec014c220..f9d718ce3 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ coverage .idea .vscode *.suo -*.lock \ No newline at end of file +*.lock +src/utils/vendor/ \ No newline at end of file diff --git a/build.ts b/build.ts index b179ec16d..27f576861 100644 --- a/build.ts +++ b/build.ts @@ -53,3 +53,19 @@ for (const file of files) { console.log( `Bundled ${result.outputs.length} files to ${outdir}/ (patched ${patched} for Node.js compat)`, ); + +// Step 4: Bundle download-ripgrep script as standalone JS for postinstall +const rgScript = await Bun.build({ + entrypoints: ["scripts/download-ripgrep.ts"], + outdir, + target: "node", +}); +if (!rgScript.success) { + console.error("Failed to bundle download-ripgrep script:"); + for (const log of rgScript.logs) { + console.error(log); + } + // Non-fatal — postinstall fallback to bun run scripts/download-ripgrep.ts +} else { + console.log(`Bundled download-ripgrep script to ${outdir}/`); +} diff --git a/package.json b/package.json index 8b9831c0a..1d650533c 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "packages/@ant/*" ], "files": [ - "dist" + "dist", + "scripts/download-ripgrep.ts" ], "scripts": { "build": "bun run build.ts", @@ -46,6 +47,7 @@ "test": "bun test", "check:unused": "knip-bun", "health": "bun run scripts/health-check.ts", + "postinstall": "node dist/download-ripgrep.js || bun run scripts/download-ripgrep.ts || true", "docs:dev": "npx mintlify dev" }, "dependencies": {}, diff --git a/scripts/download-ripgrep.ts b/scripts/download-ripgrep.ts new file mode 100644 index 000000000..fce392f6a --- /dev/null +++ b/scripts/download-ripgrep.ts @@ -0,0 +1,191 @@ +/** + * Download ripgrep binary from GitHub releases. + * + * Run automatically via `bun install` (postinstall hook), + * or manually: `bun run scripts/download-ripgrep.ts [--force]` + * + * Idempotent — skips download if the binary already exists. + * Use --force to re-download. + */ + +import { existsSync, mkdirSync, renameSync, rmSync, statSync } from 'fs' +import { chmodSync } from 'fs' +import { spawnSync } from 'child_process' +import * as path from 'path' +import { fileURLToPath } from 'url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const RG_VERSION = '15.0.1' +const BASE_URL = `https://github.com/microsoft/ripgrep-prebuilt/releases/download/v${RG_VERSION}` + +// --- Platform mapping --- + +type PlatformMapping = { + target: string + ext: 'tar.gz' | 'zip' +} + +function getPlatformMapping(): PlatformMapping { + 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') { + // x64 Linux always uses musl (statically linked, most portable) + 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(): boolean { + const muslArch = process.arch === 'x64' ? 'x86_64' : 'aarch64' + try { + statSync(`/lib/libc.musl-${muslArch}.so.1`) + return true + } catch { + return false + } +} + +// --- Target vendor path (must match ripgrep.ts logic) --- + +function getVendorDir(): string { + const packageRoot = path.resolve(__dirname, '..') + + // Dev mode: package root has src/ directory + // ripgrep.ts at src/utils/ripgrep.ts: __dirname = src/utils/ + // vendor path = src/utils/vendor/ripgrep/ + if (existsSync(path.join(packageRoot, 'src'))) { + return path.resolve(packageRoot, 'src', 'utils', 'vendor', 'ripgrep') + } + + // Published mode: compiled chunks are flat in dist/ + // ripgrep chunk at dist/xxxx.js: __dirname = dist/ + // vendor path = dist/vendor/ripgrep/ + return path.resolve(packageRoot, 'dist', 'vendor', 'ripgrep') +} + +function getBinaryPath(): string { + const dir = getVendorDir() + const subdir = `${process.arch}-${process.platform}` + const binary = process.platform === 'win32' ? 'rg.exe' : 'rg' + return path.resolve(dir, subdir, binary) +} + +// --- Download & extract --- + +async function downloadAndExtract(): Promise { + const { target, ext } = getPlatformMapping() + const assetName = `ripgrep-v${RG_VERSION}-${target}.${ext}` + const downloadUrl = `${BASE_URL}/${assetName}` + + const binaryPath = getBinaryPath() + const binaryDir = path.dirname(binaryPath) + + // Idempotent: skip if binary exists and has content + 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}`) + + // Prepare temp directory + const tmpDir = path.join(binaryDir, '.tmp-download') + rmSync(tmpDir, { recursive: true, force: true }) + mkdirSync(tmpDir, { recursive: true }) + + try { + const archivePath = path.join(tmpDir, assetName) + + // Download + const response = await fetch(downloadUrl, { redirect: 'follow' }) + if (!response.ok) { + throw new Error(`Download failed: ${response.status} ${response.statusText}`) + } + + const buffer = Buffer.from(await response.arrayBuffer()) + const { writeFileSync } = await import('fs') + writeFileSync(archivePath, buffer) + console.log(`[ripgrep] Downloaded ${Math.round(buffer.length / 1024)} KB`) + + // Extract + mkdirSync(binaryDir, { recursive: true }) + + if (ext === 'tar.gz') { + const result = spawnSync('tar', ['xzf', archivePath, '-C', tmpDir], { + stdio: 'pipe', + }) + if (result.status !== 0) { + throw new Error(`tar extract failed: ${result.stderr?.toString()}`) + } + } else { + // .zip + const result = spawnSync('unzip', ['-o', archivePath, '-d', tmpDir], { + stdio: 'pipe', + }) + if (result.status !== 0) { + throw new Error(`unzip failed: ${result.stderr?.toString()}`) + } + } + + // Find the rg binary in the extracted directory + // microsoft/ripgrep-prebuilt archives extract flat: ./rg (no subdirectory) + const extractedBinary = process.platform === 'win32' ? 'rg.exe' : 'rg' + const srcBinary = path.join(tmpDir, extractedBinary) + + if (!existsSync(srcBinary)) { + throw new Error(`Binary not found at expected path: ${srcBinary}`) + } + + // Move to final location + renameSync(srcBinary, binaryPath) + + // Make executable (non-Windows) + if (process.platform !== 'win32') { + chmodSync(binaryPath, 0o755) + } + + console.log(`[ripgrep] Installed to ${binaryPath}`) + } finally { + // Cleanup temp directory + rmSync(tmpDir, { recursive: true, force: true }) + } +} + +// --- Main --- + +downloadAndExtract().catch(error => { + console.error(`[ripgrep] Download failed: ${error.message}`) + console.error(`[ripgrep] You can install ripgrep manually: https://github.com/BurntSushi/ripgrep#installation`) + // Don't exit with error code — postinstall should not break bun install + process.exit(0) +})