Просмотр исходного кода

feat: Replace unzip with @zip.js/zip.js for Windows compatibility (#662)

Clay Warren 7 месяцев назад
Родитель
Сommit
d50ae8e4d4
4 измененных файлов с 119 добавлено и 59 удалено
  1. 4 0
      bun.lock
  2. 2 0
      packages/opencode/package.json
  3. 36 14
      packages/opencode/src/file/fzf.ts
  4. 77 45
      packages/opencode/src/file/ripgrep.ts

+ 4 - 0
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/[email protected]", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
 
+    "@zip.js/zip.js": ["@zip.js/[email protected]", "", {}, "sha512-OaLvZ8j4gCkLn048ypkZu29KX30r8/OfFF2w4Jo5WXFr+J04J+lzJ5TKZBVgFXhlvSkqNFQdfnY1Q8TMTCyBVA=="],
+
     "accepts": ["[email protected]", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
 
     "acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],

+ 2 - 0
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",

+ 36 - 14
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
   }
-}
+}

+ 77 - 45
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)
   }
-}
+}