diff --git a/package-lock.json b/package-lock.json index a703b9fe..132fc713 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3949,6 +3949,16 @@ "@types/node": "*" } }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript/native-preview": { "version": "7.0.0-dev.20260120.1", "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20260120.1.tgz", @@ -4436,6 +4446,15 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -4932,7 +4951,6 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -5176,6 +5194,41 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extract-zip/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5243,6 +5296,15 @@ "reusify": "^1.0.4" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -6643,7 +6705,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -6859,6 +6920,12 @@ "@napi-rs/canvas": "^0.1.81" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, "node_modules/pi-extension-custom-provider-anthropic": { "resolved": "packages/coding-agent/examples/extensions/custom-provider-anthropic", "link": true @@ -7042,7 +7109,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "dev": true, "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -8253,7 +8319,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/ws": { @@ -8340,6 +8405,16 @@ "node": ">=10" } }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yoctocolors": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", @@ -8465,6 +8540,7 @@ "chalk": "^5.5.0", "cli-highlight": "^2.1.11", "diff": "^8.0.2", + "extract-zip": "^2.0.1", "file-type": "^21.1.1", "glob": "^13.0.1", "hosted-git-info": "^9.0.2", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 28fa5965..495fdeff 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,7 @@ - Fixed offline startup hangs by adding offline startup behavior and network timeouts during managed tool setup ([#1631](https://github.com/badlogic/pi-mono/pull/1631) by [@mcollina](https://github.com/mcollina)) - Fixed Windows VT input initialization in ESM by loading koffi via createRequire, avoiding runtime and bundling issues in end-user environments ([#1627](https://github.com/badlogic/pi-mono/pull/1627) by [@kaste](https://github.com/kaste)) +- Fixed managed `fd`/`rg` bootstrap on Windows in Git Bash by using `extract-zip` for `.zip` archives, searching extracted layouts more robustly, and isolating extraction temp directories to avoid concurrent download races ([#1348](https://github.com/badlogic/pi-mono/issues/1348)) ## [0.55.0] - 2026-02-24 diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 0c8fd271..1f176e38 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -47,6 +47,7 @@ "chalk": "^5.5.0", "cli-highlight": "^2.1.11", "diff": "^8.0.2", + "extract-zip": "^2.0.1", "file-type": "^21.1.1", "glob": "^13.0.1", "hosted-git-info": "^9.0.2", diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts index 4aebd770..f51e042b 100644 --- a/packages/coding-agent/src/utils/tools-manager.ts +++ b/packages/coding-agent/src/utils/tools-manager.ts @@ -1,6 +1,7 @@ import chalk from "chalk"; import { spawnSync } from "child_process"; -import { chmodSync, createWriteStream, existsSync, mkdirSync, renameSync, rmSync } from "fs"; +import extractZip from "extract-zip"; +import { chmodSync, createWriteStream, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "fs"; import { arch, platform } from "os"; import { join } from "path"; import { Readable } from "stream"; @@ -130,6 +131,28 @@ async function downloadFile(url: string, dest: string): Promise { await finished(Readable.fromWeb(response.body as any).pipe(fileStream)); } +function findBinaryRecursively(rootDir: string, binaryFileName: string): string | null { + const stack: string[] = [rootDir]; + + while (stack.length > 0) { + const currentDir = stack.pop(); + if (!currentDir) continue; + + const entries = readdirSync(currentDir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(currentDir, entry.name); + if (entry.isFile() && entry.name === binaryFileName) { + return fullPath; + } + if (entry.isDirectory()) { + stack.push(fullPath); + } + } + } + + return null; +} + // Download and install a tool async function downloadTool(tool: "fd" | "rg"): Promise { const config = TOOLS[tool]; @@ -158,33 +181,42 @@ async function downloadTool(tool: "fd" | "rg"): Promise { // Download await downloadFile(downloadUrl, archivePath); - // Extract - const extractDir = join(TOOLS_DIR, "extract_tmp"); + // Extract into a unique temp directory. fd and rg downloads can run concurrently + // during startup, so sharing a fixed directory causes races. + const extractDir = join( + TOOLS_DIR, + `extract_tmp_${config.binaryName}_${process.pid}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`, + ); mkdirSync(extractDir, { recursive: true }); try { - // Use tar for both .tar.gz and .zip extraction. Windows 10+ ships bsdtar - // which handles both formats, avoiding the need for `unzip` (not available - // on Windows by default). - const extractResult = assetName.endsWith(".tar.gz") - ? spawnSync("tar", ["xzf", archivePath, "-C", extractDir], { stdio: "pipe" }) - : assetName.endsWith(".zip") - ? spawnSync("tar", ["xf", archivePath, "-C", extractDir], { stdio: "pipe" }) - : null; - - if (!extractResult || extractResult.error || extractResult.status !== 0) { - const errMsg = extractResult?.error?.message ?? extractResult?.stderr?.toString().trim() ?? "unknown error"; - throw new Error(`Failed to extract ${assetName}: ${errMsg}`); + if (assetName.endsWith(".tar.gz")) { + const extractResult = spawnSync("tar", ["xzf", archivePath, "-C", extractDir], { stdio: "pipe" }); + if (extractResult.error || extractResult.status !== 0) { + const errMsg = extractResult.error?.message ?? extractResult.stderr?.toString().trim() ?? "unknown error"; + throw new Error(`Failed to extract ${assetName}: ${errMsg}`); + } + } else if (assetName.endsWith(".zip")) { + await extractZip(archivePath, { dir: extractDir }); + } else { + throw new Error(`Unsupported archive format: ${assetName}`); } - // Find the binary in extracted files + // Find the binary in extracted files. Some archives contain files directly + // at root, others nest under a versioned subdirectory. + const binaryFileName = config.binaryName + binaryExt; const extractedDir = join(extractDir, assetName.replace(/\.(tar\.gz|zip)$/, "")); - const extractedBinary = join(extractedDir, config.binaryName + binaryExt); + const extractedBinaryCandidates = [join(extractedDir, binaryFileName), join(extractDir, binaryFileName)]; + let extractedBinary = extractedBinaryCandidates.find((candidate) => existsSync(candidate)); - if (existsSync(extractedBinary)) { + if (!extractedBinary) { + extractedBinary = findBinaryRecursively(extractDir, binaryFileName) ?? undefined; + } + + if (extractedBinary) { renameSync(extractedBinary, binaryPath); } else { - throw new Error(`Binary not found in archive: ${extractedBinary}`); + throw new Error(`Binary not found in archive: expected ${binaryFileName} under ${extractDir}`); } // Make executable (Unix only)