Преглед на файлове

refactor: migrate from Bun.Glob to npm glob package (#14317)

Dax преди 1 месец
родител
ревизия
cb8b74d3f1

+ 10 - 8
bun.lock

@@ -15,6 +15,7 @@
         "@actions/artifact": "5.0.1",
         "@tsconfig/bun": "catalog:",
         "@types/mime-types": "3.0.1",
+        "glob": "13.0.5",
         "husky": "9.1.7",
         "prettier": "3.6.2",
         "semver": "^7.6.0",
@@ -321,6 +322,7 @@
         "diff": "catalog:",
         "drizzle-orm": "1.0.0-beta.12-a5629fb",
         "fuzzysort": "3.1.0",
+        "glob": "13.0.5",
         "google-auth-library": "10.5.0",
         "gray-matter": "4.0.3",
         "hono": "catalog:",
@@ -2694,7 +2696,7 @@
 
     "github-slugger": ["[email protected]", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
 
-    "glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
+    "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
 
     "glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
 
@@ -3074,7 +3076,7 @@
 
     "lower-case": ["[email protected]", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg=="],
 
-    "lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
+    "lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
 
     "lru.min": ["[email protected]", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="],
 
@@ -4794,14 +4796,14 @@
 
     "openid-client/jose": ["[email protected]", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
 
+    "openid-client/lru-cache": ["[email protected]", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
+
     "p-locate/p-limit": ["[email protected]", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
 
     "parse-entities/@types/unist": ["@types/[email protected]", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
 
     "parse5/entities": ["[email protected]", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
 
-    "path-scurry/lru-cache": ["[email protected]", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
-
     "pixelmatch/pngjs": ["[email protected]", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
 
     "pkg-up/find-up": ["[email protected]", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="],
@@ -4874,10 +4876,10 @@
 
     "unifont/ofetch": ["[email protected]", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
 
-    "unstorage/lru-cache": ["[email protected]", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
-
     "utif2/pako": ["[email protected]", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
 
+    "vite-plugin-icons-spritesheet/glob": ["[email protected]", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
+
     "vitest/tinyexec": ["[email protected]", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
 
     "vitest/vite": ["[email protected]", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="],
@@ -5236,8 +5238,6 @@
 
     "astro/unstorage/h3": ["[email protected]", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.5", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg=="],
 
-    "astro/unstorage/lru-cache": ["[email protected]", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
-
     "astro/unstorage/ofetch": ["[email protected]", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
 
     "aws-sdk/xml2js/sax": ["[email protected]", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="],
@@ -5370,6 +5370,8 @@
 
     "type-is/mime-types/mime-db": ["[email protected]", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
 
+    "vite-plugin-icons-spritesheet/glob/minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A=="],
+
     "wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="],
 
     "wrangler/esbuild/@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="],

+ 1 - 0
package.json

@@ -70,6 +70,7 @@
     "@actions/artifact": "5.0.1",
     "@tsconfig/bun": "catalog:",
     "@types/mime-types": "3.0.1",
+    "glob": "13.0.5",
     "husky": "9.1.7",
     "prettier": "3.6.2",
     "semver": "^7.6.0",

+ 1 - 0
packages/opencode/package.json

@@ -107,6 +107,7 @@
     "diff": "catalog:",
     "drizzle-orm": "1.0.0-beta.12-a5629fb",
     "fuzzysort": "3.1.0",
+    "glob": "13.0.5",
     "google-auth-library": "10.5.0",
     "gray-matter": "4.0.3",
     "hono": "catalog:",

+ 4 - 4
packages/opencode/src/cli/cmd/tui/context/theme.tsx

@@ -3,6 +3,7 @@ import path from "path"
 import { createEffect, createMemo, onMount } from "solid-js"
 import { useSync } from "@tui/context/sync"
 import { createSimpleContext } from "./helper"
+import { Glob } from "../../../../util/glob"
 import aura from "./theme/aura.json" with { type: "json" }
 import ayu from "./theme/ayu.json" with { type: "json" }
 import catppuccin from "./theme/catppuccin.json" with { type: "json" }
@@ -391,7 +392,6 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
   },
 })
 
-const CUSTOM_THEME_GLOB = new Bun.Glob("themes/*.json")
 async function getCustomThemes() {
   const directories = [
     Global.Path.config,
@@ -405,11 +405,11 @@ async function getCustomThemes() {
 
   const result: Record<string, ThemeJson> = {}
   for (const dir of directories) {
-    for await (const item of CUSTOM_THEME_GLOB.scan({
+    for (const item of await Glob.scan("themes/*.json", {
+      cwd: dir,
       absolute: true,
-      followSymlinks: true,
       dot: true,
-      cwd: dir,
+      symlink: true,
     })) {
       const name = path.basename(item, ".json")
       result[name] = await Filesystem.readJson(item)

+ 13 - 16
packages/opencode/src/config/config.ts

@@ -28,6 +28,7 @@ import { constants, existsSync } from "fs"
 import { Bus } from "@/bus"
 import { GlobalBus } from "@/bus/global"
 import { Event } from "../server/event"
+import { Glob } from "../util/glob"
 import { PackageRegistry } from "@/bun/registry"
 import { proxied } from "@/util/proxied"
 import { iife } from "@/util/iife"
@@ -351,14 +352,13 @@ export namespace Config {
     return ext.length ? file.slice(0, -ext.length) : file
   }
 
-  const COMMAND_GLOB = new Bun.Glob("{command,commands}/**/*.md")
   async function loadCommand(dir: string) {
     const result: Record<string, Command> = {}
-    for await (const item of COMMAND_GLOB.scan({
+    for (const item of await Glob.scan("{command,commands}/**/*.md", {
+      cwd: dir,
       absolute: true,
-      followSymlinks: true,
       dot: true,
-      cwd: dir,
+      symlink: true,
     })) {
       const md = await ConfigMarkdown.parse(item).catch(async (err) => {
         const message = ConfigMarkdown.FrontmatterError.isInstance(err)
@@ -390,15 +390,14 @@ export namespace Config {
     return result
   }
 
-  const AGENT_GLOB = new Bun.Glob("{agent,agents}/**/*.md")
   async function loadAgent(dir: string) {
     const result: Record<string, Agent> = {}
 
-    for await (const item of AGENT_GLOB.scan({
+    for (const item of await Glob.scan("{agent,agents}/**/*.md", {
+      cwd: dir,
       absolute: true,
-      followSymlinks: true,
       dot: true,
-      cwd: dir,
+      symlink: true,
     })) {
       const md = await ConfigMarkdown.parse(item).catch(async (err) => {
         const message = ConfigMarkdown.FrontmatterError.isInstance(err)
@@ -430,14 +429,13 @@ export namespace Config {
     return result
   }
 
-  const MODE_GLOB = new Bun.Glob("{mode,modes}/*.md")
   async function loadMode(dir: string) {
     const result: Record<string, Agent> = {}
-    for await (const item of MODE_GLOB.scan({
+    for (const item of await Glob.scan("{mode,modes}/*.md", {
+      cwd: dir,
       absolute: true,
-      followSymlinks: true,
       dot: true,
-      cwd: dir,
+      symlink: true,
     })) {
       const md = await ConfigMarkdown.parse(item).catch(async (err) => {
         const message = ConfigMarkdown.FrontmatterError.isInstance(err)
@@ -467,15 +465,14 @@ export namespace Config {
     return result
   }
 
-  const PLUGIN_GLOB = new Bun.Glob("{plugin,plugins}/*.{ts,js}")
   async function loadPlugin(dir: string) {
     const plugins: string[] = []
 
-    for await (const item of PLUGIN_GLOB.scan({
+    for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", {
+      cwd: dir,
       absolute: true,
-      followSymlinks: true,
       dot: true,
-      cwd: dir,
+      symlink: true,
     })) {
       plugins.push(pathToFileURL(item).href)
     }

+ 7 - 8
packages/opencode/src/file/ignore.ts

@@ -1,4 +1,5 @@
 import { sep } from "node:path"
+import { Glob } from "../util/glob"
 
 export namespace FileIgnore {
   const FOLDERS = new Set([
@@ -53,19 +54,17 @@ export namespace FileIgnore {
     "**/.nyc_output/**",
   ]
 
-  const FILE_GLOBS = FILES.map((p) => new Bun.Glob(p))
-
   export const PATTERNS = [...FILES, ...FOLDERS]
 
   export function match(
     filepath: string,
     opts?: {
-      extra?: Bun.Glob[]
-      whitelist?: Bun.Glob[]
+      extra?: string[]
+      whitelist?: string[]
     },
   ) {
-    for (const glob of opts?.whitelist || []) {
-      if (glob.match(filepath)) return false
+    for (const pattern of opts?.whitelist || []) {
+      if (Glob.match(pattern, filepath)) return false
     }
 
     const parts = filepath.split(sep)
@@ -74,8 +73,8 @@ export namespace FileIgnore {
     }
 
     const extra = opts?.extra || []
-    for (const glob of [...FILE_GLOBS, ...extra]) {
-      if (glob.match(filepath)) return true
+    for (const pattern of [...FILES, ...extra]) {
+      if (Glob.match(pattern, filepath)) return true
     }
 
     return false

+ 6 - 10
packages/opencode/src/project/project.ts

@@ -13,6 +13,7 @@ import { iife } from "@/util/iife"
 import { GlobalBus } from "@/bus/global"
 import { existsSync } from "fs"
 import { git } from "../util/git"
+import { Glob } from "../util/glob"
 
 export namespace Project {
   const log = Log.create({ service: "project" })
@@ -262,16 +263,11 @@ export namespace Project {
     if (input.vcs !== "git") return
     if (input.icon?.override) return
     if (input.icon?.url) return
-    const glob = new Bun.Glob("**/{favicon}.{ico,png,svg,jpg,jpeg,webp}")
-    const matches = await Array.fromAsync(
-      glob.scan({
-        cwd: input.worktree,
-        absolute: true,
-        onlyFiles: true,
-        followSymlinks: false,
-        dot: false,
-      }),
-    )
+    const matches = await Glob.scan("**/favicon.{ico,png,svg,jpg,jpeg,webp}", {
+      cwd: input.worktree,
+      absolute: true,
+      include: "file",
+    })
     const shortest = matches.sort((a, b) => a.length - b.length)[0]
     if (!shortest) return
     const buffer = await Filesystem.readBytes(shortest)

+ 6 - 7
packages/opencode/src/session/instruction.ts

@@ -6,6 +6,7 @@ import { Config } from "../config/config"
 import { Instance } from "../project/instance"
 import { Flag } from "@/flag/flag"
 import { Log } from "../util/log"
+import { Glob } from "../util/glob"
 import type { MessageV2 } from "./message-v2"
 
 const log = Log.create({ service: "instruction" })
@@ -98,13 +99,11 @@ export namespace InstructionPrompt {
           instruction = path.join(os.homedir(), instruction.slice(2))
         }
         const matches = path.isAbsolute(instruction)
-          ? await Array.fromAsync(
-              new Bun.Glob(path.basename(instruction)).scan({
-                cwd: path.dirname(instruction),
-                absolute: true,
-                onlyFiles: true,
-              }),
-            ).catch(() => [])
+          ? await Glob.scan(path.basename(instruction), {
+              cwd: path.dirname(instruction),
+              absolute: true,
+              include: "file",
+            }).catch(() => [])
           : await resolveRelative(instruction)
         matches.forEach((p) => {
           paths.add(path.resolve(p))

+ 26 - 25
packages/opencode/src/skill/skill.ts

@@ -12,6 +12,7 @@ import { Flag } from "@/flag/flag"
 import { Bus } from "@/bus"
 import { Session } from "@/session"
 import { Discovery } from "./discovery"
+import { Glob } from "../util/glob"
 
 export namespace Skill {
   const log = Log.create({ service: "skill" })
@@ -44,10 +45,9 @@ export namespace Skill {
   // External skill directories to search for (project-level and global)
   // These follow the directory layout used by Claude Code and other agents.
   const EXTERNAL_DIRS = [".claude", ".agents"]
-  const EXTERNAL_SKILL_GLOB = new Bun.Glob("skills/**/SKILL.md")
-
-  const OPENCODE_SKILL_GLOB = new Bun.Glob("{skill,skills}/**/SKILL.md")
-  const SKILL_GLOB = new Bun.Glob("**/SKILL.md")
+  const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
+  const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
+  const SKILL_PATTERN = "**/SKILL.md"
 
   export const state = Instance.state(async () => {
     const skills: Record<string, Info> = {}
@@ -88,15 +88,13 @@ export namespace Skill {
     }
 
     const scanExternal = async (root: string, scope: "global" | "project") => {
-      return Array.fromAsync(
-        EXTERNAL_SKILL_GLOB.scan({
-          cwd: root,
-          absolute: true,
-          onlyFiles: true,
-          followSymlinks: true,
-          dot: true,
-        }),
-      )
+      return Glob.scan(EXTERNAL_SKILL_PATTERN, {
+        cwd: root,
+        absolute: true,
+        include: "file",
+        dot: true,
+        symlink: true,
+      })
         .then((matches) => Promise.all(matches.map(addSkill)))
         .catch((error) => {
           log.error(`failed to scan ${scope} skills`, { dir: root, error })
@@ -123,12 +121,13 @@ export namespace Skill {
 
     // Scan .opencode/skill/ directories
     for (const dir of await Config.directories()) {
-      for await (const match of OPENCODE_SKILL_GLOB.scan({
+      const matches = await Glob.scan(OPENCODE_SKILL_PATTERN, {
         cwd: dir,
         absolute: true,
-        onlyFiles: true,
-        followSymlinks: true,
-      })) {
+        include: "file",
+        symlink: true,
+      })
+      for (const match of matches) {
         await addSkill(match)
       }
     }
@@ -142,12 +141,13 @@ export namespace Skill {
         log.warn("skill path not found", { path: resolved })
         continue
       }
-      for await (const match of SKILL_GLOB.scan({
+      const matches = await Glob.scan(SKILL_PATTERN, {
         cwd: resolved,
         absolute: true,
-        onlyFiles: true,
-        followSymlinks: true,
-      })) {
+        include: "file",
+        symlink: true,
+      })
+      for (const match of matches) {
         await addSkill(match)
       }
     }
@@ -157,12 +157,13 @@ export namespace Skill {
       const list = await Discovery.pull(url)
       for (const dir of list) {
         dirs.add(dir)
-        for await (const match of SKILL_GLOB.scan({
+        const matches = await Glob.scan(SKILL_PATTERN, {
           cwd: dir,
           absolute: true,
-          onlyFiles: true,
-          followSymlinks: true,
-        })) {
+          include: "file",
+          symlink: true,
+        })
+        for (const match of matches) {
           await addSkill(match)
         }
       }

+ 2 - 6
packages/opencode/src/storage/json-migration.ts

@@ -8,6 +8,7 @@ import { SessionShareTable } from "../share/share.sql"
 import path from "path"
 import { existsSync } from "fs"
 import { Filesystem } from "../util/filesystem"
+import { Glob } from "../util/glob"
 
 export namespace JsonMigration {
   const log = Log.create({ service: "json-migration" })
@@ -71,12 +72,7 @@ export namespace JsonMigration {
     const now = Date.now()
 
     async function list(pattern: string) {
-      const items: string[] = []
-      const scan = new Bun.Glob(pattern)
-      for await (const file of scan.scan({ cwd: storageDir, absolute: true })) {
-        items.push(file)
-      }
-      return items
+      return Glob.scan(pattern, { cwd: storageDir, absolute: true })
     }
 
     async function read(files: string[], start: number, end: number) {

+ 19 - 20
packages/opencode/src/storage/storage.ts

@@ -8,6 +8,7 @@ import { Lock } from "../util/lock"
 import { $ } from "bun"
 import { NamedError } from "@opencode-ai/util/error"
 import z from "zod"
+import { Glob } from "../util/glob"
 
 export namespace Storage {
   const log = Log.create({ service: "storage" })
@@ -25,17 +26,20 @@ export namespace Storage {
     async (dir) => {
       const project = path.resolve(dir, "../project")
       if (!(await Filesystem.isDir(project))) return
-      for await (const projectDir of new Bun.Glob("*").scan({
+      const projectDirs = await Glob.scan("*", {
         cwd: project,
-        onlyFiles: false,
-      })) {
+        include: "all",
+      })
+      for (const projectDir of projectDirs) {
+        const fullPath = path.join(project, projectDir)
+        if (!(await Filesystem.isDir(fullPath))) continue
         log.info(`migrating project ${projectDir}`)
         let projectID = projectDir
         const fullProjectDir = path.join(project, projectDir)
         let worktree = "/"
 
         if (projectID !== "global") {
-          for await (const msgFile of new Bun.Glob("storage/session/message/*/*.json").scan({
+          for (const msgFile of await Glob.scan("storage/session/message/*/*.json", {
             cwd: path.join(project, projectDir),
             absolute: true,
           })) {
@@ -71,7 +75,7 @@ export namespace Storage {
           })
 
           log.info(`migrating sessions for project ${projectID}`)
-          for await (const sessionFile of new Bun.Glob("storage/session/info/*.json").scan({
+          for (const sessionFile of await Glob.scan("storage/session/info/*.json", {
             cwd: fullProjectDir,
             absolute: true,
           })) {
@@ -83,7 +87,7 @@ export namespace Storage {
             const session = await Filesystem.readJson<any>(sessionFile)
             await Filesystem.writeJson(dest, session)
             log.info(`migrating messages for session ${session.id}`)
-            for await (const msgFile of new Bun.Glob(`storage/session/message/${session.id}/*.json`).scan({
+            for (const msgFile of await Glob.scan(`storage/session/message/${session.id}/*.json`, {
               cwd: fullProjectDir,
               absolute: true,
             })) {
@@ -96,12 +100,10 @@ export namespace Storage {
               await Filesystem.writeJson(dest, message)
 
               log.info(`migrating parts for message ${message.id}`)
-              for await (const partFile of new Bun.Glob(`storage/session/part/${session.id}/${message.id}/*.json`).scan(
-                {
-                  cwd: fullProjectDir,
-                  absolute: true,
-                },
-              )) {
+              for (const partFile of await Glob.scan(`storage/session/part/${session.id}/${message.id}/*.json`, {
+                cwd: fullProjectDir,
+                absolute: true,
+              })) {
                 const dest = path.join(dir, "part", message.id, path.basename(partFile))
                 const part = await Filesystem.readJson(partFile)
                 log.info("copying", {
@@ -116,7 +118,7 @@ export namespace Storage {
       }
     },
     async (dir) => {
-      for await (const item of new Bun.Glob("session/*/*.json").scan({
+      for (const item of await Glob.scan("session/*/*.json", {
         cwd: dir,
         absolute: true,
       })) {
@@ -202,16 +204,13 @@ export namespace Storage {
     })
   }
 
-  const glob = new Bun.Glob("**/*")
   export async function list(prefix: string[]) {
     const dir = await state().then((x) => x.dir)
     try {
-      const result = await Array.fromAsync(
-        glob.scan({
-          cwd: path.join(dir, ...prefix),
-          onlyFiles: true,
-        }),
-      ).then((results) => results.map((x) => [...prefix, ...x.slice(0, -5).split(path.sep)]))
+      const result = await Glob.scan("**/*", {
+        cwd: path.join(dir, ...prefix),
+        include: "file",
+      }).then((results) => results.map((x) => [...prefix, ...x.slice(0, -5).split(path.sep)]))
       result.sort()
       return result
     } catch {

+ 4 - 2
packages/opencode/src/tool/registry.ts

@@ -27,16 +27,18 @@ import { LspTool } from "./lsp"
 import { Truncate } from "./truncation"
 import { PlanExitTool, PlanEnterTool } from "./plan"
 import { ApplyPatchTool } from "./apply_patch"
+import { Glob } from "../util/glob"
 
 export namespace ToolRegistry {
   const log = Log.create({ service: "tool.registry" })
 
   export const state = Instance.state(async () => {
     const custom = [] as Tool.Info[]
-    const glob = new Bun.Glob("{tool,tools}/*.{js,ts}")
 
     const matches = await Config.directories().then((dirs) =>
-      dirs.flatMap((dir) => [...glob.scanSync({ cwd: dir, absolute: true, followSymlinks: true, dot: true })]),
+      dirs.flatMap((dir) =>
+        Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
+      ),
     )
     if (matches.length) await Config.waitForDependencies()
     for (const match of matches) {

+ 2 - 2
packages/opencode/src/tool/truncation.ts

@@ -6,6 +6,7 @@ import { PermissionNext } from "../permission/next"
 import type { Agent } from "../agent/agent"
 import { Scheduler } from "../scheduler"
 import { Filesystem } from "../util/filesystem"
+import { Glob } from "../util/glob"
 
 export namespace Truncate {
   export const MAX_LINES = 2000
@@ -34,8 +35,7 @@ export namespace Truncate {
 
   export async function cleanup() {
     const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - RETENTION_MS))
-    const glob = new Bun.Glob("tool_*")
-    const entries = await Array.fromAsync(glob.scan({ cwd: DIR, onlyFiles: true })).catch(() => [] as string[])
+    const entries = await Glob.scan("tool_*", { cwd: DIR, include: "file" }).catch(() => [] as string[])
     for (const entry of entries) {
       if (Identifier.timestamp(entry) >= cutoff) continue
       await fs.unlink(path.join(DIR, entry)).catch(() => {})

+ 5 - 7
packages/opencode/src/util/filesystem.ts

@@ -5,6 +5,7 @@ import { realpathSync } from "fs"
 import { dirname, join, relative } from "path"
 import { Readable } from "stream"
 import { pipeline } from "stream/promises"
+import { Glob } from "./glob"
 
 export namespace Filesystem {
   // Fast sync version for metadata checks
@@ -156,16 +157,13 @@ export namespace Filesystem {
     const result = []
     while (true) {
       try {
-        const glob = new Bun.Glob(pattern)
-        for await (const match of glob.scan({
+        const matches = await Glob.scan(pattern, {
           cwd: current,
           absolute: true,
-          onlyFiles: true,
-          followSymlinks: true,
+          include: "file",
           dot: true,
-        })) {
-          result.push(match)
-        }
+        })
+        result.push(...matches)
       } catch {
         // Skip invalid glob patterns
       }

+ 34 - 0
packages/opencode/src/util/glob.ts

@@ -0,0 +1,34 @@
+import { glob, globSync, type GlobOptions } from "glob"
+import { minimatch } from "minimatch"
+
+export namespace Glob {
+  export interface Options {
+    cwd?: string
+    absolute?: boolean
+    include?: "file" | "all"
+    dot?: boolean
+    symlink?: boolean
+  }
+
+  function toGlobOptions(options: Options): GlobOptions {
+    return {
+      cwd: options.cwd,
+      absolute: options.absolute,
+      dot: options.dot,
+      follow: options.symlink ?? false,
+      nodir: options.include !== "all",
+    }
+  }
+
+  export async function scan(pattern: string, options: Options = {}): Promise<string[]> {
+    return glob(pattern, toGlobOptions(options)) as Promise<string[]>
+  }
+
+  export function scanSync(pattern: string, options: Options = {}): string[] {
+    return globSync(pattern, toGlobOptions(options)) as string[]
+  }
+
+  export function match(pattern: string, filepath: string): boolean {
+    return minimatch(filepath, pattern, { dot: true })
+  }
+}

+ 6 - 7
packages/opencode/src/util/log.ts

@@ -3,6 +3,7 @@ import fs from "fs/promises"
 import { createWriteStream } from "fs"
 import { Global } from "../global"
 import z from "zod"
+import { Glob } from "./glob"
 
 export namespace Log {
   export const Level = z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).meta({ ref: "LogLevel", description: "Log level" })
@@ -77,13 +78,11 @@ export namespace Log {
   }
 
   async function cleanup(dir: string) {
-    const glob = new Bun.Glob("????-??-??T??????.log")
-    const files = await Array.fromAsync(
-      glob.scan({
-        cwd: dir,
-        absolute: true,
-      }),
-    )
+    const files = await Glob.scan("????-??-??T??????.log", {
+      cwd: dir,
+      absolute: true,
+      include: "file",
+    })
     if (files.length <= 5) return
 
     const filesToDelete = files.slice(0, -10)

+ 164 - 0
packages/opencode/test/util/glob.test.ts

@@ -0,0 +1,164 @@
+import { describe, test, expect } from "bun:test"
+import path from "path"
+import fs from "fs/promises"
+import { Glob } from "../../src/util/glob"
+import { tmpdir } from "../fixture/fixture"
+
+describe("Glob", () => {
+  describe("scan()", () => {
+    test("finds files matching pattern", async () => {
+      await using tmp = await tmpdir()
+      await fs.writeFile(path.join(tmp.path, "a.txt"), "", "utf-8")
+      await fs.writeFile(path.join(tmp.path, "b.txt"), "", "utf-8")
+      await fs.writeFile(path.join(tmp.path, "c.md"), "", "utf-8")
+
+      const results = await Glob.scan("*.txt", { cwd: tmp.path })
+
+      expect(results.sort()).toEqual(["a.txt", "b.txt"])
+    })
+
+    test("returns absolute paths when absolute option is true", async () => {
+      await using tmp = await tmpdir()
+      await fs.writeFile(path.join(tmp.path, "file.txt"), "", "utf-8")
+
+      const results = await Glob.scan("*.txt", { cwd: tmp.path, absolute: true })
+
+      expect(results[0]).toBe(path.join(tmp.path, "file.txt"))
+    })
+
+    test("excludes directories by default", async () => {
+      await using tmp = await tmpdir()
+      await fs.mkdir(path.join(tmp.path, "subdir"))
+      await fs.writeFile(path.join(tmp.path, "file.txt"), "", "utf-8")
+
+      const results = await Glob.scan("*", { cwd: tmp.path })
+
+      expect(results).toEqual(["file.txt"])
+    })
+
+    test("excludes directories when include is 'file'", async () => {
+      await using tmp = await tmpdir()
+      await fs.mkdir(path.join(tmp.path, "subdir"))
+      await fs.writeFile(path.join(tmp.path, "file.txt"), "", "utf-8")
+
+      const results = await Glob.scan("*", { cwd: tmp.path, include: "file" })
+
+      expect(results).toEqual(["file.txt"])
+    })
+
+    test("includes directories when include is 'all'", async () => {
+      await using tmp = await tmpdir()
+      await fs.mkdir(path.join(tmp.path, "subdir"))
+      await fs.writeFile(path.join(tmp.path, "file.txt"), "", "utf-8")
+
+      const results = await Glob.scan("*", { cwd: tmp.path, include: "all" })
+
+      expect(results.sort()).toEqual(["file.txt", "subdir"])
+    })
+
+    test("handles nested patterns", async () => {
+      await using tmp = await tmpdir()
+      await fs.mkdir(path.join(tmp.path, "nested"), { recursive: true })
+      await fs.writeFile(path.join(tmp.path, "nested", "deep.txt"), "", "utf-8")
+
+      const results = await Glob.scan("**/*.txt", { cwd: tmp.path })
+
+      expect(results).toEqual(["nested/deep.txt"])
+    })
+
+    test("returns empty array for no matches", async () => {
+      await using tmp = await tmpdir()
+
+      const results = await Glob.scan("*.nonexistent", { cwd: tmp.path })
+
+      expect(results).toEqual([])
+    })
+
+    test("does not follow symlinks by default", async () => {
+      await using tmp = await tmpdir()
+      await fs.mkdir(path.join(tmp.path, "realdir"))
+      await fs.writeFile(path.join(tmp.path, "realdir", "file.txt"), "", "utf-8")
+      await fs.symlink(path.join(tmp.path, "realdir"), path.join(tmp.path, "linkdir"))
+
+      const results = await Glob.scan("**/*.txt", { cwd: tmp.path })
+
+      expect(results).toEqual(["realdir/file.txt"])
+    })
+
+    test("follows symlinks when symlink option is true", async () => {
+      await using tmp = await tmpdir()
+      await fs.mkdir(path.join(tmp.path, "realdir"))
+      await fs.writeFile(path.join(tmp.path, "realdir", "file.txt"), "", "utf-8")
+      await fs.symlink(path.join(tmp.path, "realdir"), path.join(tmp.path, "linkdir"))
+
+      const results = await Glob.scan("**/*.txt", { cwd: tmp.path, symlink: true })
+
+      expect(results.sort()).toEqual(["linkdir/file.txt", "realdir/file.txt"])
+    })
+
+    test("includes dotfiles when dot option is true", async () => {
+      await using tmp = await tmpdir()
+      await fs.writeFile(path.join(tmp.path, ".hidden"), "", "utf-8")
+      await fs.writeFile(path.join(tmp.path, "visible"), "", "utf-8")
+
+      const results = await Glob.scan("*", { cwd: tmp.path, dot: true })
+
+      expect(results.sort()).toEqual([".hidden", "visible"])
+    })
+
+    test("excludes dotfiles when dot option is false", async () => {
+      await using tmp = await tmpdir()
+      await fs.writeFile(path.join(tmp.path, ".hidden"), "", "utf-8")
+      await fs.writeFile(path.join(tmp.path, "visible"), "", "utf-8")
+
+      const results = await Glob.scan("*", { cwd: tmp.path, dot: false })
+
+      expect(results).toEqual(["visible"])
+    })
+  })
+
+  describe("scanSync()", () => {
+    test("finds files matching pattern synchronously", async () => {
+      await using tmp = await tmpdir()
+      await fs.writeFile(path.join(tmp.path, "a.txt"), "", "utf-8")
+      await fs.writeFile(path.join(tmp.path, "b.txt"), "", "utf-8")
+
+      const results = Glob.scanSync("*.txt", { cwd: tmp.path })
+
+      expect(results.sort()).toEqual(["a.txt", "b.txt"])
+    })
+
+    test("respects options", async () => {
+      await using tmp = await tmpdir()
+      await fs.mkdir(path.join(tmp.path, "subdir"))
+      await fs.writeFile(path.join(tmp.path, "file.txt"), "", "utf-8")
+
+      const results = Glob.scanSync("*", { cwd: tmp.path, include: "all" })
+
+      expect(results.sort()).toEqual(["file.txt", "subdir"])
+    })
+  })
+
+  describe("match()", () => {
+    test("matches simple patterns", () => {
+      expect(Glob.match("*.txt", "file.txt")).toBe(true)
+      expect(Glob.match("*.txt", "file.js")).toBe(false)
+    })
+
+    test("matches directory patterns", () => {
+      expect(Glob.match("**/*.js", "src/index.js")).toBe(true)
+      expect(Glob.match("**/*.js", "src/index.ts")).toBe(false)
+    })
+
+    test("matches dot files", () => {
+      expect(Glob.match(".*", ".gitignore")).toBe(true)
+      expect(Glob.match("**/*.md", ".github/README.md")).toBe(true)
+    })
+
+    test("matches brace expansion", () => {
+      expect(Glob.match("*.{js,ts}", "file.js")).toBe(true)
+      expect(Glob.match("*.{js,ts}", "file.ts")).toBe(true)
+      expect(Glob.match("*.{js,ts}", "file.py")).toBe(false)
+    })
+  })
+})