From d50ae8e4d4d6fd762c620e4d6d1a4edc6abd0585 Mon Sep 17 00:00:00 2001 From: Clay Warren <36519094+ClayWarren@users.noreply.github.com> Date: Thu, 24 Jul 2025 15:49:04 -0500 Subject: [PATCH] feat: Replace unzip with @zip.js/zip.js for Windows compatibility (#662) --- bun.lock | 4 + packages/opencode/package.json | 2 + packages/opencode/src/file/fzf.ts | 50 ++++++++--- packages/opencode/src/file/ripgrep.ts | 122 ++++++++++++++++---------- 4 files changed, 119 insertions(+), 59 deletions(-) diff --git a/bun.lock b/bun.lock index 9ca00e26..6ea2b02c 100644 --- a/bun.lock +++ b/bun.lock @@ -33,6 +33,8 @@ "@hono/zod-validator": "0.4.2", "@modelcontextprotocol/sdk": "1.15.1", "@openauthjs/openauth": "0.4.3", + "@standard-schema/spec": "1.0.0", + "@zip.js/zip.js": "2.7.62", "ai": "catalog:", "decimal.js": "10.5.0", "diff": "8.0.2", @@ -749,6 +751,8 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + "@zip.js/zip.js": ["@zip.js/zip.js@2.7.62", "", {}, "sha512-OaLvZ8j4gCkLn048ypkZu29KX30r8/OfFF2w4Jo5WXFr+J04J+lzJ5TKZBVgFXhlvSkqNFQdfnY1Q8TMTCyBVA=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 8541e018..f278c636 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -31,6 +31,8 @@ "@hono/zod-validator": "0.4.2", "@modelcontextprotocol/sdk": "1.15.1", "@openauthjs/openauth": "0.4.3", + "@standard-schema/spec": "1.0.0", + "@zip.js/zip.js": "2.7.62", "ai": "catalog:", "decimal.js": "10.5.0", "diff": "8.0.2", diff --git a/packages/opencode/src/file/fzf.ts b/packages/opencode/src/file/fzf.ts index 1376af8c..7a481b0f 100644 --- a/packages/opencode/src/file/fzf.ts +++ b/packages/opencode/src/file/fzf.ts @@ -5,6 +5,7 @@ import { z } from "zod" import { NamedError } from "../util/error" import { lazy } from "../util/lazy" import { Log } from "../util/log" +import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js" export namespace Fzf { const log = Log.create({ service: "fzf" }) @@ -45,7 +46,10 @@ export namespace Fzf { log.info("found", { filepath }) return { filepath } } - filepath = path.join(Global.Path.bin, "fzf" + (process.platform === "win32" ? ".exe" : "")) + filepath = path.join( + Global.Path.bin, + "fzf" + (process.platform === "win32" ? ".exe" : ""), + ) const file = Bun.file(filepath) if (!(await file.exists())) { @@ -53,15 +57,18 @@ export namespace Fzf { const arch = archMap[process.arch as keyof typeof archMap] ?? "amd64" const config = PLATFORM[process.platform as keyof typeof PLATFORM] - if (!config) throw new UnsupportedPlatformError({ platform: process.platform }) + if (!config) + throw new UnsupportedPlatformError({ platform: process.platform }) const version = VERSION - const platformName = process.platform === "win32" ? "windows" : process.platform + const platformName = + process.platform === "win32" ? "windows" : process.platform const filename = `fzf-${version}-${platformName}_${arch}.${config.extension}` const url = `https://github.com/junegunn/fzf/releases/download/v${version}/${filename}` const response = await fetch(url) - if (!response.ok) throw new DownloadFailedError({ url, status: response.status }) + if (!response.ok) + throw new DownloadFailedError({ url, status: response.status }) const buffer = await response.arrayBuffer() const archivePath = path.join(Global.Path.bin, filename) @@ -80,17 +87,32 @@ export namespace Fzf { }) } if (config.extension === "zip") { - const proc = Bun.spawn(["unzip", "-j", archivePath, "fzf.exe", "-d", Global.Path.bin], { - cwd: Global.Path.bin, - stderr: "pipe", - stdout: "ignore", - }) - await proc.exited - if (proc.exitCode !== 0) + const zipFileReader = new ZipReader(new BlobReader(new Blob([await Bun.file(archivePath).arrayBuffer()]))); + const entries = await zipFileReader.getEntries(); + let fzfEntry: any; + for (const entry of entries) { + if (entry.filename === "fzf.exe") { + fzfEntry = entry; + break; + } + } + + if (!fzfEntry) { throw new ExtractionFailedError({ filepath: archivePath, - stderr: await Bun.readableStreamToText(proc.stderr), - }) + stderr: "fzf.exe not found in zip archive", + }); + } + + const fzfBlob = await fzfEntry.getData(new BlobWriter()); + if (!fzfBlob) { + throw new ExtractionFailedError({ + filepath: archivePath, + stderr: "Failed to extract fzf.exe from zip archive", + }); + } + await Bun.write(filepath, await fzfBlob.arrayBuffer()); + await zipFileReader.close(); } await fs.unlink(archivePath) if (process.platform !== "win32") await fs.chmod(filepath, 0o755) @@ -105,4 +127,4 @@ export namespace Fzf { const { filepath } = await state() return filepath } -} +} \ No newline at end of file diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 05ebbe7d..a802b887 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -7,6 +7,7 @@ import { NamedError } from "../util/error" import { lazy } from "../util/lazy" import { $ } from "bun" import { Fzf } from "./fzf" +import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js" export namespace Ripgrep { const Stats = z.object({ @@ -34,27 +35,25 @@ export namespace Ripgrep { export const Match = z.object({ type: z.literal("match"), - data: z - .object({ - path: z.object({ - text: z.string(), - }), - lines: z.object({ - text: z.string(), - }), - line_number: z.number(), - absolute_offset: z.number(), - submatches: z.array( - z.object({ - match: z.object({ - text: z.string(), - }), - start: z.number(), - end: z.number(), + data: z.object({ + path: z.object({ + text: z.string(), + }), + lines: z.object({ + text: z.string(), + }), + line_number: z.number(), + absolute_offset: z.number(), + submatches: z.array( + z.object({ + match: z.object({ + text: z.string(), }), - ), - }) - .openapi({ ref: "Match" }), + start: z.number(), + end: z.number(), + }), + ), + }), }) const End = z.object({ @@ -124,11 +123,15 @@ export namespace Ripgrep { const state = lazy(async () => { let filepath = Bun.which("rg") if (filepath) return { filepath } - filepath = path.join(Global.Path.bin, "rg" + (process.platform === "win32" ? ".exe" : "")) + filepath = path.join( + Global.Path.bin, + "rg" + (process.platform === "win32" ? ".exe" : ""), + ) const file = Bun.file(filepath) if (!(await file.exists())) { - const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM + const platformKey = + `${process.arch}-${process.platform}` as keyof typeof PLATFORM const config = PLATFORM[platformKey] if (!config) throw new UnsupportedPlatformError({ platform: platformKey }) @@ -137,7 +140,8 @@ export namespace Ripgrep { const url = `https://github.com/BurntSushi/ripgrep/releases/download/${version}/${filename}` const response = await fetch(url) - if (!response.ok) throw new DownloadFailedError({ url, status: response.status }) + if (!response.ok) + throw new DownloadFailedError({ url, status: response.status }) const buffer = await response.arrayBuffer() const archivePath = path.join(Global.Path.bin, filename) @@ -161,17 +165,34 @@ export namespace Ripgrep { }) } if (config.extension === "zip") { - const proc = Bun.spawn(["unzip", "-j", archivePath, "*/rg.exe", "-d", Global.Path.bin], { - cwd: Global.Path.bin, - stderr: "pipe", - stdout: "ignore", - }) - await proc.exited - if (proc.exitCode !== 0) + if (config.extension === "zip") { + const zipFileReader = new ZipReader(new BlobReader(new Blob([await Bun.file(archivePath).arrayBuffer()]))); + const entries = await zipFileReader.getEntries(); + let rgEntry: any; + for (const entry of entries) { + if (entry.filename.endsWith("rg.exe")) { + rgEntry = entry; + break; + } + } + + if (!rgEntry) { throw new ExtractionFailedError({ filepath: archivePath, - stderr: await Bun.readableStreamToText(proc.stderr), - }) + stderr: "rg.exe not found in zip archive", + }); + } + + const rgBlob = await rgEntry.getData(new BlobWriter()); + if (!rgBlob) { + throw new ExtractionFailedError({ + filepath: archivePath, + stderr: "Failed to extract rg.exe from zip archive", + }); + } + await Bun.write(filepath, await rgBlob.arrayBuffer()); + await zipFileReader.close(); + } } await fs.unlink(archivePath) if (!platformKey.endsWith("-win32")) await fs.chmod(filepath, 0o755) @@ -187,16 +208,17 @@ export namespace Ripgrep { return filepath } - export async function files(input: { cwd: string; query?: string; glob?: string[]; limit?: number }) { - const commands = [`${$.escape(await filepath())} --files --follow --hidden --glob='!.git/*'`] - - if (input.glob) { - for (const g of input.glob) { - commands[0] += ` --glob='${g}'` - } - } - - if (input.query) commands.push(`${await Fzf.filepath()} --filter=${input.query}`) + export async function files(input: { + cwd: string + query?: string + glob?: string + limit?: number + }) { + const commands = [ + `${await filepath()} --files --hidden --glob='!.git/*' ${input.glob ? `--glob='${input.glob}'` : ``}`, + ] + if (input.query) + commands.push(`${await Fzf.filepath()} --filter=${input.query}`) if (input.limit) commands.push(`head -n ${input.limit}`) const joined = commands.join(" | ") const result = await $`${{ raw: joined }}`.cwd(input.cwd).nothrow().text() @@ -303,8 +325,18 @@ export namespace Ripgrep { return lines.join("\n") } - export async function search(input: { cwd: string; pattern: string; glob?: string[]; limit?: number }) { - const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"] + export async function search(input: { + cwd: string + pattern: string + glob?: string[] + limit?: number + }) { + const args = [ + `${await filepath()}`, + "--json", + "--hidden", + "--glob='!.git/*'", + ] if (input.glob) { for (const g of input.glob) { @@ -333,4 +365,4 @@ export namespace Ripgrep { .filter((r) => r.type === "match") .map((r) => r.data) } -} +} \ No newline at end of file