From a3f6a0a25cec9835580ff036347737264dcefebc Mon Sep 17 00:00:00 2001 From: Ame <112511595+amekawaii@users.noreply.github.com> Date: Sat, 11 Jul 2026 17:03:31 +0800 Subject: [PATCH] Bundle fd and ripgrep in managed runtime --- apps/desktop/src/main.ts | 9 + scripts/assert-desktop-package.mjs | 51 ++++- scripts/assert-desktop-package.spec.ts | 22 ++- scripts/guardian/dev.ts | 14 +- scripts/smoke-packaged-toolchain.mjs | 62 +++++- scripts/smoke-packaged-toolchain.spec.ts | 34 +++- scripts/vendor-managed-runtime.mjs | 230 +++++++++++++++++++++-- scripts/vendor-managed-runtime.spec.ts | 35 ++++ 8 files changed, 428 insertions(+), 29 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index feafdde65..a612dc6ef 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -233,6 +233,15 @@ function resolveManagedRuntimeEnv(opts: { } const toolchainPaths: string[] = [] + const managedSearchToolsDir = existingDir(join( + opts.appHome, + 'vendor', + 'tools', + platformArch, + 'bin', + )) + if (managedSearchToolsDir) toolchainPaths.push(managedSearchToolsDir) + if (process.platform === 'win32') { const gitDir = existingDir(join(opts.appHome, 'vendor', 'git', platformArch)) if (gitDir) { diff --git a/scripts/assert-desktop-package.mjs b/scripts/assert-desktop-package.mjs index e79921f51..110e8a0e6 100644 --- a/scripts/assert-desktop-package.mjs +++ b/scripts/assert-desktop-package.mjs @@ -54,7 +54,7 @@ export function assertDesktopPackage(options = {}) { const platform = options.platform ?? platformFromAppRoot(appRoot) const arch = options.arch ?? process.arch - const platformArch = platform === 'win32' ? `win32-${arch}` : null + const platformArch = `${platform}-${arch}` const requiredFiles = [...BASE_REQUIRED_FILES, ...platformRequiredFiles(platform, platformArch)] const missing = requiredFiles.filter((file) => !existsSync(join(appRoot, file))) if (missing.length > 0) { @@ -78,6 +78,29 @@ export function assertDesktopPackage(options = {}) { errors.push(`[desktop-package] unexpected manifest.pi.cli: ${JSON.stringify(manifest?.pi?.cli)}`) } + if ((platform === 'win32' || platform === 'darwin') && platformArch) { + const searchTools = manifest?.searchTools?.[platformArch] + if (!searchTools) { + errors.push(`[desktop-package] expected manifest.searchTools.${platformArch} for managed fd/rg`) + } else { + if (searchTools.path !== `vendor/tools/${platformArch}`) { + errors.push( + `[desktop-package] unexpected manifest.searchTools.${platformArch}.path: ${JSON.stringify(searchTools.path)}`, + ) + } + if (normalizeManifestPath(searchTools.fd?.binary) !== `bin/fd${platform === 'win32' ? '.exe' : ''}`) { + errors.push( + `[desktop-package] unexpected manifest.searchTools.${platformArch}.fd.binary: ${JSON.stringify(searchTools.fd?.binary)}`, + ) + } + if (normalizeManifestPath(searchTools.rg?.binary) !== `bin/rg${platform === 'win32' ? '.exe' : ''}`) { + errors.push( + `[desktop-package] unexpected manifest.searchTools.${platformArch}.rg.binary: ${JSON.stringify(searchTools.rg?.binary)}`, + ) + } + } + } + if (platform === 'win32' && platformArch) { const git = manifest?.git?.[platformArch] if (!git) { @@ -110,12 +133,20 @@ export function platformFromAppRoot(appRoot) { } function platformRequiredFiles(platform, platformArch) { - if (platform !== 'win32' || !platformArch) return [] - return [ - `vendor/git/${platformArch}/cmd/git.exe`, - `vendor/git/${platformArch}/bin/bash.exe`, - `vendor/git/${platformArch}/bin/sh.exe`, - ] + if (!platformArch) return [] + const searchTools = platform === 'win32' + ? [`vendor/tools/${platformArch}/bin/fd.exe`, `vendor/tools/${platformArch}/bin/rg.exe`] + : platform === 'darwin' + ? [`vendor/tools/${platformArch}/bin/fd`, `vendor/tools/${platformArch}/bin/rg`] + : [] + const git = platform === 'win32' + ? [ + `vendor/git/${platformArch}/cmd/git.exe`, + `vendor/git/${platformArch}/bin/bash.exe`, + `vendor/git/${platformArch}/bin/sh.exe`, + ] + : [] + return [...searchTools, ...git] } function normalizeManifestPath(value) { @@ -130,6 +161,12 @@ function main() { } console.log(`[desktop-package] app resources OK: ${relative(repoRoot, result.appRoot)}`) console.log(`[desktop-package] managed Pi: ${result.manifest.pi.version} (${result.manifest.pi.mode})`) + if (result.platform === 'win32' || result.platform === 'darwin') { + const tools = result.manifest.searchTools[result.platformArch] + console.log( + `[desktop-package] managed fd/rg: ${tools.fd.version}/${tools.rg.version} (${result.platformArch})`, + ) + } if (result.platform === 'win32') { const git = result.manifest.git[result.platformArch] console.log(`[desktop-package] managed Git Bash: ${git.version} (${result.platformArch})`) diff --git a/scripts/assert-desktop-package.spec.ts b/scripts/assert-desktop-package.spec.ts index d51d355bb..b91719059 100644 --- a/scripts/assert-desktop-package.spec.ts +++ b/scripts/assert-desktop-package.spec.ts @@ -32,12 +32,27 @@ function piManifest() { } } +function searchToolsManifest(platformArch: string, windows = false) { + return { + searchTools: { + [platformArch]: { + path: `vendor/tools/${platformArch}`, + binPath: 'bin', + fd: { version: '10.4.2', binary: `bin/fd${windows ? '.exe' : ''}` }, + rg: { version: '15.1.0', binary: `bin/rg${windows ? '.exe' : ''}` }, + }, + }, + } +} + describe('assertDesktopPackage', () => { - it('does not require vendor Git in macOS packages', () => { + it('requires managed search tools but not vendor Git in macOS packages', () => { const root = mkdtempSync(join(tmpdir(), 'openalice-package-mac-')) try { const appRoot = join(root, 'mac-arm64/OpenAlice.app/Contents/Resources/app') - writeBasePackage(appRoot, piManifest()) + writeBasePackage(appRoot, { ...piManifest(), ...searchToolsManifest('darwin-arm64') }) + writePackageFile(appRoot, 'vendor/tools/darwin-arm64/bin/fd') + writePackageFile(appRoot, 'vendor/tools/darwin-arm64/bin/rg') const result = assertDesktopPackage({ packageRoot: root, repoRoot: root, arch: 'arm64' }) @@ -71,6 +86,7 @@ describe('assertDesktopPackage', () => { const appRoot = join(root, 'win-unpacked/resources/app') writeBasePackage(appRoot, { ...piManifest(), + ...searchToolsManifest('win32-x64', true), git: { 'win32-x64': { version: '2.55.0.2', @@ -84,6 +100,8 @@ describe('assertDesktopPackage', () => { writePackageFile(appRoot, 'vendor/git/win32-x64/cmd/git.exe') writePackageFile(appRoot, 'vendor/git/win32-x64/bin/bash.exe') writePackageFile(appRoot, 'vendor/git/win32-x64/bin/sh.exe') + writePackageFile(appRoot, 'vendor/tools/win32-x64/bin/fd.exe') + writePackageFile(appRoot, 'vendor/tools/win32-x64/bin/rg.exe') const result = assertDesktopPackage({ packageRoot: root, repoRoot: root, arch: 'x64' }) diff --git a/scripts/guardian/dev.ts b/scripts/guardian/dev.ts index 4a48765e9..fcec422eb 100644 --- a/scripts/guardian/dev.ts +++ b/scripts/guardian/dev.ts @@ -14,7 +14,7 @@ * Replaces the previous `scripts/dev.ts`. Same `pnpm dev` UX. */ -import { resolve } from 'node:path' +import { delimiter, resolve } from 'node:path' import { homedir } from 'node:os' import { existsSync } from 'node:fs' import type { ChildProcess } from 'node:child_process' @@ -113,6 +113,17 @@ async function main(): Promise { const flagPath = resolve(dataHome, 'data/control/restart-uta.flag') const utaUrl = `http://127.0.0.1:${ports.utaPort}` const backendHotReload = isBackendHotReloadEnabled(process.env) + const managedSearchToolsDir = resolve( + process.cwd(), + 'vendor', + 'tools', + `${process.platform}-${process.arch}`, + 'bin', + ) + const managedToolchainPath = [ + ...(existsSync(managedSearchToolsDir) ? [managedSearchToolsDir] : []), + ...(process.env['OPENALICE_MANAGED_TOOLCHAIN_PATH'] ?? '').split(delimiter).filter(Boolean), + ].join(delimiter) console.log('') console.log(`[guardian] mode → ${initialMode.mode} (${initialMode.source}${initialMode.envLocked ? ', env-locked' : ''})`) @@ -137,6 +148,7 @@ async function main(): Promise { OPENALICE_LAUNCHER: 'dev', OPENALICE_GUARDIAN_PID: String(process.pid), OPENALICE_GUARDIAN_STARTED_AT: String(guardianStartedAt), + ...(managedToolchainPath ? { OPENALICE_MANAGED_TOOLCHAIN_PATH: managedToolchainPath } : {}), ...(takeover ? { OPENALICE_TAKEOVER: '1' } : {}), } diff --git a/scripts/smoke-packaged-toolchain.mjs b/scripts/smoke-packaged-toolchain.mjs index 3aea98208..edae98a7b 100644 --- a/scripts/smoke-packaged-toolchain.mjs +++ b/scripts/smoke-packaged-toolchain.mjs @@ -36,6 +36,61 @@ export function buildPackagedToolchainSmokePlan(packageResult) { expectStdout: /\b0\.80\.6\b/, }) + const searchTools = packageResult.manifest.searchTools?.[packageResult.platformArch] + if ((packageResult.platform === 'win32' || packageResult.platform === 'darwin') && !searchTools) { + errors.push(`[packaged-toolchain] missing managed fd/rg manifest entry ${packageResult.platformArch}`) + return { ok: false, errors, commands } + } + if (searchTools) { + const searchToolsRoot = join(packageResult.appRoot, searchTools.path) + const searchToolsBin = join(searchToolsRoot, searchTools.binPath) + const fdBinary = join(searchToolsRoot, searchTools.fd.binary) + const rgBinary = join(searchToolsRoot, searchTools.rg.binary) + const searchToolsEnv = { + PATH: [searchToolsBin, process.env['PATH']].filter(Boolean).join(delimiter), + } + commands.push({ + label: 'managed fd', + command: fdBinary, + args: ['--version'], + expectStdout: /^fd \d+\.\d+\.\d+/m, + }) + commands.push({ + label: 'managed ripgrep', + command: rgBinary, + args: ['--version'], + expectStdout: /^ripgrep \d+\.\d+\.\d+/m, + }) + + const piToolsManager = join( + packageResult.appRoot, + 'vendor', + 'pi', + 'node_modules', + '@earendil-works', + 'pi-coding-agent', + 'dist', + 'utils', + 'tools-manager.js', + ) + const piProbe = [ + `import(${JSON.stringify(pathToFileURL(piToolsManager).href)})`, + '.then((m) => console.log("OPENALICE_PI_SEARCH_TOOLS_OK " + m.getToolPath("fd") + " " + m.getToolPath("rg")))', + '.catch((err) => { console.error(err); process.exit(1) })', + ].join('') + commands.push({ + label: 'managed Pi resolves packaged fd/rg without download', + command: electron, + args: ['-e', piProbe], + env: { + ...searchToolsEnv, + ELECTRON_RUN_AS_NODE: '1', + PI_CODING_AGENT_DIR: join(packageResult.appRoot, '.missing-smoke-pi-agent'), + }, + expectStdout: /OPENALICE_PI_SEARCH_TOOLS_OK fd rg/, + }) + } + commands.push({ label: 'workspace CLI payload through packaged Electron Node', command: electron, @@ -62,8 +117,13 @@ export function buildPackagedToolchainSmokePlan(packageResult) { const toolchainPath = (Array.isArray(git.toolchainPaths) ? git.toolchainPaths : ['cmd', 'bin', 'usr/bin']) .map((entry) => join(gitRoot, entry)) .join(delimiter) + const managedSearchToolsBin = searchTools + ? join(packageResult.appRoot, searchTools.path, searchTools.binPath) + : null const winEnv = { - PATH: [workspaceCliDir, toolchainPath, process.env['PATH']].filter(Boolean).join(delimiter), + PATH: [workspaceCliDir, managedSearchToolsBin, toolchainPath, process.env['PATH']] + .filter(Boolean) + .join(delimiter), CHERE_INVOKING: '1', MSYSTEM: packageResult.platformArch.endsWith('arm64') ? 'CLANGARM64' : 'MINGW64', OPENALICE_MANAGED_PI_NODE_PATH: electron, diff --git a/scripts/smoke-packaged-toolchain.spec.ts b/scripts/smoke-packaged-toolchain.spec.ts index ea0ddda6d..c148eba66 100644 --- a/scripts/smoke-packaged-toolchain.spec.ts +++ b/scripts/smoke-packaged-toolchain.spec.ts @@ -25,11 +25,19 @@ describe('buildPackagedToolchainSmokePlan', () => { errors: [], appRoot, platform: 'darwin', - platformArch: null, + platformArch: 'darwin-arm64', manifest: { pi: { cli: 'vendor/pi/node_modules/@earendil-works/pi-coding-agent/dist/cli.js', }, + searchTools: { + 'darwin-arm64': { + path: 'vendor/tools/darwin-arm64', + binPath: 'bin', + fd: { binary: 'bin/fd' }, + rg: { binary: 'bin/rg' }, + }, + }, }, }) @@ -37,6 +45,9 @@ describe('buildPackagedToolchainSmokePlan', () => { expect(plan.commands.map((command) => command.label)).toEqual([ 'packaged Electron Node mode', 'managed Pi through packaged Electron Node', + 'managed fd', + 'managed ripgrep', + 'managed Pi resolves packaged fd/rg without download', 'workspace CLI payload through packaged Electron Node', ]) expect(packagedElectronExecutable(appRoot, 'darwin')?.replaceAll('\\', '/')) @@ -61,6 +72,14 @@ describe('buildPackagedToolchainSmokePlan', () => { pi: { cli: 'vendor/pi/node_modules/@earendil-works/pi-coding-agent/dist/cli.js', }, + searchTools: { + 'win32-x64': { + path: 'vendor/tools/win32-x64', + binPath: 'bin', + fd: { binary: 'bin/fd.exe' }, + rg: { binary: 'bin/rg.exe' }, + }, + }, git: { 'win32-x64': { path: 'vendor/git/win32-x64', @@ -77,6 +96,9 @@ describe('buildPackagedToolchainSmokePlan', () => { expect(plan.commands.map((command) => command.label)).toEqual([ 'packaged Electron Node mode', 'managed Pi through packaged Electron Node', + 'managed fd', + 'managed ripgrep', + 'managed Pi resolves packaged fd/rg without download', 'workspace CLI payload through packaged Electron Node', 'managed git.exe', 'managed bash.exe', @@ -84,11 +106,13 @@ describe('buildPackagedToolchainSmokePlan', () => { 'Workspace CLI launcher through managed Git Bash', 'Workspace CLI transport env through managed Git Bash', ]) - expect(plan.commands[3].command.replaceAll('\\', '/')).toContain('vendor/git/win32-x64/cmd/git.exe') - expect(plan.commands[5].env?.PATH.replaceAll('\\', '/')).toContain('vendor/git/win32-x64/mingw64/bin') - expect(plan.commands[6].env?.OPENALICE_MANAGED_PI_NODE_PATH.replaceAll('\\', '/')) + expect(plan.commands[2].command.replaceAll('\\', '/')).toContain('vendor/tools/win32-x64/bin/fd.exe') + expect(plan.commands[6].command.replaceAll('\\', '/')).toContain('vendor/git/win32-x64/cmd/git.exe') + expect(plan.commands[8].env?.PATH.replaceAll('\\', '/')).toContain('vendor/git/win32-x64/mingw64/bin') + expect(plan.commands[8].env?.PATH.replaceAll('\\', '/')).toContain('vendor/tools/win32-x64/bin') + expect(plan.commands[9].env?.OPENALICE_MANAGED_PI_NODE_PATH.replaceAll('\\', '/')) .toContain('win-unpacked/OpenAlice.exe') - expect(plan.commands[7].env?.OPENALICE_TOOL_URL).toBe('/cli') + expect(plan.commands[10].env?.OPENALICE_TOOL_URL).toBe('/cli') } finally { rmSync(root, { recursive: true, force: true }) } diff --git a/scripts/vendor-managed-runtime.mjs b/scripts/vendor-managed-runtime.mjs index bee903caa..cef5e0856 100644 --- a/scripts/vendor-managed-runtime.mjs +++ b/scripts/vendor-managed-runtime.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node import { spawnSync } from 'node:child_process' import { createHash } from 'node:crypto' -import { existsSync, readFileSync } from 'node:fs' -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { chmod, copyFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { basename, dirname, relative, resolve } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -55,6 +55,90 @@ const WINDOWS_GIT_RUNTIMES = { }, } +const MANAGED_SEARCH_TOOL_RUNTIMES = { + 'win32-x64': searchToolRuntime('win32', 'x64', { + fd: releaseAsset( + 'fd', + '10.4.2', + 'fd.exe', + 'https://github.com/sharkdp/fd/releases/download/v10.4.2/fd-v10.4.2-x86_64-pc-windows-msvc.zip', + 'b2816e506390a89941c63c9187d58a3cc10e9a55f2ef0685f9ea0eccaf7c98c8', + ), + rg: releaseAsset( + 'rg', + '15.1.0', + 'rg.exe', + 'https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-x86_64-pc-windows-msvc.zip', + '124510b94b6baa3380d051fdf4650eaa80a302c876d611e9dba0b2e18d87493a', + ), + }), + 'win32-arm64': searchToolRuntime('win32', 'arm64', { + fd: releaseAsset( + 'fd', + '10.4.2', + 'fd.exe', + 'https://github.com/sharkdp/fd/releases/download/v10.4.2/fd-v10.4.2-aarch64-pc-windows-msvc.zip', + '4f9110c2d5b33a7f760bfa5510f4c113d828109f7277d421b1053a9943c0fc92', + ), + rg: releaseAsset( + 'rg', + '15.1.0', + 'rg.exe', + 'https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-aarch64-pc-windows-msvc.zip', + '00d931fb5237c9696ca49308818edb76d8eb6fc132761cb2a1bd616b2df02f8e', + ), + }), + 'darwin-arm64': searchToolRuntime('darwin', 'arm64', { + fd: releaseAsset( + 'fd', + '10.4.2', + 'fd', + 'https://github.com/sharkdp/fd/releases/download/v10.4.2/fd-v10.4.2-aarch64-apple-darwin.tar.gz', + '623dc0afc81b92e4d4606b380d7bc91916ba7b97814263e554d50923a39e480a', + ), + rg: releaseAsset( + 'rg', + '15.1.0', + 'rg', + 'https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-aarch64-apple-darwin.tar.gz', + '378e973289176ca0c6054054ee7f631a065874a352bf43f0fa60ef079b6ba715', + ), + }), + 'darwin-x64': searchToolRuntime('darwin', 'x64', { + // fd 10.4.x no longer publishes an Intel macOS asset. + fd: releaseAsset( + 'fd', + '10.3.0', + 'fd', + 'https://github.com/sharkdp/fd/releases/download/v10.3.0/fd-v10.3.0-x86_64-apple-darwin.tar.gz', + '50d30f13fe3d5914b14c4fff5abcbd4d0cdab4b855970a6956f4f006c17117a3', + ), + rg: releaseAsset( + 'rg', + '15.1.0', + 'rg', + 'https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-x86_64-apple-darwin.tar.gz', + '64811cb24e77cac3057d6c40b63ac9becf9082eedd54ca411b475b755d334882', + ), + }), +} + +function releaseAsset(id, version, binaryName, url, sha256) { + return { id, version, binaryName, url, sha256 } +} + +function searchToolRuntime(platform, arch, tools) { + const platformArch = `${platform}-${arch}` + return { + platform, + arch, + platformArch, + root: `vendor/tools/${platformArch}`, + binPath: 'bin', + tools, + } +} + function printHelp() { console.log(`Usage: pnpm vendor:runtime [options] @@ -70,8 +154,9 @@ async function main() { parseArgs(process.argv.slice(2)) await mkdir(vendorRoot, { recursive: true }) await vendorPi() + const searchToolsSpec = await vendorManagedSearchTools() const gitSpec = await vendorWindowsGit() - await writeManifest(gitSpec) + await writeManifest(gitSpec, searchToolsSpec) } function parseArgs(argv) { @@ -167,11 +252,101 @@ async function vendorWindowsGit() { return spec } -async function download(url) { - console.log(`[vendor-runtime] download ${url}`) - const res = await fetch(url) - if (!res.ok) throw new Error(`${url} returned HTTP ${res.status}`) - return Buffer.from(await res.arrayBuffer()) +async function vendorManagedSearchTools() { + const spec = resolveManagedSearchToolsSpec() + if (!spec) { + console.log(`[vendor-runtime] managed fd/rg skipped on unsupported ${process.platform}-${process.arch} host`) + return null + } + + const existingManifest = readManifest() + const existing = existingManifest?.searchTools?.[spec.platformArch] + if ( + !force && + Object.values(spec.tools).every((tool) => ( + existing?.[tool.id]?.version === tool.version && existing?.[tool.id]?.sha256 === tool.sha256 + )) && + requiredManagedSearchToolFiles(spec).every((file) => existsSync(resolve(repoRoot, spec.root, file))) + ) { + console.log(`[vendor-runtime] managed fd/rg already present at ${spec.root}`) + return spec + } + + const root = resolve(repoRoot, spec.root) + const binDir = resolve(root, spec.binPath) + console.log(`[vendor-runtime] preparing managed fd/rg at ${relativeForLog(root)}`) + await rm(root, { recursive: true, force: true }) + await mkdir(binDir, { recursive: true }) + + for (const tool of Object.values(spec.tools)) { + const bytes = await download(tool.url) + verifySha256(bytes, tool.sha256, tool.url) + const tmpRoot = await mkdtemp(resolve(tmpdir(), `openalice-${tool.id}-`)) + const archivePath = resolve(tmpRoot, basename(tool.url)) + try { + await writeFile(archivePath, bytes) + if (archivePath.endsWith('.tar.gz')) { + run(`extract ${tool.id}`, 'tar', ['-xzf', archivePath, '-C', tmpRoot]) + } else if (archivePath.endsWith('.zip')) { + run(`extract ${tool.id}`, windowsTarCommand(), ['-xf', archivePath, '-C', tmpRoot]) + } else { + throw new Error(`unsupported managed tool archive: ${archivePath}`) + } + const extracted = findFileRecursively(tmpRoot, tool.binaryName, new Set([archivePath])) + if (!extracted) throw new Error(`${tool.binaryName} missing after extracting ${tool.url}`) + const destination = resolve(binDir, tool.binaryName) + await copyFile(extracted, destination) + if (spec.platform !== 'win32') await chmod(destination, 0o755) + } finally { + await rm(tmpRoot, { recursive: true, force: true }) + } + } + + const missing = requiredManagedSearchToolFiles(spec) + .filter((file) => !existsSync(resolve(repoRoot, spec.root, file))) + if (missing.length > 0) { + throw new Error(`managed fd/rg extraction missing required files: ${missing.join(', ')}`) + } + console.log(`[vendor-runtime] managed fd/rg -> ${relativeForLog(binDir)}`) + return spec +} + +function windowsTarCommand() { + const systemRoot = process.env['SystemRoot'] ?? process.env['WINDIR'] + const systemTar = systemRoot ? resolve(systemRoot, 'System32', 'tar.exe') : null + return systemTar && existsSync(systemTar) ? systemTar : 'tar.exe' +} + +function findFileRecursively(root, fileName, excluded = new Set()) { + for (const entry of readdirSync(root, { withFileTypes: true })) { + const path = resolve(root, entry.name) + if (excluded.has(path)) continue + if (entry.isFile() && entry.name === fileName) return path + if (entry.isDirectory()) { + const nested = findFileRecursively(path, fileName, excluded) + if (nested) return nested + } + } + return null +} + +async function download(url, attempts = 3) { + let lastError = null + for (let attempt = 1; attempt <= attempts; attempt += 1) { + console.log(`[vendor-runtime] download ${url}${attempt > 1 ? ` (attempt ${attempt}/${attempts})` : ''}`) + try { + const res = await fetch(url) + if (!res.ok) throw new Error(`${url} returned HTTP ${res.status}`) + return Buffer.from(await res.arrayBuffer()) + } catch (err) { + lastError = err + if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, attempt * 1_000)) + } + } + const detail = lastError instanceof Error + ? `${lastError.message}${lastError.cause instanceof Error ? `: ${lastError.cause.message}` : ''}` + : String(lastError) + throw new Error(`failed to download ${url} after ${attempts} attempts: ${detail}`) } function verifySha256(bytes, expected, label) { @@ -190,9 +365,11 @@ function run(label, command, commandArgs, opts = {}) { shell: opts.shell ?? false, }) if (result.error) { - console.error(`[vendor-runtime] failed to start ${command}: ${result.error.message}`) + throw new Error(`${label} failed to start ${command}: ${result.error.message}`) + } + if (result.status !== 0) { + throw new Error(`${label} exited ${result.status ?? 'unknown'}${result.signal ? ` (${result.signal})` : ''}`) } - if (result.status !== 0 || result.error) process.exit(result.status ?? 1) } function readManifest() { @@ -203,7 +380,7 @@ function readManifest() { } } -export function buildVendorRuntimeManifest(gitSpec = null) { +export function buildVendorRuntimeManifest(gitSpec = null, searchToolsSpec = null) { const manifest = { pi: { version: PI_VERSION, @@ -213,6 +390,23 @@ export function buildVendorRuntimeManifest(gitSpec = null) { node: 'electron', }, } + if (searchToolsSpec) { + manifest.searchTools = { + [searchToolsSpec.platformArch]: { + path: searchToolsSpec.root, + binPath: searchToolsSpec.binPath, + ...Object.fromEntries(Object.values(searchToolsSpec.tools).map((tool) => [ + tool.id, + { + version: tool.version, + binary: `${searchToolsSpec.binPath}/${tool.binaryName}`, + url: tool.url, + sha256: tool.sha256, + }, + ])), + }, + } + } if (gitSpec) { manifest.git = { [gitSpec.platformArch]: { @@ -231,12 +425,22 @@ export function buildVendorRuntimeManifest(gitSpec = null) { return manifest } -async function writeManifest(gitSpec) { - const manifest = buildVendorRuntimeManifest(gitSpec) +async function writeManifest(gitSpec, searchToolsSpec) { + const manifest = buildVendorRuntimeManifest(gitSpec, searchToolsSpec) await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n') console.log(`[vendor-runtime] manifest -> ${relativeForLog(manifestPath)}`) } +export function resolveManagedSearchToolsSpec(opts = {}) { + const platform = opts.platform ?? process.platform + const arch = opts.arch ?? process.arch + return MANAGED_SEARCH_TOOL_RUNTIMES[`${platform}-${arch}`] ?? null +} + +export function requiredManagedSearchToolFiles(spec) { + return Object.values(spec.tools).map((tool) => `${spec.binPath}/${tool.binaryName}`) +} + export function resolveWindowsGitRuntimeSpec(opts = {}) { const platform = opts.platform ?? process.platform const arch = opts.arch ?? process.arch diff --git a/scripts/vendor-managed-runtime.spec.ts b/scripts/vendor-managed-runtime.spec.ts index 2b09d506e..5cdfc91ba 100644 --- a/scripts/vendor-managed-runtime.spec.ts +++ b/scripts/vendor-managed-runtime.spec.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest' import { buildVendorRuntimeManifest, + requiredManagedSearchToolFiles, requiredWindowsGitFiles, + resolveManagedSearchToolsSpec, resolveWindowsGitRuntimeSpec, } from './vendor-managed-runtime.mjs' @@ -11,6 +13,27 @@ describe('vendor managed runtime helpers', () => { expect(buildVendorRuntimeManifest(null).pi.version).toBe('0.80.6') }) + it('pins fd and ripgrep for every supported desktop architecture', () => { + const win = resolveManagedSearchToolsSpec({ platform: 'win32', arch: 'x64' }) + expect(win).toMatchObject({ + platformArch: 'win32-x64', + root: 'vendor/tools/win32-x64', + binPath: 'bin', + tools: { + fd: { version: '10.4.2', binaryName: 'fd.exe' }, + rg: { version: '15.1.0', binaryName: 'rg.exe' }, + }, + }) + expect(requiredManagedSearchToolFiles(win!)).toEqual(['bin/fd.exe', 'bin/rg.exe']) + + const intelMac = resolveManagedSearchToolsSpec({ platform: 'darwin', arch: 'x64' }) + expect(intelMac?.tools.fd.version).toBe('10.3.0') + expect(intelMac?.tools.rg.version).toBe('15.1.0') + expect(requiredManagedSearchToolFiles(intelMac!)).toEqual(['bin/fd', 'bin/rg']) + + expect(resolveManagedSearchToolsSpec({ platform: 'linux', arch: 'x64' })).toBeNull() + }) + it('does not select a managed Git runtime on non-Windows hosts', () => { expect(resolveWindowsGitRuntimeSpec({ platform: 'darwin', arch: 'arm64' })).toBeNull() expect(resolveWindowsGitRuntimeSpec({ platform: 'linux', arch: 'x64' })).toBeNull() @@ -50,4 +73,16 @@ describe('vendor managed runtime helpers', () => { shPath: 'bin/sh.exe', }) }) + + it('writes managed search tool metadata for the selected package architecture', () => { + const tools = resolveManagedSearchToolsSpec({ platform: 'darwin', arch: 'arm64' }) + const manifest = buildVendorRuntimeManifest(null, tools) + + expect(manifest.searchTools['darwin-arm64']).toMatchObject({ + path: 'vendor/tools/darwin-arm64', + binPath: 'bin', + fd: { version: '10.4.2', binary: 'bin/fd' }, + rg: { version: '15.1.0', binary: 'bin/rg' }, + }) + }) })