diff options
| author | Dax <[email protected]> | 2026-02-19 11:32:32 -0500 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-19 11:32:32 -0500 |
| commit | 02a94950638b4403a9ea44aeeb2d3d19212a04ec (patch) | |
| tree | 504c5b4d3c556320763e14e1435439e9e63c28d7 /packages | |
| parent | 0fcba68d4cd07014dda445543f70945379519ba0 (diff) | |
| download | opencode-02a94950638b4403a9ea44aeeb2d3d19212a04ec.tar.gz opencode-02a94950638b4403a9ea44aeeb2d3d19212a04ec.zip | |
Remove use of Bun.file (#14215)
Diffstat (limited to 'packages')
43 files changed, 634 insertions, 431 deletions
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx index 5f8a3920d..3ea8826ef 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx @@ -1,9 +1,10 @@ import path from "path" import { Global } from "@/global" +import { Filesystem } from "@/util/filesystem" import { onMount } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "../../context/helper" -import { appendFile } from "fs/promises" +import { appendFile, writeFile } from "fs/promises" function calculateFrecency(entry?: { frequency: number; lastOpen: number }): number { if (!entry) return 0 @@ -17,9 +18,9 @@ const MAX_FRECENCY_ENTRIES = 1000 export const { use: useFrecency, provider: FrecencyProvider } = createSimpleContext({ name: "Frecency", init: () => { - const frecencyFile = Bun.file(path.join(Global.Path.state, "frecency.jsonl")) + const frecencyPath = path.join(Global.Path.state, "frecency.jsonl") onMount(async () => { - const text = await frecencyFile.text().catch(() => "") + const text = await Filesystem.readText(frecencyPath).catch(() => "") const lines = text .split("\n") .filter(Boolean) @@ -53,7 +54,7 @@ export const { use: useFrecency, provider: FrecencyProvider } = createSimpleCont if (sorted.length > 0) { const content = sorted.map((entry) => JSON.stringify(entry)).join("\n") + "\n" - Bun.write(frecencyFile, content).catch(() => {}) + writeFile(frecencyPath, content).catch(() => {}) } }) @@ -68,7 +69,7 @@ export const { use: useFrecency, provider: FrecencyProvider } = createSimpleCont lastOpen: Date.now(), } setStore("data", absolutePath, newEntry) - appendFile(frecencyFile.name!, JSON.stringify({ path: absolutePath, ...newEntry }) + "\n").catch(() => {}) + appendFile(frecencyPath, JSON.stringify({ path: absolutePath, ...newEntry }) + "\n").catch(() => {}) if (Object.keys(store.data).length > MAX_FRECENCY_ENTRIES) { const sorted = Object.entries(store.data) @@ -76,7 +77,7 @@ export const { use: useFrecency, provider: FrecencyProvider } = createSimpleCont .slice(0, MAX_FRECENCY_ENTRIES) setStore("data", Object.fromEntries(sorted)) const content = sorted.map(([path, entry]) => JSON.stringify({ path, ...entry })).join("\n") + "\n" - Bun.write(frecencyFile, content).catch(() => {}) + writeFile(frecencyPath, content).catch(() => {}) } } diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx index e90503e9f..c40534e7e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx @@ -1,5 +1,6 @@ import path from "path" import { Global } from "@/global" +import { Filesystem } from "@/util/filesystem" import { onMount } from "solid-js" import { createStore, produce } from "solid-js/store" import { clone } from "remeda" @@ -30,9 +31,9 @@ const MAX_HISTORY_ENTRIES = 50 export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({ name: "PromptHistory", init: () => { - const historyFile = Bun.file(path.join(Global.Path.state, "prompt-history.jsonl")) + const historyPath = path.join(Global.Path.state, "prompt-history.jsonl") onMount(async () => { - const text = await historyFile.text().catch(() => "") + const text = await Filesystem.readText(historyPath).catch(() => "") const lines = text .split("\n") .filter(Boolean) @@ -51,7 +52,7 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create // Rewrite file with only valid entries to self-heal corruption if (lines.length > 0) { const content = lines.map((line) => JSON.stringify(line)).join("\n") + "\n" - writeFile(historyFile.name!, content).catch(() => {}) + writeFile(historyPath, content).catch(() => {}) } }) @@ -97,11 +98,11 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create if (trimmed) { const content = store.history.map((line) => JSON.stringify(line)).join("\n") + "\n" - writeFile(historyFile.name!, content).catch(() => {}) + writeFile(historyPath, content).catch(() => {}) return } - appendFile(historyFile.name!, JSON.stringify(entry) + "\n").catch(() => {}) + appendFile(historyPath, JSON.stringify(entry) + "\n").catch(() => {}) }, } }, diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 4114daf6c..d63c248fb 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1,6 +1,8 @@ import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg } from "@opentui/core" import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js" import "opentui-spinner/solid" +import path from "path" +import { Filesystem } from "@/util/filesystem" import { useLocal } from "@tui/context/local" import { useTheme } from "@tui/context/theme" import { EmptyBorder } from "@tui/component/border" @@ -931,26 +933,26 @@ export function Prompt(props: PromptProps) { const isUrl = /^(https?):\/\//.test(filepath) if (!isUrl) { try { - const file = Bun.file(filepath) + const mime = Filesystem.mimeType(filepath) + const filename = path.basename(filepath) // Handle SVG as raw text content, not as base64 image - if (file.type === "image/svg+xml") { + if (mime === "image/svg+xml") { event.preventDefault() - const content = await file.text().catch(() => {}) + const content = await Filesystem.readText(filepath).catch(() => {}) if (content) { - pasteText(content, `[SVG: ${file.name ?? "image"}]`) + pasteText(content, `[SVG: ${filename ?? "image"}]`) return } } - if (file.type.startsWith("image/")) { + if (mime.startsWith("image/")) { event.preventDefault() - const content = await file - .arrayBuffer() + const content = await Filesystem.readArrayBuffer(filepath) .then((buffer) => Buffer.from(buffer).toString("base64")) .catch(() => {}) if (content) { await pasteImage({ - filename: file.name, - mime: file.type, + filename, + mime, content, }) return diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/stash.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/stash.tsx index fd1cba86b..d4dc138d8 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/stash.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/stash.tsx @@ -1,5 +1,6 @@ import path from "path" import { Global } from "@/global" +import { Filesystem } from "@/util/filesystem" import { onMount } from "solid-js" import { createStore, produce } from "solid-js/store" import { clone } from "remeda" @@ -18,9 +19,9 @@ const MAX_STASH_ENTRIES = 50 export const { use: usePromptStash, provider: PromptStashProvider } = createSimpleContext({ name: "PromptStash", init: () => { - const stashFile = Bun.file(path.join(Global.Path.state, "prompt-stash.jsonl")) + const stashPath = path.join(Global.Path.state, "prompt-stash.jsonl") onMount(async () => { - const text = await stashFile.text().catch(() => "") + const text = await Filesystem.readText(stashPath).catch(() => "") const lines = text .split("\n") .filter(Boolean) @@ -39,7 +40,7 @@ export const { use: usePromptStash, provider: PromptStashProvider } = createSimp // Rewrite file with only valid entries to self-heal corruption if (lines.length > 0) { const content = lines.map((line) => JSON.stringify(line)).join("\n") + "\n" - writeFile(stashFile.name!, content).catch(() => {}) + writeFile(stashPath, content).catch(() => {}) } }) @@ -66,11 +67,11 @@ export const { use: usePromptStash, provider: PromptStashProvider } = createSimp if (trimmed) { const content = store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n" - writeFile(stashFile.name!, content).catch(() => {}) + writeFile(stashPath, content).catch(() => {}) return } - appendFile(stashFile.name!, JSON.stringify(stash) + "\n").catch(() => {}) + appendFile(stashPath, JSON.stringify(stash) + "\n").catch(() => {}) }, pop() { if (store.entries.length === 0) return undefined @@ -82,7 +83,7 @@ export const { use: usePromptStash, provider: PromptStashProvider } = createSimp ) const content = store.entries.length > 0 ? store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n" : "" - writeFile(stashFile.name!, content).catch(() => {}) + writeFile(stashPath, content).catch(() => {}) return entry }, remove(index: number) { @@ -94,7 +95,7 @@ export const { use: usePromptStash, provider: PromptStashProvider } = createSimp ) const content = store.entries.length > 0 ? store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n" : "" - writeFile(stashFile.name!, content).catch(() => {}) + writeFile(stashPath, content).catch(() => {}) }, } }, diff --git a/packages/opencode/src/cli/cmd/tui/context/kv.tsx b/packages/opencode/src/cli/cmd/tui/context/kv.tsx index 651c2dbc0..7a52156f8 100644 --- a/packages/opencode/src/cli/cmd/tui/context/kv.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/kv.tsx @@ -1,4 +1,5 @@ import { Global } from "@/global" +import { Filesystem } from "@/util/filesystem" import { createSignal, type Setter } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "./helper" @@ -9,10 +10,9 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({ init: () => { const [ready, setReady] = createSignal(false) const [store, setStore] = createStore<Record<string, any>>() - const file = Bun.file(path.join(Global.Path.state, "kv.json")) + const filePath = path.join(Global.Path.state, "kv.json") - file - .json() + Filesystem.readJson(filePath) .then((x) => { setStore(x) }) @@ -44,7 +44,7 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({ }, set(key: string, value: any) { setStore(key, value) - Bun.write(file, JSON.stringify(store, null, 2)) + Filesystem.writeJson(filePath, store) }, } return result diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index 72c72dc5b..d93079f12 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -12,6 +12,7 @@ import { Provider } from "@/provider/provider" import { useArgs } from "./args" import { useSDK } from "./sdk" import { RGBA } from "@opentui/core" +import { Filesystem } from "@/util/filesystem" export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ name: "Local", @@ -119,7 +120,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ variant: {}, }) - const file = Bun.file(path.join(Global.Path.state, "model.json")) + const filePath = path.join(Global.Path.state, "model.json") const state = { pending: false, } @@ -130,19 +131,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return } state.pending = false - Bun.write( - file, - JSON.stringify({ - recent: modelStore.recent, - favorite: modelStore.favorite, - variant: modelStore.variant, - }), - ) + Filesystem.writeJson(filePath, { + recent: modelStore.recent, + favorite: modelStore.favorite, + variant: modelStore.variant, + }) } - file - .json() - .then((x) => { + Filesystem.readJson(filePath) + .then((x: any) => { if (Array.isArray(x.recent)) setModelStore("recent", x.recent) if (Array.isArray(x.favorite)) setModelStore("favorite", x.favorite) if (typeof x.variant === "object" && x.variant !== null) setModelStore("variant", x.variant) diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 41c5a4a83..f9db1d77c 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -412,7 +412,7 @@ async function getCustomThemes() { cwd: dir, })) { const name = path.basename(item, ".json") - result[name] = await Bun.file(item).json() + result[name] = await Filesystem.readJson(item) } } return result diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 9eb296032..50f63c3df 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -3,10 +3,12 @@ import { tui } from "./app" import { Rpc } from "@/util/rpc" import { type rpc } from "./worker" import path from "path" +import { fileURLToPath } from "url" import { UI } from "@/cli/ui" import { iife } from "@/util/iife" import { Log } from "@/util/log" import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network" +import { Filesystem } from "@/util/filesystem" import type { Event } from "@opencode-ai/sdk/v2" import type { EventSource } from "./context/sdk" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" @@ -99,7 +101,7 @@ export const TuiThreadCommand = cmd({ const distWorker = new URL("./cli/cmd/tui/worker.js", import.meta.url) const workerPath = await iife(async () => { if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH - if (await Bun.file(distWorker).exists()) return distWorker + if (await Filesystem.exists(fileURLToPath(distWorker))) return distWorker return localWorker }) try { diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 8704b65ac..084ccf831 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -147,8 +147,7 @@ export namespace LSPClient { notify: { async open(input: { path: string }) { input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path) - const file = Bun.file(input.path) - const text = await file.text() + const text = await Filesystem.readText(input.path) const extension = path.extname(input.path) const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext" diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 0200be226..a4ebeb5a2 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -131,7 +131,7 @@ export namespace LSPServer { "bin", "vue-language-server.js", ) - if (!(await Bun.file(js).exists())) { + if (!(await Filesystem.exists(js))) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return await Bun.spawn([BunProc.which(), "install", "@vue/language-server"], { cwd: Global.Path.bin, @@ -173,14 +173,14 @@ export namespace LSPServer { if (!eslint) return log.info("spawning eslint server") const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js") - if (!(await Bun.file(serverPath).exists())) { + if (!(await Filesystem.exists(serverPath))) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return log.info("downloading and building VS Code ESLint server") const response = await fetch("https://github.com/microsoft/vscode-eslint/archive/refs/heads/main.zip") if (!response.ok) return const zipPath = path.join(Global.Path.bin, "vscode-eslint.zip") - await Bun.file(zipPath).write(response) + if (response.body) await Filesystem.writeStream(zipPath, response.body) const ok = await Archive.extractZip(zipPath, Global.Path.bin) .then(() => true) @@ -242,7 +242,7 @@ export namespace LSPServer { const resolveBin = async (target: string) => { const localBin = path.join(root, target) - if (await Bun.file(localBin).exists()) return localBin + if (await Filesystem.exists(localBin)) return localBin const candidates = Filesystem.up({ targets: [target], @@ -326,7 +326,7 @@ export namespace LSPServer { async spawn(root) { const localBin = path.join(root, "node_modules", ".bin", "biome") let bin: string | undefined - if (await Bun.file(localBin).exists()) bin = localBin + if (await Filesystem.exists(localBin)) bin = localBin if (!bin) { const found = Bun.which("biome") if (found) bin = found @@ -467,7 +467,7 @@ export namespace LSPServer { const potentialPythonPath = isWindows ? path.join(venvPath, "Scripts", "python.exe") : path.join(venvPath, "bin", "python") - if (await Bun.file(potentialPythonPath).exists()) { + if (await Filesystem.exists(potentialPythonPath)) { initialization["pythonPath"] = potentialPythonPath break } @@ -479,7 +479,7 @@ export namespace LSPServer { const potentialTyPath = isWindows ? path.join(venvPath, "Scripts", "ty.exe") : path.join(venvPath, "bin", "ty") - if (await Bun.file(potentialTyPath).exists()) { + if (await Filesystem.exists(potentialTyPath)) { binary = potentialTyPath break } @@ -511,7 +511,7 @@ export namespace LSPServer { const args = [] if (!binary) { const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js") - if (!(await Bun.file(js).exists())) { + if (!(await Filesystem.exists(js))) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return await Bun.spawn([BunProc.which(), "install", "pyright"], { cwd: Global.Path.bin, @@ -536,7 +536,7 @@ export namespace LSPServer { const potentialPythonPath = isWindows ? path.join(venvPath, "Scripts", "python.exe") : path.join(venvPath, "bin", "python") - if (await Bun.file(potentialPythonPath).exists()) { + if (await Filesystem.exists(potentialPythonPath)) { initialization["pythonPath"] = potentialPythonPath break } @@ -571,7 +571,7 @@ export namespace LSPServer { process.platform === "win32" ? "language_server.bat" : "language_server.sh", ) - if (!(await Bun.file(binary).exists())) { + if (!(await Filesystem.exists(binary))) { const elixir = Bun.which("elixir") if (!elixir) { log.error("elixir is required to run elixir-ls") @@ -584,7 +584,7 @@ export namespace LSPServer { const response = await fetch("https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip") if (!response.ok) return const zipPath = path.join(Global.Path.bin, "elixir-ls.zip") - await Bun.file(zipPath).write(response) + if (response.body) await Filesystem.writeStream(zipPath, response.body) const ok = await Archive.extractZip(zipPath, Global.Path.bin) .then(() => true) @@ -692,7 +692,7 @@ export namespace LSPServer { } const tempPath = path.join(Global.Path.bin, assetName) - await Bun.file(tempPath).write(downloadResponse) + if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) if (ext === "zip") { const ok = await Archive.extractZip(tempPath, Global.Path.bin) @@ -710,7 +710,7 @@ export namespace LSPServer { bin = path.join(Global.Path.bin, "zls" + (platform === "win32" ? ".exe" : "")) - if (!(await Bun.file(bin).exists())) { + if (!(await Filesystem.exists(bin))) { log.error("Failed to extract zls binary") return } @@ -857,7 +857,7 @@ export namespace LSPServer { // Stop at filesystem root const cargoTomlPath = path.join(currentDir, "Cargo.toml") try { - const cargoTomlContent = await Bun.file(cargoTomlPath).text() + const cargoTomlContent = await Filesystem.readText(cargoTomlPath) if (cargoTomlContent.includes("[workspace]")) { return currentDir } @@ -907,7 +907,7 @@ export namespace LSPServer { const ext = process.platform === "win32" ? ".exe" : "" const direct = path.join(Global.Path.bin, "clangd" + ext) - if (await Bun.file(direct).exists()) { + if (await Filesystem.exists(direct)) { return { process: spawn(direct, args, { cwd: root, @@ -920,7 +920,7 @@ export namespace LSPServer { if (!entry.isDirectory()) continue if (!entry.name.startsWith("clangd_")) continue const candidate = path.join(Global.Path.bin, entry.name, "bin", "clangd" + ext) - if (await Bun.file(candidate).exists()) { + if (await Filesystem.exists(candidate)) { return { process: spawn(candidate, args, { cwd: root, @@ -990,7 +990,7 @@ export namespace LSPServer { log.error("Failed to write clangd archive") return } - await Bun.write(archive, buf) + await Filesystem.write(archive, Buffer.from(buf)) const zip = name.endsWith(".zip") const tar = name.endsWith(".tar.xz") @@ -1014,7 +1014,7 @@ export namespace LSPServer { await fs.rm(archive, { force: true }) const bin = path.join(Global.Path.bin, "clangd_" + tag, "bin", "clangd" + ext) - if (!(await Bun.file(bin).exists())) { + if (!(await Filesystem.exists(bin))) { log.error("Failed to extract clangd binary") return } @@ -1045,7 +1045,7 @@ export namespace LSPServer { const args: string[] = [] if (!binary) { const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js") - if (!(await Bun.file(js).exists())) { + if (!(await Filesystem.exists(js))) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return await Bun.spawn([BunProc.which(), "install", "svelte-language-server"], { cwd: Global.Path.bin, @@ -1092,7 +1092,7 @@ export namespace LSPServer { const args: string[] = [] if (!binary) { const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js") - if (!(await Bun.file(js).exists())) { + if (!(await Filesystem.exists(js))) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return await Bun.spawn([BunProc.which(), "install", "@astrojs/language-server"], { cwd: Global.Path.bin, @@ -1248,7 +1248,7 @@ export namespace LSPServer { const distPath = path.join(Global.Path.bin, "kotlin-ls") const launcherScript = process.platform === "win32" ? path.join(distPath, "kotlin-lsp.cmd") : path.join(distPath, "kotlin-lsp.sh") - const installed = await Bun.file(launcherScript).exists() + const installed = await Filesystem.exists(launcherScript) if (!installed) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return log.info("Downloading Kotlin Language Server from GitHub.") @@ -1307,7 +1307,7 @@ export namespace LSPServer { } log.info("Installed Kotlin Language Server", { path: launcherScript }) } - if (!(await Bun.file(launcherScript).exists())) { + if (!(await Filesystem.exists(launcherScript))) { log.error(`Failed to locate the Kotlin LS launcher script in the installed directory: ${distPath}.`) return } @@ -1336,7 +1336,7 @@ export namespace LSPServer { "src", "server.js", ) - const exists = await Bun.file(js).exists() + const exists = await Filesystem.exists(js) if (!exists) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return await Bun.spawn([BunProc.which(), "install", "yaml-language-server"], { @@ -1443,7 +1443,7 @@ export namespace LSPServer { } const tempPath = path.join(Global.Path.bin, assetName) - await Bun.file(tempPath).write(downloadResponse) + if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) // Unlike zls which is a single self-contained binary, // lua-language-server needs supporting files (meta/, locale/, etc.) @@ -1482,7 +1482,7 @@ export namespace LSPServer { // Binary is located in bin/ subdirectory within the extracted archive bin = path.join(installDir, "bin", "lua-language-server" + (platform === "win32" ? ".exe" : "")) - if (!(await Bun.file(bin).exists())) { + if (!(await Filesystem.exists(bin))) { log.error("Failed to extract lua-language-server binary") return } @@ -1516,7 +1516,7 @@ export namespace LSPServer { const args: string[] = [] if (!binary) { const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js") - if (!(await Bun.file(js).exists())) { + if (!(await Filesystem.exists(js))) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return await Bun.spawn([BunProc.which(), "install", "intelephense"], { cwd: Global.Path.bin, @@ -1613,7 +1613,7 @@ export namespace LSPServer { const args: string[] = [] if (!binary) { const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js") - if (!(await Bun.file(js).exists())) { + if (!(await Filesystem.exists(js))) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return await Bun.spawn([BunProc.which(), "install", "bash-language-server"], { cwd: Global.Path.bin, @@ -1654,22 +1654,17 @@ export namespace LSPServer { if (!bin) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - log.info("downloading terraform-ls from GitHub releases") + log.info("downloading terraform-ls from HashiCorp releases") - const releaseResponse = await fetch("https://api.github.com/repos/hashicorp/terraform-ls/releases/latest") + const releaseResponse = await fetch("https://api.releases.hashicorp.com/v1/releases/terraform-ls/latest") if (!releaseResponse.ok) { log.error("Failed to fetch terraform-ls release info") return } const release = (await releaseResponse.json()) as { - tag_name?: string - assets?: { name?: string; browser_download_url?: string }[] - } - const version = release.tag_name?.replace("v", "") - if (!version) { - log.error("terraform-ls release did not include a version tag") - return + version?: string + builds?: { arch?: string; os?: string; url?: string }[] } const platform = process.platform @@ -1678,23 +1673,21 @@ export namespace LSPServer { const tfArch = arch === "arm64" ? "arm64" : "amd64" const tfPlatform = platform === "win32" ? "windows" : platform - const assetName = `terraform-ls_${version}_${tfPlatform}_${tfArch}.zip` - - const assets = release.assets ?? [] - const asset = assets.find((a) => a.name === assetName) - if (!asset?.browser_download_url) { - log.error(`Could not find asset ${assetName} in terraform-ls release`) + const builds = release.builds ?? [] + const build = builds.find((b) => b.arch === tfArch && b.os === tfPlatform) + if (!build?.url) { + log.error(`Could not find build for ${tfPlatform}/${tfArch} terraform-ls release version ${release.version}`) return } - const downloadResponse = await fetch(asset.browser_download_url) + const downloadResponse = await fetch(build.url) if (!downloadResponse.ok) { log.error("Failed to download terraform-ls") return } - const tempPath = path.join(Global.Path.bin, assetName) - await Bun.file(tempPath).write(downloadResponse) + const tempPath = path.join(Global.Path.bin, "terraform-ls.zip") + if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) const ok = await Archive.extractZip(tempPath, Global.Path.bin) .then(() => true) @@ -1707,7 +1700,7 @@ export namespace LSPServer { bin = path.join(Global.Path.bin, "terraform-ls" + (platform === "win32" ? ".exe" : "")) - if (!(await Bun.file(bin).exists())) { + if (!(await Filesystem.exists(bin))) { log.error("Failed to extract terraform-ls binary") return } @@ -1784,7 +1777,7 @@ export namespace LSPServer { } const tempPath = path.join(Global.Path.bin, assetName) - await Bun.file(tempPath).write(downloadResponse) + if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) if (ext === "zip") { const ok = await Archive.extractZip(tempPath, Global.Path.bin) @@ -1803,7 +1796,7 @@ export namespace LSPServer { bin = path.join(Global.Path.bin, "texlab" + (platform === "win32" ? ".exe" : "")) - if (!(await Bun.file(bin).exists())) { + if (!(await Filesystem.exists(bin))) { log.error("Failed to extract texlab binary") return } @@ -1832,7 +1825,7 @@ export namespace LSPServer { const args: string[] = [] if (!binary) { const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js") - if (!(await Bun.file(js).exists())) { + if (!(await Filesystem.exists(js))) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return await Bun.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], { cwd: Global.Path.bin, @@ -1990,7 +1983,7 @@ export namespace LSPServer { } const tempPath = path.join(Global.Path.bin, assetName) - await Bun.file(tempPath).write(downloadResponse) + if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) if (ext === "zip") { const ok = await Archive.extractZip(tempPath, Global.Path.bin) @@ -2008,7 +2001,7 @@ export namespace LSPServer { bin = path.join(Global.Path.bin, "tinymist" + (platform === "win32" ? ".exe" : "")) - if (!(await Bun.file(bin).exists())) { + if (!(await Filesystem.exists(bin))) { log.error("Failed to extract tinymist binary") return } diff --git a/packages/opencode/src/mcp/auth.ts b/packages/opencode/src/mcp/auth.ts index 0f91a35b8..399986376 100644 --- a/packages/opencode/src/mcp/auth.ts +++ b/packages/opencode/src/mcp/auth.ts @@ -1,6 +1,7 @@ import path from "path" import z from "zod" import { Global } from "../global" +import { Filesystem } from "../util/filesystem" export namespace McpAuth { export const Tokens = z.object({ @@ -53,25 +54,22 @@ export namespace McpAuth { } export async function all(): Promise<Record<string, Entry>> { - const file = Bun.file(filepath) - return file.json().catch(() => ({})) + return Filesystem.readJson<Record<string, Entry>>(filepath).catch(() => ({})) } export async function set(mcpName: string, entry: Entry, serverUrl?: string): Promise<void> { - const file = Bun.file(filepath) const data = await all() // Always update serverUrl if provided if (serverUrl) { entry.serverUrl = serverUrl } - await Bun.write(file, JSON.stringify({ ...data, [mcpName]: entry }, null, 2), { mode: 0o600 }) + await Filesystem.writeJson(filepath, { ...data, [mcpName]: entry }, 0o600) } export async function remove(mcpName: string): Promise<void> { - const file = Bun.file(filepath) const data = await all() delete data[mcpName] - await Bun.write(file, JSON.stringify(data, null, 2), { mode: 0o600 }) + await Filesystem.writeJson(filepath, data, 0o600) } export async function updateTokens(mcpName: string, tokens: Tokens, serverUrl?: string): Promise<void> { diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 8fa0f6c6f..63c1c4cad 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -86,8 +86,7 @@ export namespace Project { const gitBinary = Bun.which("git") // cached id calculation - let id = await Bun.file(path.join(dotgit, "opencode")) - .text() + let id = await Filesystem.readText(path.join(dotgit, "opencode")) .then((x) => x.trim()) .catch(() => undefined) @@ -125,9 +124,7 @@ export namespace Project { id = roots[0] if (id) { - void Bun.file(path.join(dotgit, "opencode")) - .write(id) - .catch(() => undefined) + void Filesystem.write(path.join(dotgit, "opencode"), id).catch(() => undefined) } } @@ -277,10 +274,9 @@ export namespace Project { ) const shortest = matches.sort((a, b) => a.length - b.length)[0] if (!shortest) return - const file = Bun.file(shortest) - const buffer = await file.arrayBuffer() - const base64 = Buffer.from(buffer).toString("base64") - const mime = file.type || "image/png" + const buffer = await Filesystem.readBytes(shortest) + const base64 = buffer.toString("base64") + const mime = Filesystem.mimeType(shortest) || "image/png" const url = `data:${mime};base64,${base64}` await update({ projectID: input.id, @@ -381,10 +377,8 @@ export namespace Project { const data = fromRow(row) const valid: string[] = [] for (const dir of data.sandboxes) { - const stat = await Bun.file(dir) - .stat() - .catch(() => undefined) - if (stat?.isDirectory()) valid.push(dir) + const s = Filesystem.stat(dir) + if (s?.isDirectory()) valid.push(dir) } return valid } diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 0960176e2..bae331784 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -5,6 +5,7 @@ import z from "zod" import { Installation } from "../installation" import { Flag } from "../flag/flag" import { lazy } from "@/util/lazy" +import { Filesystem } from "../util/filesystem" // Try to import bundled snapshot (generated at build time) // Falls back to undefined in dev mode when snapshot doesn't exist @@ -85,8 +86,7 @@ export namespace ModelsDev { } export const Data = lazy(async () => { - const file = Bun.file(Flag.OPENCODE_MODELS_PATH ?? filepath) - const result = await file.json().catch(() => {}) + const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {}) if (result) return result // @ts-ignore const snapshot = await import("./models-snapshot") @@ -104,7 +104,6 @@ export namespace ModelsDev { } export async function refresh() { - const file = Bun.file(filepath) const result = await fetch(`${url()}/api.json`, { headers: { "User-Agent": Installation.USER_AGENT, @@ -116,7 +115,7 @@ export namespace ModelsDev { }) }) if (result && result.ok) { - await Bun.write(file, await result.text()) + await Filesystem.write(filepath, await result.text()) ModelsDev.Data.reset() } } diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index f1871ddb6..022ec3167 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -16,6 +16,7 @@ import { Flag } from "../flag/flag" import { iife } from "@/util/iife" import { Global } from "../global" import path from "path" +import { Filesystem } from "../util/filesystem" // Direct imports for bundled providers import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock" @@ -1289,8 +1290,9 @@ export namespace Provider { if (cfg.model) return parseModel(cfg.model) const providers = await list() - const recent = (await Bun.file(path.join(Global.Path.state, "model.json")) - .json() + const recent = (await Filesystem.readJson<{ recent?: { providerID: string; modelID: string }[] }>( + path.join(Global.Path.state, "model.json"), + ) .then((x) => (Array.isArray(x.recent) ? x.recent : [])) .catch(() => [])) as { providerID: string; modelID: string }[] for (const entry of recent) { diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 6fb2a7aeb..d65ada278 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -85,7 +85,7 @@ export namespace InstructionPrompt { } for (const file of globalFiles()) { - if (await Bun.file(file).exists()) { + if (await Filesystem.exists(file)) { paths.add(path.resolve(file)) break } @@ -120,9 +120,7 @@ export namespace InstructionPrompt { const paths = await systemPaths() const files = Array.from(paths).map(async (p) => { - const content = await Bun.file(p) - .text() - .catch(() => "") + const content = await Filesystem.readText(p).catch(() => "") return content ? "Instructions from: " + p + "\n" + content : "" }) @@ -164,7 +162,7 @@ export namespace InstructionPrompt { export async function find(dir: string) { for (const file of FILES) { const filepath = path.resolve(path.join(dir, file)) - if (await Bun.file(filepath).exists()) return filepath + if (await Filesystem.exists(filepath)) return filepath } } @@ -182,9 +180,7 @@ export namespace InstructionPrompt { if (found && found !== target && !system.has(found) && !already.has(found) && !isClaimed(messageID, found)) { claim(messageID, found) - const content = await Bun.file(found) - .text() - .catch(() => undefined) + const content = await Filesystem.readText(found).catch(() => undefined) if (content) { results.push({ filepath: found, content: "Instructions from: " + found + "\n" + content }) } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index d1f407258..6ca93979e 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -2,6 +2,7 @@ import path from "path" import os from "os" import fs from "fs/promises" import z from "zod" +import { Filesystem } from "../util/filesystem" import { Identifier } from "../id/id" import { MessageV2 } from "./message-v2" import { Log } from "../util/log" @@ -1082,11 +1083,9 @@ export namespace SessionPrompt { // have to normalize, symbol search returns absolute paths // Decode the pathname since URL constructor doesn't automatically decode it const filepath = fileURLToPath(part.url) - const stat = await Bun.file(filepath) - .stat() - .catch(() => undefined) + const s = Filesystem.stat(filepath) - if (stat?.isDirectory()) { + if (s?.isDirectory()) { part.mime = "application/x-directory" } @@ -1233,14 +1232,13 @@ export namespace SessionPrompt { ] } - const file = Bun.file(filepath) FileTime.read(input.sessionID, filepath) return [ { messageID: info.id, sessionID: input.sessionID, type: "text", - text: `Called the Read tool with the following input: {\"filePath\":\"${filepath}\"}`, + text: `Called the Read tool with the following input: {"filePath":"${filepath}"}`, synthetic: true, }, { @@ -1248,7 +1246,7 @@ export namespace SessionPrompt { messageID: info.id, sessionID: input.sessionID, type: "file", - url: `data:${part.mime};base64,` + Buffer.from(await file.bytes()).toString("base64"), + url: `data:${part.mime};base64,` + (await Filesystem.readBytes(filepath)).toString("base64"), mime: part.mime, filename: part.filename!, source: part.source, @@ -1354,7 +1352,7 @@ export namespace SessionPrompt { // Switching from plan mode to build mode if (input.agent.name !== "plan" && assistantMessage?.info.agent === "plan") { const plan = Session.plan(input.session) - const exists = await Bun.file(plan).exists() + const exists = await Filesystem.exists(plan) if (exists) { const part = await Session.updatePart({ id: Identifier.ascending("part"), @@ -1373,7 +1371,7 @@ export namespace SessionPrompt { // Entering plan mode if (input.agent.name === "plan" && assistantMessage?.info.agent !== "plan") { const plan = Session.plan(input.session) - const exists = await Bun.file(plan).exists() + const exists = await Filesystem.exists(plan) if (!exists) await fs.mkdir(path.dirname(plan), { recursive: true }) const part = await Session.updatePart({ id: Identifier.ascending("part"), diff --git a/packages/opencode/src/shell/shell.ts b/packages/opencode/src/shell/shell.ts index 2e8d48bfd..e7b7cdb3e 100644 --- a/packages/opencode/src/shell/shell.ts +++ b/packages/opencode/src/shell/shell.ts @@ -1,5 +1,6 @@ import { Flag } from "@/flag/flag" import { lazy } from "@/util/lazy" +import { Filesystem } from "@/util/filesystem" import path from "path" import { spawn, type ChildProcess } from "child_process" @@ -43,7 +44,7 @@ export namespace Shell { // git.exe is typically at: C:\Program Files\Git\cmd\git.exe // bash.exe is at: C:\Program Files\Git\bin\bash.exe const bash = path.join(git, "..", "..", "bin", "bash.exe") - if (Bun.file(bash).size) return bash + if (Filesystem.stat(bash)?.size) return bash } return process.env.COMSPEC || "cmd.exe" } diff --git a/packages/opencode/src/skill/discovery.ts b/packages/opencode/src/skill/discovery.ts index a4bf97d7a..846002cda 100644 --- a/packages/opencode/src/skill/discovery.ts +++ b/packages/opencode/src/skill/discovery.ts @@ -2,6 +2,7 @@ import path from "path" import { mkdir } from "fs/promises" import { Log } from "../util/log" import { Global } from "../global" +import { Filesystem } from "../util/filesystem" export namespace Discovery { const log = Log.create({ service: "skill-discovery" }) @@ -19,14 +20,14 @@ export namespace Discovery { } async function get(url: string, dest: string): Promise<boolean> { - if (await Bun.file(dest).exists()) return true + if (await Filesystem.exists(dest)) return true return fetch(url) .then(async (response) => { if (!response.ok) { log.error("failed to download", { url, status: response.status }) return false } - await Bun.write(dest, await response.text()) + if (response.body) await Filesystem.writeStream(dest, response.body) return true }) .catch((err) => { @@ -88,7 +89,7 @@ export namespace Discovery { ) const md = path.join(root, "SKILL.md") - if (await Bun.file(md).exists()) result.push(root) + if (await Filesystem.exists(md)) result.push(root) }), ) diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index 0974cbe7b..6d7bfd728 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -10,7 +10,7 @@ import { Log } from "../util/log" import { NamedError } from "@opencode-ai/util/error" import z from "zod" import path from "path" -import { readFileSync, readdirSync } from "fs" +import { readFileSync, readdirSync, existsSync } from "fs" import * as schema from "./schema" declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number }[] | undefined @@ -54,7 +54,7 @@ export namespace Database { const sql = dirs .map((name) => { const file = path.join(dir, name, "migration.sql") - if (!Bun.file(file).size) return + if (!existsSync(file)) return return { sql: readFileSync(file, "utf-8"), timestamp: time(name), diff --git a/packages/opencode/src/storage/json-migration.ts b/packages/opencode/src/storage/json-migration.ts index e0684ce3c..268442dcf 100644 --- a/packages/opencode/src/storage/json-migration.ts +++ b/packages/opencode/src/storage/json-migration.ts @@ -7,6 +7,7 @@ import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } fro import { SessionShareTable } from "../share/share.sql" import path from "path" import { existsSync } from "fs" +import { Filesystem } from "../util/filesystem" export namespace JsonMigration { const log = Log.create({ service: "json-migration" }) @@ -82,7 +83,7 @@ export namespace JsonMigration { const count = end - start const tasks = new Array(count) for (let i = 0; i < count; i++) { - tasks[i] = Bun.file(files[start + i]).json() + tasks[i] = Filesystem.readJson(files[start + i]) } const results = await Promise.allSettled(tasks) const items = new Array(count) diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index 18f2d67e7..691ce3c53 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -39,7 +39,7 @@ export namespace Storage { cwd: path.join(project, projectDir), absolute: true, })) { - const json = await Bun.file(msgFile).json() + const json = await Filesystem.readJson<any>(msgFile) worktree = json.path?.root if (worktree) break } @@ -60,18 +60,15 @@ export namespace Storage { if (!id) continue projectID = id - await Bun.write( - path.join(dir, "project", projectID + ".json"), - JSON.stringify({ - id, - vcs: "git", - worktree, - time: { - created: Date.now(), - initialized: Date.now(), - }, - }), - ) + await Filesystem.writeJson(path.join(dir, "project", projectID + ".json"), { + id, + vcs: "git", + worktree, + time: { + created: Date.now(), + initialized: Date.now(), + }, + }) log.info(`migrating sessions for project ${projectID}`) for await (const sessionFile of new Bun.Glob("storage/session/info/*.json").scan({ @@ -83,8 +80,8 @@ export namespace Storage { sessionFile, dest, }) - const session = await Bun.file(sessionFile).json() - await Bun.write(dest, JSON.stringify(session)) + 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({ cwd: fullProjectDir, @@ -95,8 +92,8 @@ export namespace Storage { msgFile, dest, }) - const message = await Bun.file(msgFile).json() - await Bun.write(dest, JSON.stringify(message)) + const message = await Filesystem.readJson<any>(msgFile) + 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( @@ -106,12 +103,12 @@ export namespace Storage { }, )) { const dest = path.join(dir, "part", message.id, path.basename(partFile)) - const part = await Bun.file(partFile).json() + const part = await Filesystem.readJson(partFile) log.info("copying", { partFile, dest, }) - await Bun.write(dest, JSON.stringify(part)) + await Filesystem.writeJson(dest, part) } } } @@ -123,35 +120,32 @@ export namespace Storage { cwd: dir, absolute: true, })) { - const session = await Bun.file(item).json() + const session = await Filesystem.readJson<any>(item) if (!session.projectID) continue if (!session.summary?.diffs) continue const { diffs } = session.summary - await Bun.file(path.join(dir, "session_diff", session.id + ".json")).write(JSON.stringify(diffs)) - await Bun.file(path.join(dir, "session", session.projectID, session.id + ".json")).write( - JSON.stringify({ - ...session, - summary: { - additions: diffs.reduce((sum: any, x: any) => sum + x.additions, 0), - deletions: diffs.reduce((sum: any, x: any) => sum + x.deletions, 0), - }, - }), - ) + await Filesystem.write(path.join(dir, "session_diff", session.id + ".json"), JSON.stringify(diffs)) + await Filesystem.writeJson(path.join(dir, "session", session.projectID, session.id + ".json"), { + ...session, + summary: { + additions: diffs.reduce((sum: any, x: any) => sum + x.additions, 0), + deletions: diffs.reduce((sum: any, x: any) => sum + x.deletions, 0), + }, + }) } }, ] const state = lazy(async () => { const dir = path.join(Global.Path.data, "storage") - const migration = await Bun.file(path.join(dir, "migration")) - .json() + const migration = await Filesystem.readJson<string>(path.join(dir, "migration")) .then((x) => parseInt(x)) .catch(() => 0) for (let index = migration; index < MIGRATIONS.length; index++) { log.info("running migration", { index }) const migration = MIGRATIONS[index] await migration(dir).catch(() => log.error("failed to run migration", { index })) - await Bun.write(path.join(dir, "migration"), (index + 1).toString()) + await Filesystem.write(path.join(dir, "migration"), (index + 1).toString()) } return { dir, @@ -171,7 +165,7 @@ export namespace Storage { const target = path.join(dir, ...key) + ".json" return withErrorHandling(async () => { using _ = await Lock.read(target) - const result = await Bun.file(target).json() + const result = await Filesystem.readJson<T>(target) return result as T }) } @@ -181,10 +175,10 @@ export namespace Storage { const target = path.join(dir, ...key) + ".json" return withErrorHandling(async () => { using _ = await Lock.write(target) - const content = await Bun.file(target).json() - fn(content) - await Bun.write(target, JSON.stringify(content, null, 2)) - return content as T + const content = await Filesystem.readJson<T>(target) + fn(content as T) + await Filesystem.writeJson(target, content) + return content }) } @@ -193,7 +187,7 @@ export namespace Storage { const target = path.join(dir, ...key) + ".json" return withErrorHandling(async () => { using _ = await Lock.write(target) - await Bun.write(target, JSON.stringify(content, null, 2)) + await Filesystem.writeJson(target, content) }) } diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index d84f6ec34..7a097d3fe 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -49,7 +49,7 @@ export const EditTool = Tool.define("edit", { let contentNew = "" await FileTime.withLock(filePath, async () => { if (params.oldString === "") { - const existed = await Bun.file(filePath).exists() + const existed = await Filesystem.exists(filePath) contentNew = params.newString diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) await ctx.ask({ @@ -61,7 +61,7 @@ export const EditTool = Tool.define("edit", { diff, }, }) - await Bun.write(filePath, params.newString) + await Filesystem.write(filePath, params.newString) await Bus.publish(File.Event.Edited, { file: filePath, }) @@ -73,12 +73,11 @@ export const EditTool = Tool.define("edit", { return } - const file = Bun.file(filePath) - const stats = await file.stat().catch(() => {}) + const stats = Filesystem.stat(filePath) if (!stats) throw new Error(`File ${filePath} not found`) if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`) await FileTime.assert(ctx.sessionID, filePath) - contentOld = await file.text() + contentOld = await Filesystem.readText(filePath) contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll) diff = trimDiff( @@ -94,7 +93,7 @@ export const EditTool = Tool.define("edit", { }, }) - await file.write(contentNew) + await Filesystem.write(filePath, contentNew) await Bus.publish(File.Event.Edited, { file: filePath, }) @@ -102,7 +101,7 @@ export const EditTool = Tool.define("edit", { file: filePath, event: "change", }) - contentNew = await file.text() + contentNew = await Filesystem.readText(filePath) diff = trimDiff( createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)), ) diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index 9df1eedca..a2611246c 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -1,6 +1,7 @@ import z from "zod" import path from "path" import { Tool } from "./tool" +import { Filesystem } from "../util/filesystem" import DESCRIPTION from "./glob.txt" import { Ripgrep } from "../file/ripgrep" import { Instance } from "../project/instance" @@ -45,10 +46,7 @@ export const GlobTool = Tool.define("glob", { break } const full = path.resolve(search, file) - const stats = await Bun.file(full) - .stat() - .then((x) => x.mtime.getTime()) - .catch(() => 0) + const stats = Filesystem.stat(full)?.mtime.getTime() ?? 0 files.push({ path: full, mtime: stats, diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 41ed494de..00497d4e3 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -1,5 +1,6 @@ import z from "zod" import { Tool } from "./tool" +import { Filesystem } from "../util/filesystem" import { Ripgrep } from "../file/ripgrep" import DESCRIPTION from "./grep.txt" @@ -83,8 +84,7 @@ export const GrepTool = Tool.define("grep", { const lineNum = parseInt(lineNumStr, 10) const lineText = lineTextParts.join("|") - const file = Bun.file(filePath) - const stats = await file.stat().catch(() => null) + const stats = Filesystem.stat(filePath) if (!stats) continue matches.push({ diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts index ca352280b..52aef0f9e 100644 --- a/packages/opencode/src/tool/lsp.ts +++ b/packages/opencode/src/tool/lsp.ts @@ -6,6 +6,7 @@ import DESCRIPTION from "./lsp.txt" import { Instance } from "../project/instance" import { pathToFileURL } from "url" import { assertExternalDirectory } from "./external-directory" +import { Filesystem } from "../util/filesystem" const operations = [ "goToDefinition", @@ -47,7 +48,7 @@ export const LspTool = Tool.define("lsp", { const relPath = path.relative(Instance.worktree, file) const title = `${args.operation} ${relPath}:${args.line}:${args.character}` - const exists = await Bun.file(file).exists() + const exists = await Filesystem.exists(file) if (!exists) { throw new Error(`File not found: ${file}`) } diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 80ca95900..c981ac16e 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -10,6 +10,7 @@ import DESCRIPTION from "./read.txt" import { Instance } from "../project/instance" import { assertExternalDirectory } from "./external-directory" import { InstructionPrompt } from "../session/instruction" +import { Filesystem } from "../util/filesystem" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 @@ -34,8 +35,7 @@ export const ReadTool = Tool.define("read", { } const title = path.relative(Instance.worktree, filepath) - const file = Bun.file(filepath) - const stat = await file.stat().catch(() => undefined) + const stat = Filesystem.stat(filepath) await assertExternalDirectory(ctx, filepath, { bypass: Boolean(ctx.extra?.["bypassCwdCheck"]), @@ -118,11 +118,10 @@ export const ReadTool = Tool.define("read", { const instructions = await InstructionPrompt.resolve(ctx.messages, filepath, ctx.messageID) // Exclude SVG (XML-based) and vnd.fastbidsheet (.fbs extension, commonly FlatBuffers schema files) - const isImage = - file.type.startsWith("image/") && file.type !== "image/svg+xml" && file.type !== "image/vnd.fastbidsheet" - const isPdf = file.type === "application/pdf" + const mime = Filesystem.mimeType(filepath) + const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet" + const isPdf = mime === "application/pdf" if (isImage || isPdf) { - const mime = file.type const msg = `${isImage ? "Image" : "PDF"} read successfully` return { title, @@ -136,13 +135,13 @@ export const ReadTool = Tool.define("read", { { type: "file", mime, - url: `data:${mime};base64,${Buffer.from(await file.bytes()).toString("base64")}`, + url: `data:${mime};base64,${Buffer.from(await Filesystem.readBytes(filepath)).toString("base64")}`, }, ], } } - const isBinary = await isBinaryFile(filepath, stat.size) + const isBinary = await isBinaryFile(filepath, Number(stat.size)) if (isBinary) throw new Error(`Cannot read binary file: ${filepath}`) const stream = createReadStream(filepath, { encoding: "utf8" }) diff --git a/packages/opencode/src/tool/truncation.ts b/packages/opencode/src/tool/truncation.ts index 84e799c13..4cc524aee 100644 --- a/packages/opencode/src/tool/truncation.ts +++ b/packages/opencode/src/tool/truncation.ts @@ -5,6 +5,7 @@ import { Identifier } from "../id/id" import { PermissionNext } from "../permission/next" import type { Agent } from "../agent/agent" import { Scheduler } from "../scheduler" +import { Filesystem } from "../util/filesystem" export namespace Truncate { export const MAX_LINES = 2000 @@ -91,7 +92,7 @@ export namespace Truncate { const id = Identifier.ascending("tool") const filepath = path.join(DIR, id) - await Bun.write(Bun.file(filepath), text) + await Filesystem.write(filepath, text) const hint = hasTaskTool(agent) ? `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.` diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index eca64d303..8c1e53cca 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -26,9 +26,8 @@ export const WriteTool = Tool.define("write", { const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) await assertExternalDirectory(ctx, filepath) - const file = Bun.file(filepath) - const exists = await file.exists() - const contentOld = exists ? await file.text() : "" + const exists = await Filesystem.exists(filepath) + const contentOld = exists ? await Filesystem.readText(filepath) : "" if (exists) await FileTime.assert(ctx.sessionID, filepath) const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content)) @@ -42,7 +41,7 @@ export const WriteTool = Tool.define("write", { }, }) - await Bun.write(filepath, params.content) + await Filesystem.write(filepath, params.content) await Bus.publish(File.Event.Edited, { file: filepath, }) diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index 7b196eb84..575e61406 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -1,8 +1,10 @@ -import { mkdir, readFile, writeFile } from "fs/promises" -import { existsSync, statSync } from "fs" +import { chmod, mkdir, readFile, writeFile } from "fs/promises" +import { createWriteStream, existsSync, statSync } from "fs" import { lookup } from "mime-types" import { realpathSync } from "fs" import { dirname, join, relative } from "path" +import { Readable } from "stream" +import { pipeline } from "stream/promises" export namespace Filesystem { // Fast sync version for metadata checks @@ -39,11 +41,16 @@ export namespace Filesystem { return readFile(p) } + export async function readArrayBuffer(p: string): Promise<ArrayBuffer> { + const buf = await readFile(p) + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer + } + function isEnoent(e: unknown): e is { code: "ENOENT" } { return typeof e === "object" && e !== null && "code" in e && (e as { code: string }).code === "ENOENT" } - export async function write(p: string, content: string | Buffer, mode?: number): Promise<void> { + export async function write(p: string, content: string | Buffer | Uint8Array, mode?: number): Promise<void> { try { if (mode) { await writeFile(p, content, { mode }) @@ -68,6 +75,25 @@ export namespace Filesystem { return write(p, JSON.stringify(data, null, 2), mode) } + export async function writeStream( + p: string, + stream: ReadableStream<Uint8Array> | Readable, + mode?: number, + ): Promise<void> { + const dir = dirname(p) + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true }) + } + + const nodeStream = stream instanceof ReadableStream ? Readable.fromWeb(stream as any) : stream + const writeStream = createWriteStream(p) + await pipeline(nodeStream, writeStream) + + if (mode) { + await chmod(p, mode) + } + } + export function mimeType(p: string): string { return lookup(p) || "application/octet-stream" } diff --git a/packages/opencode/src/util/log.ts b/packages/opencode/src/util/log.ts index 6941310bb..c62d59299 100644 --- a/packages/opencode/src/util/log.ts +++ b/packages/opencode/src/util/log.ts @@ -1,5 +1,6 @@ import path from "path" import fs from "fs/promises" +import { createWriteStream } from "fs" import { Global } from "../global" import z from "zod" @@ -63,13 +64,15 @@ export namespace Log { Global.Path.log, options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log", ) - const logfile = Bun.file(logpath) await fs.truncate(logpath).catch(() => {}) - const writer = logfile.writer() + const stream = createWriteStream(logpath, { flags: "a" }) write = async (msg: any) => { - const num = writer.write(msg) - writer.flush() - return num + return new Promise((resolve, reject) => { + stream.write(msg, (err) => { + if (err) reject(err) + else resolve(msg.length) + }) + }) } } diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 836a3f5d1..56773570a 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -7,6 +7,7 @@ import path from "path" import fs from "fs/promises" import { pathToFileURL } from "url" import { Global } from "../../src/global" +import { Filesystem } from "../../src/util/filesystem" // Get managed config directory from environment (set in preload.ts) const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! @@ -17,11 +18,11 @@ afterEach(async () => { async function writeManagedSettings(settings: object, filename = "opencode.json") { await fs.mkdir(managedConfigDir, { recursive: true }) - await Bun.write(path.join(managedConfigDir, filename), JSON.stringify(settings)) + await Filesystem.write(path.join(managedConfigDir, filename), JSON.stringify(settings)) } async function writeConfig(dir: string, config: object, name = "opencode.json") { - await Bun.write(path.join(dir, name), JSON.stringify(config)) + await Filesystem.write(path.join(dir, name), JSON.stringify(config)) } test("loads config with defaults when no files exist", async () => { @@ -58,7 +59,7 @@ test("loads JSON config file", async () => { test("loads JSONC config file", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.jsonc"), `{ // This is a comment @@ -144,7 +145,7 @@ test("preserves env variables when adding $schema to config", async () => { await using tmp = await tmpdir({ init: async (dir) => { // Config without $schema - should trigger auto-add - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ theme: "{env:PRESERVE_VAR}", @@ -159,7 +160,7 @@ test("preserves env variables when adding $schema to config", async () => { expect(config.theme).toBe("secret_value") // Read the file to verify the env variable was preserved - const content = await Bun.file(path.join(tmp.path, "opencode.json")).text() + const content = await Filesystem.readText(path.join(tmp.path, "opencode.json")) expect(content).toContain("{env:PRESERVE_VAR}") expect(content).not.toContain("secret_value") expect(content).toContain("$schema") @@ -177,7 +178,7 @@ test("preserves env variables when adding $schema to config", async () => { test("handles file inclusion substitution", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write(path.join(dir, "included.txt"), "test_theme") + await Filesystem.write(path.join(dir, "included.txt"), "test_theme") await writeConfig(dir, { $schema: "https://opencode.ai/config.json", theme: "{file:included.txt}", @@ -196,7 +197,7 @@ test("handles file inclusion substitution", async () => { test("handles file inclusion with replacement tokens", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write(path.join(dir, "included.md"), "const out = await Bun.$`echo hi`") + await Filesystem.write(path.join(dir, "included.md"), "const out = await Bun.$`echo hi`") await writeConfig(dir, { $schema: "https://opencode.ai/config.json", theme: "{file:included.md}", @@ -233,7 +234,7 @@ test("validates config schema and throws on invalid fields", async () => { test("throws error for invalid JSON", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write(path.join(dir, "opencode.json"), "{ invalid json }") + await Filesystem.write(path.join(dir, "opencode.json"), "{ invalid json }") }, }) await Instance.provide({ @@ -336,7 +337,7 @@ test("handles command configuration", async () => { test("migrates autoshare to share field", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -358,7 +359,7 @@ test("migrates autoshare to share field", async () => { test("migrates mode field to agent field", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -395,7 +396,7 @@ test("loads config from .opencode directory", async () => { const agentDir = path.join(opencodeDir, "agent") await fs.mkdir(agentDir, { recursive: true }) - await Bun.write( + await Filesystem.write( path.join(agentDir, "test.md"), `--- model: test/model @@ -428,7 +429,7 @@ test("loads agents from .opencode/agents (plural)", async () => { const agentsDir = path.join(opencodeDir, "agents") await fs.mkdir(path.join(agentsDir, "nested"), { recursive: true }) - await Bun.write( + await Filesystem.write( path.join(agentsDir, "helper.md"), `--- model: test/model @@ -437,7 +438,7 @@ mode: subagent Helper agent prompt`, ) - await Bun.write( + await Filesystem.write( path.join(agentsDir, "nested", "child.md"), `--- model: test/model @@ -479,7 +480,7 @@ test("loads commands from .opencode/command (singular)", async () => { const commandDir = path.join(opencodeDir, "command") await fs.mkdir(path.join(commandDir, "nested"), { recursive: true }) - await Bun.write( + await Filesystem.write( path.join(commandDir, "hello.md"), `--- description: Test command @@ -487,7 +488,7 @@ description: Test command Hello from singular command`, ) - await Bun.write( + await Filesystem.write( path.join(commandDir, "nested", "child.md"), `--- description: Nested command @@ -524,7 +525,7 @@ test("loads commands from .opencode/commands (plural)", async () => { const commandsDir = path.join(opencodeDir, "commands") await fs.mkdir(path.join(commandsDir, "nested"), { recursive: true }) - await Bun.write( + await Filesystem.write( path.join(commandsDir, "hello.md"), `--- description: Test command @@ -532,7 +533,7 @@ description: Test command Hello from plural commands`, ) - await Bun.write( + await Filesystem.write( path.join(commandsDir, "nested", "child.md"), `--- description: Nested command @@ -568,7 +569,7 @@ test("updates config and writes to file", async () => { const newConfig = { model: "updated/model" } await Config.update(newConfig as any) - const writtenConfig = JSON.parse(await Bun.file(path.join(tmp.path, "config.json")).text()) + const writtenConfig = await Filesystem.readJson(path.join(tmp.path, "config.json")) expect(writtenConfig.model).toBe("updated/model") }, }) @@ -639,8 +640,8 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => { }, }) - expect(await Bun.file(path.join(tmp.extra, "package.json")).exists()).toBe(true) - expect(await Bun.file(path.join(tmp.extra, ".gitignore")).exists()).toBe(true) + expect(await Filesystem.exists(path.join(tmp.extra, "package.json"))).toBe(true) + expect(await Filesystem.exists(path.join(tmp.extra, ".gitignore"))).toBe(true) } finally { if (prev === undefined) delete process.env.OPENCODE_CONFIG_DIR else process.env.OPENCODE_CONFIG_DIR = prev @@ -653,12 +654,12 @@ test("resolves scoped npm plugins in config", async () => { const pluginDir = path.join(dir, "node_modules", "@scope", "plugin") await fs.mkdir(pluginDir, { recursive: true }) - await Bun.write( + await Filesystem.write( path.join(dir, "package.json"), JSON.stringify({ name: "config-fixture", version: "1.0.0", type: "module" }, null, 2), ) - await Bun.write( + await Filesystem.write( path.join(pluginDir, "package.json"), JSON.stringify( { @@ -672,9 +673,9 @@ test("resolves scoped npm plugins in config", async () => { ), ) - await Bun.write(path.join(pluginDir, "index.js"), "export default {}\n") + await Filesystem.write(path.join(pluginDir, "index.js"), "export default {}\n") - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", plugin: ["@scope/plugin"] }, null, 2), ) @@ -708,7 +709,7 @@ test("merges plugin arrays from global and local configs", async () => { await fs.mkdir(opencodeDir, { recursive: true }) // Global config with plugins - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -717,7 +718,7 @@ test("merges plugin arrays from global and local configs", async () => { ) // Local .opencode config with different plugins - await Bun.write( + await Filesystem.write( path.join(opencodeDir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -753,7 +754,7 @@ test("does not error when only custom agent is a subagent", async () => { const agentDir = path.join(opencodeDir, "agent") await fs.mkdir(agentDir, { recursive: true }) - await Bun.write( + await Filesystem.write( path.join(agentDir, "helper.md"), `--- model: test/model @@ -784,7 +785,7 @@ test("merges instructions arrays from global and local configs", async () => { const opencodeDir = path.join(projectDir, ".opencode") await fs.mkdir(opencodeDir, { recursive: true }) - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -792,7 +793,7 @@ test("merges instructions arrays from global and local configs", async () => { }), ) - await Bun.write( + await Filesystem.write( path.join(opencodeDir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -823,7 +824,7 @@ test("deduplicates duplicate instructions from global and local configs", async const opencodeDir = path.join(projectDir, ".opencode") await fs.mkdir(opencodeDir, { recursive: true }) - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -831,7 +832,7 @@ test("deduplicates duplicate instructions from global and local configs", async }), ) - await Bun.write( + await Filesystem.write( path.join(opencodeDir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -867,7 +868,7 @@ test("deduplicates duplicate plugins from global and local configs", async () => await fs.mkdir(opencodeDir, { recursive: true }) // Global config with plugins - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -876,7 +877,7 @@ test("deduplicates duplicate plugins from global and local configs", async () => ) // Local .opencode config with some overlapping plugins - await Bun.write( + await Filesystem.write( path.join(opencodeDir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -915,7 +916,7 @@ test("deduplicates duplicate plugins from global and local configs", async () => test("migrates legacy tools config to permissions - allow", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -946,7 +947,7 @@ test("migrates legacy tools config to permissions - allow", async () => { test("migrates legacy tools config to permissions - deny", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -977,7 +978,7 @@ test("migrates legacy tools config to permissions - deny", async () => { test("migrates legacy write tool to edit permission", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -1086,7 +1087,7 @@ test("missing managed settings file is not an error", async () => { test("migrates legacy edit tool to edit permission", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -1115,7 +1116,7 @@ test("migrates legacy edit tool to edit permission", async () => { test("migrates legacy patch tool to edit permission", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -1144,7 +1145,7 @@ test("migrates legacy patch tool to edit permission", async () => { test("migrates legacy multiedit tool to edit permission", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -1173,7 +1174,7 @@ test("migrates legacy multiedit tool to edit permission", async () => { test("migrates mixed legacy tools config", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -1208,7 +1209,7 @@ test("migrates mixed legacy tools config", async () => { test("merges legacy tools with existing permission config", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -1241,7 +1242,7 @@ test("merges legacy tools with existing permission config", async () => { test("permission config preserves key order", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -1289,7 +1290,7 @@ test("project config can override MCP server enabled status", async () => { await using tmp = await tmpdir({ init: async (dir) => { // Simulates a base config (like from remote .well-known) with disabled MCP - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.jsonc"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -1308,7 +1309,7 @@ test("project config can override MCP server enabled status", async () => { }), ) // Project config enables just jira - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -1347,7 +1348,7 @@ test("MCP config deep merges preserving base config properties", async () => { await using tmp = await tmpdir({ init: async (dir) => { // Base config with full MCP definition - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.jsonc"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -1364,7 +1365,7 @@ test("MCP config deep merges preserving base config properties", async () => { }), ) // Override just enables it, should preserve other properties - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -1399,7 +1400,7 @@ test("local .opencode config can override MCP from project config", async () => await using tmp = await tmpdir({ init: async (dir) => { // Project config with disabled MCP - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -1415,7 +1416,7 @@ test("local .opencode config can override MCP from project config", async () => // Local .opencode directory config enables it const opencodeDir = path.join(dir, ".opencode") await fs.mkdir(opencodeDir, { recursive: true }) - await Bun.write( + await Filesystem.write( path.join(opencodeDir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -1483,7 +1484,7 @@ test("project config overrides remote well-known config", async () => { git: true, init: async (dir) => { // Project config enables jira (overriding remote default) - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -1576,7 +1577,7 @@ describe("deduplicatePlugins", () => { const pluginDir = path.join(opencodeDir, "plugin") await fs.mkdir(pluginDir, { recursive: true }) - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -1584,7 +1585,7 @@ describe("deduplicatePlugins", () => { }), ) - await Bun.write(path.join(pluginDir, "my-plugin.js"), "export default {}") + await Filesystem.write(path.join(pluginDir, "my-plugin.js"), "export default {}") }, }) @@ -1611,7 +1612,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { await using tmp = await tmpdir({ init: async (dir) => { // Create a project config that would normally be loaded - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -1649,7 +1650,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { // Create a .opencode directory with a command const opencodeDir = path.join(dir, ".opencode", "command") await fs.mkdir(opencodeDir, { recursive: true }) - await Bun.write(path.join(opencodeDir, "test-cmd.md"), "# Test Command\nThis is a test command.") + await Filesystem.write(path.join(opencodeDir, "test-cmd.md"), "# Test Command\nThis is a test command.") }, }) await Instance.provide({ @@ -1706,7 +1707,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { await using tmp = await tmpdir({ init: async (dir) => { // Create a config with relative instruction path - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -1714,7 +1715,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { }), ) // Create the instruction file (should be skipped) - await Bun.write(path.join(dir, "CUSTOM.md"), "# Custom Instructions") + await Filesystem.write(path.join(dir, "CUSTOM.md"), "# Custom Instructions") }, }) @@ -1752,7 +1753,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { await using configDirTmp = await tmpdir({ init: async (dir) => { // Create config in the custom config dir - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -1765,7 +1766,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { await using projectTmp = await tmpdir({ init: async (dir) => { // Create config in project (should be ignored) - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index 053a64e20..f269926b5 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -3,11 +3,12 @@ import path from "path" import fs from "fs/promises" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" +import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" -describe("file/index Bun.file patterns", () => { +describe("file/index Filesystem patterns", () => { describe("File.read() - text content", () => { - test("reads text file via Bun.file().text()", async () => { + test("reads text file via Filesystem.readText()", async () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "test.txt") await fs.writeFile(filepath, "Hello World", "utf-8") @@ -22,7 +23,7 @@ describe("file/index Bun.file patterns", () => { }) }) - test("reads with Bun.file().exists() check", async () => { + test("reads with Filesystem.exists() check", async () => { await using tmp = await tmpdir() await Instance.provide({ @@ -81,7 +82,7 @@ describe("file/index Bun.file patterns", () => { }) describe("File.read() - binary content", () => { - test("reads binary file via Bun.file().arrayBuffer()", async () => { + test("reads binary file via Filesystem.readArrayBuffer()", async () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "image.png") const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) @@ -115,8 +116,8 @@ describe("file/index Bun.file patterns", () => { }) }) - describe("File.read() - Bun.file().type", () => { - test("detects MIME type via Bun.file().type", async () => { + describe("File.read() - Filesystem.mimeType()", () => { + test("detects MIME type via Filesystem.mimeType()", async () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "test.json") await fs.writeFile(filepath, '{"key": "value"}', "utf-8") @@ -124,8 +125,7 @@ describe("file/index Bun.file patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bunFile = Bun.file(filepath) - expect(bunFile.type).toContain("application/json") + expect(Filesystem.mimeType(filepath)).toContain("application/json") const result = await File.read("test.json") expect(result.type).toBe("text") @@ -149,16 +149,15 @@ describe("file/index Bun.file patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bunFile = Bun.file(filepath) - expect(bunFile.type).toContain(mime) + expect(Filesystem.mimeType(filepath)).toContain(mime) }, }) } }) }) - describe("File.list() - Bun.file().exists() and .text()", () => { - test("reads .gitignore via Bun.file().exists() and .text()", async () => { + describe("File.list() - Filesystem.exists() and readText()", () => { + test("reads .gitignore via Filesystem.exists() and readText()", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ @@ -168,10 +167,9 @@ describe("file/index Bun.file patterns", () => { await fs.writeFile(gitignorePath, "node_modules\ndist\n", "utf-8") // This is used internally in File.list() - const bunFile = Bun.file(gitignorePath) - expect(await bunFile.exists()).toBe(true) + expect(await Filesystem.exists(gitignorePath)).toBe(true) - const content = await bunFile.text() + const content = await Filesystem.readText(gitignorePath) expect(content).toContain("node_modules") }, }) @@ -186,9 +184,8 @@ describe("file/index Bun.file patterns", () => { const ignorePath = path.join(tmp.path, ".ignore") await fs.writeFile(ignorePath, "*.log\n.env\n", "utf-8") - const bunFile = Bun.file(ignorePath) - expect(await bunFile.exists()).toBe(true) - expect(await bunFile.text()).toContain("*.log") + expect(await Filesystem.exists(ignorePath)).toBe(true) + expect(await Filesystem.readText(ignorePath)).toContain("*.log") }, }) }) @@ -200,8 +197,7 @@ describe("file/index Bun.file patterns", () => { directory: tmp.path, fn: async () => { const gitignorePath = path.join(tmp.path, ".gitignore") - const bunFile = Bun.file(gitignorePath) - expect(await bunFile.exists()).toBe(false) + expect(await Filesystem.exists(gitignorePath)).toBe(false) // File.list() should still work const nodes = await File.list() @@ -211,8 +207,8 @@ describe("file/index Bun.file patterns", () => { }) }) - describe("File.changed() - Bun.file().text() for untracked files", () => { - test("reads untracked files via Bun.file().text()", async () => { + describe("File.changed() - Filesystem.readText() for untracked files", () => { + test("reads untracked files via Filesystem.readText()", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ @@ -222,8 +218,7 @@ describe("file/index Bun.file patterns", () => { await fs.writeFile(untrackedPath, "new content\nwith multiple lines", "utf-8") // This is how File.changed() reads untracked files - const bunFile = Bun.file(untrackedPath) - const content = await bunFile.text() + const content = await Filesystem.readText(untrackedPath) const lines = content.split("\n").length expect(lines).toBe(2) }, @@ -232,7 +227,7 @@ describe("file/index Bun.file patterns", () => { }) describe("Error handling", () => { - test("handles errors gracefully in Bun.file().text()", async () => { + test("handles errors gracefully in Filesystem.readText()", async () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "readonly.txt") await fs.writeFile(filepath, "content", "utf-8") @@ -240,9 +235,9 @@ describe("file/index Bun.file patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const nonExistentFile = Bun.file(path.join(tmp.path, "does-not-exist.txt")) - // Bun.file().text() on non-existent file throws - await expect(nonExistentFile.text()).rejects.toThrow() + const nonExistentPath = path.join(tmp.path, "does-not-exist.txt") + // Filesystem.readText() on non-existent file throws + await expect(Filesystem.readText(nonExistentPath)).rejects.toThrow() // But File.read() handles this gracefully const result = await File.read("does-not-exist.txt") @@ -251,14 +246,14 @@ describe("file/index Bun.file patterns", () => { }) }) - test("handles errors in Bun.file().arrayBuffer()", async () => { + test("handles errors in Filesystem.readArrayBuffer()", async () => { await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, fn: async () => { - const nonExistentFile = Bun.file(path.join(tmp.path, "does-not-exist.bin")) - const buffer = await nonExistentFile.arrayBuffer().catch(() => new ArrayBuffer(0)) + const nonExistentPath = path.join(tmp.path, "does-not-exist.bin") + const buffer = await Filesystem.readArrayBuffer(nonExistentPath).catch(() => new ArrayBuffer(0)) expect(buffer.byteLength).toBe(0) }, }) @@ -272,7 +267,6 @@ describe("file/index Bun.file patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bunFile = Bun.file(filepath) // File.read() handles missing images gracefully const result = await File.read("broken.png") expect(result.type).toBe("text") diff --git a/packages/opencode/test/file/time.test.ts b/packages/opencode/test/file/time.test.ts index ab7451276..e46d5229b 100644 --- a/packages/opencode/test/file/time.test.ts +++ b/packages/opencode/test/file/time.test.ts @@ -3,6 +3,7 @@ import path from "path" import fs from "fs/promises" import { FileTime } from "../../src/file/time" import { Instance } from "../../src/project/instance" +import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" describe("file/time", () => { @@ -312,8 +313,8 @@ describe("file/time", () => { }) }) - describe("stat() Bun.file pattern", () => { - test("reads file modification time via Bun.file().stat()", async () => { + describe("stat() Filesystem.stat pattern", () => { + test("reads file modification time via Filesystem.stat()", async () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "content", "utf-8") @@ -323,9 +324,9 @@ describe("file/time", () => { fn: async () => { FileTime.read(sessionID, filepath) - const stats = await Bun.file(filepath).stat() - expect(stats.mtime).toBeInstanceOf(Date) - expect(stats.mtime.getTime()).toBeGreaterThan(0) + const stats = Filesystem.stat(filepath) + expect(stats?.mtime).toBeInstanceOf(Date) + expect(stats!.mtime.getTime()).toBeGreaterThan(0) // FileTime.assert uses this stat internally await FileTime.assert(sessionID, filepath) @@ -343,14 +344,14 @@ describe("file/time", () => { fn: async () => { FileTime.read(sessionID, filepath) - const originalStat = await Bun.file(filepath).stat() + const originalStat = Filesystem.stat(filepath) // Wait and modify await new Promise((resolve) => setTimeout(resolve, 100)) await fs.writeFile(filepath, "modified", "utf-8") - const newStat = await Bun.file(filepath).stat() - expect(newStat.mtime.getTime()).toBeGreaterThan(originalStat.mtime.getTime()) + const newStat = Filesystem.stat(filepath) + expect(newStat!.mtime.getTime()).toBeGreaterThan(originalStat!.mtime.getTime()) await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow() }, diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 19f9821c4..fef9e4190 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -4,6 +4,7 @@ import { Log } from "../../src/util/log" import { $ } from "bun" import path from "path" import { tmpdir } from "../fixture/fixture" +import { Filesystem } from "../../src/util/filesystem" import { GlobalBus } from "../../src/bus/global" Log.init({ print: false }) @@ -78,7 +79,7 @@ describe("Project.fromDirectory", () => { expect(project.worktree).toBe(tmp.path) const opencodeFile = path.join(tmp.path, ".git", "opencode") - const fileExists = await Bun.file(opencodeFile).exists() + const fileExists = await Filesystem.exists(opencodeFile) expect(fileExists).toBe(false) }) @@ -94,7 +95,7 @@ describe("Project.fromDirectory", () => { expect(project.worktree).toBe(tmp.path) const opencodeFile = path.join(tmp.path, ".git", "opencode") - const fileExists = await Bun.file(opencodeFile).exists() + const fileExists = await Filesystem.exists(opencodeFile) expect(fileExists).toBe(true) }) diff --git a/packages/opencode/test/project/worktree-remove.test.ts b/packages/opencode/test/project/worktree-remove.test.ts index 32d38fe84..e17a5392b 100644 --- a/packages/opencode/test/project/worktree-remove.test.ts +++ b/packages/opencode/test/project/worktree-remove.test.ts @@ -4,6 +4,7 @@ import fs from "fs/promises" import path from "path" import { Instance } from "../../src/project/instance" import { Worktree } from "../../src/worktree" +import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" describe("Worktree.remove", () => { @@ -53,7 +54,7 @@ describe("Worktree.remove", () => { })() expect(ok).toBe(true) - expect(await Bun.file(dir).exists()).toBe(false) + expect(await Filesystem.exists(dir)).toBe(false) const list = await $`git worktree list --porcelain`.cwd(root).quiet().text() expect(list).not.toContain(`worktree ${dir}`) diff --git a/packages/opencode/test/provider/amazon-bedrock.test.ts b/packages/opencode/test/provider/amazon-bedrock.test.ts index d1d3cc41c..cb64455b4 100644 --- a/packages/opencode/test/provider/amazon-bedrock.test.ts +++ b/packages/opencode/test/provider/amazon-bedrock.test.ts @@ -7,11 +7,12 @@ import { Instance } from "../../src/project/instance" import { Provider } from "../../src/provider/provider" import { Env } from "../../src/env" import { Global } from "../../src/global" +import { Filesystem } from "../../src/util/filesystem" test("Bedrock: config region takes precedence over AWS_REGION env var", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -43,7 +44,7 @@ test("Bedrock: config region takes precedence over AWS_REGION env var", async () test("Bedrock: falls back to AWS_REGION env var when no config region", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -68,7 +69,7 @@ test("Bedrock: falls back to AWS_REGION env var when no config region", async () test("Bedrock: loads when bearer token from auth.json is present", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -89,14 +90,14 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => { // Save original auth.json if it exists let originalAuth: string | undefined try { - originalAuth = await Bun.file(authPath).text() + originalAuth = await Filesystem.readText(authPath) } catch { // File doesn't exist, that's fine } try { // Write test auth.json - await Bun.write( + await Filesystem.write( authPath, JSON.stringify({ "amazon-bedrock": { @@ -122,7 +123,7 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => { } finally { // Restore original or delete if (originalAuth !== undefined) { - await Bun.write(authPath, originalAuth) + await Filesystem.write(authPath, originalAuth) } else { try { await unlink(authPath) @@ -136,7 +137,7 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => { test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -169,7 +170,7 @@ test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async test("Bedrock: includes custom endpoint in options when specified", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -202,7 +203,7 @@ test("Bedrock: includes custom endpoint in options when specified", async () => test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -240,7 +241,7 @@ test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async () test("Bedrock: model with us. prefix should not be double-prefixed", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -277,7 +278,7 @@ test("Bedrock: model with us. prefix should not be double-prefixed", async () => test("Bedrock: model with global. prefix should not be prefixed", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -313,7 +314,7 @@ test("Bedrock: model with global. prefix should not be prefixed", async () => { test("Bedrock: model with eu. prefix should not be double-prefixed", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", @@ -349,7 +350,7 @@ test("Bedrock: model with eu. prefix should not be double-prefixed", async () => test("Bedrock: model without prefix in US region should get us. prefix added", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( + await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index be0b8e520..d7af9908f 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -7,6 +7,7 @@ import { Instance } from "../../src/project/instance" import { Provider } from "../../src/provider/provider" import { ProviderTransform } from "../../src/provider/transform" import { ModelsDev } from "../../src/provider/models" +import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" import type { Agent } from "../../src/agent/agent" import type { MessageV2 } from "../../src/session/message-v2" @@ -185,7 +186,7 @@ function createChatStream(text: string) { async function loadFixture(providerID: string, modelID: string) { const fixturePath = path.join(import.meta.dir, "../tool/fixtures/models-api.json") - const data = (await Bun.file(fixturePath).json()) as Record<string, ModelsDev.Provider> + const data = await Filesystem.readJson<Record<string, ModelsDev.Provider>>(fixturePath) const provider = data[providerID] if (!provider) { throw new Error(`Missing provider in fixture: ${providerID}`) diff --git a/packages/opencode/test/skill/discovery.test.ts b/packages/opencode/test/skill/discovery.test.ts index 90759fa3c..f78c6623b 100644 --- a/packages/opencode/test/skill/discovery.test.ts +++ b/packages/opencode/test/skill/discovery.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect } from "bun:test" import { Discovery } from "../../src/skill/discovery" +import { Filesystem } from "../../src/util/filesystem" import path from "path" const CLOUDFLARE_SKILLS_URL = "https://developers.cloudflare.com/.well-known/skills/" @@ -11,7 +12,7 @@ describe("Discovery.pull", () => { for (const dir of dirs) { expect(dir).toStartWith(Discovery.dir()) const md = path.join(dir, "SKILL.md") - expect(await Bun.file(md).exists()).toBe(true) + expect(await Filesystem.exists(md)).toBe(true) } }, 30_000) @@ -20,7 +21,7 @@ describe("Discovery.pull", () => { expect(dirs.length).toBeGreaterThan(0) for (const dir of dirs) { const md = path.join(dir, "SKILL.md") - expect(await Bun.file(md).exists()).toBe(true) + expect(await Filesystem.exists(md)).toBe(true) } }, 30_000) @@ -40,7 +41,7 @@ describe("Discovery.pull", () => { const agentsSdk = dirs.find((d) => d.endsWith("/agents-sdk")) if (agentsSdk) { const refs = path.join(agentsSdk, "references") - expect(await Bun.file(path.join(agentsSdk, "SKILL.md")).exists()).toBe(true) + expect(await Filesystem.exists(path.join(agentsSdk, "SKILL.md"))).toBe(true) // agents-sdk has reference files per the index const refDir = await Array.fromAsync(new Bun.Glob("**/*.md").scan({ cwd: refs, onlyFiles: true })) expect(refDir.length).toBeGreaterThan(0) diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index 091469ec7..b54cb8b8a 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -1,7 +1,9 @@ import { test, expect } from "bun:test" import { $ } from "bun" +import fs from "fs/promises" import { Snapshot } from "../../src/snapshot" import { Instance } from "../../src/project/instance" +import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" async function bootstrap() { @@ -11,8 +13,8 @@ async function bootstrap() { const unique = Math.random().toString(36).slice(2) const aContent = `A${unique}` const bContent = `B${unique}` - await Bun.write(`${dir}/a.txt`, aContent) - await Bun.write(`${dir}/b.txt`, bContent) + await Filesystem.write(`${dir}/a.txt`, aContent) + await Filesystem.write(`${dir}/b.txt`, bContent) await $`git add .`.cwd(dir).quiet() await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet() return { @@ -46,11 +48,16 @@ test("revert should remove new files", async () => { const before = await Snapshot.track() expect(before).toBeTruthy() - await Bun.write(`${tmp.path}/new.txt`, "NEW") + await Filesystem.write(`${tmp.path}/new.txt`, "NEW") await Snapshot.revert([await Snapshot.patch(before!)]) - expect(await Bun.file(`${tmp.path}/new.txt`).exists()).toBe(false) + expect( + await fs + .access(`${tmp.path}/new.txt`) + .then(() => true) + .catch(() => false), + ).toBe(false) }, }) }) @@ -64,11 +71,16 @@ test("revert in subdirectory", async () => { expect(before).toBeTruthy() await $`mkdir -p ${tmp.path}/sub`.quiet() - await Bun.write(`${tmp.path}/sub/file.txt`, "SUB") + await Filesystem.write(`${tmp.path}/sub/file.txt`, "SUB") await Snapshot.revert([await Snapshot.patch(before!)]) - expect(await Bun.file(`${tmp.path}/sub/file.txt`).exists()).toBe(false) + expect( + await fs + .access(`${tmp.path}/sub/file.txt`) + .then(() => true) + .catch(() => false), + ).toBe(false) // Note: revert currently only removes files, not directories // The empty subdirectory will remain }, @@ -84,18 +96,23 @@ test("multiple file operations", async () => { expect(before).toBeTruthy() await $`rm ${tmp.path}/a.txt`.quiet() - await Bun.write(`${tmp.path}/c.txt`, "C") + await Filesystem.write(`${tmp.path}/c.txt`, "C") await $`mkdir -p ${tmp.path}/dir`.quiet() - await Bun.write(`${tmp.path}/dir/d.txt`, "D") - await Bun.write(`${tmp.path}/b.txt`, "MODIFIED") + await Filesystem.write(`${tmp.path}/dir/d.txt`, "D") + await Filesystem.write(`${tmp.path}/b.txt`, "MODIFIED") await Snapshot.revert([await Snapshot.patch(before!)]) - expect(await Bun.file(`${tmp.path}/a.txt`).text()).toBe(tmp.extra.aContent) - expect(await Bun.file(`${tmp.path}/c.txt`).exists()).toBe(false) + expect(await fs.readFile(`${tmp.path}/a.txt`, "utf-8")).toBe(tmp.extra.aContent) + expect( + await fs + .access(`${tmp.path}/c.txt`) + .then(() => true) + .catch(() => false), + ).toBe(false) // Note: revert currently only removes files, not directories // The empty directory will remain - expect(await Bun.file(`${tmp.path}/b.txt`).text()).toBe(tmp.extra.bContent) + expect(await fs.readFile(`${tmp.path}/b.txt`, "utf-8")).toBe(tmp.extra.bContent) }, }) }) @@ -123,13 +140,18 @@ test("binary file handling", async () => { const before = await Snapshot.track() expect(before).toBeTruthy() - await Bun.write(`${tmp.path}/image.png`, new Uint8Array([0x89, 0x50, 0x4e, 0x47])) + await Filesystem.write(`${tmp.path}/image.png`, new Uint8Array([0x89, 0x50, 0x4e, 0x47])) const patch = await Snapshot.patch(before!) expect(patch.files).toContain(`${tmp.path}/image.png`) await Snapshot.revert([patch]) - expect(await Bun.file(`${tmp.path}/image.png`).exists()).toBe(false) + expect( + await fs + .access(`${tmp.path}/image.png`) + .then(() => true) + .catch(() => false), + ).toBe(false) }, }) }) @@ -157,7 +179,7 @@ test("large file handling", async () => { const before = await Snapshot.track() expect(before).toBeTruthy() - await Bun.write(`${tmp.path}/large.txt`, "x".repeat(1024 * 1024)) + await Filesystem.write(`${tmp.path}/large.txt`, "x".repeat(1024 * 1024)) expect((await Snapshot.patch(before!)).files).toContain(`${tmp.path}/large.txt`) }, @@ -173,11 +195,16 @@ test("nested directory revert", async () => { expect(before).toBeTruthy() await $`mkdir -p ${tmp.path}/level1/level2/level3`.quiet() - await Bun.write(`${tmp.path}/level1/level2/level3/deep.txt`, "DEEP") + await Filesystem.write(`${tmp.path}/level1/level2/level3/deep.txt`, "DEEP") await Snapshot.revert([await Snapshot.patch(before!)]) - expect(await Bun.file(`${tmp.path}/level1/level2/level3/deep.txt`).exists()).toBe(false) + expect( + await fs + .access(`${tmp.path}/level1/level2/level3/deep.txt`) + .then(() => true) + .catch(() => false), + ).toBe(false) }, }) }) @@ -190,9 +217,9 @@ test("special characters in filenames", async () => { const before = await Snapshot.track() expect(before).toBeTruthy() - await Bun.write(`${tmp.path}/file with spaces.txt`, "SPACES") - await Bun.write(`${tmp.path}/file-with-dashes.txt`, "DASHES") - await Bun.write(`${tmp.path}/file_with_underscores.txt`, "UNDERSCORES") + await Filesystem.write(`${tmp.path}/file with spaces.txt`, "SPACES") + await Filesystem.write(`${tmp.path}/file-with-dashes.txt`, "DASHES") + await Filesystem.write(`${tmp.path}/file_with_underscores.txt`, "UNDERSCORES") const files = (await Snapshot.patch(before!)).files expect(files).toContain(`${tmp.path}/file with spaces.txt`) @@ -225,7 +252,7 @@ test("patch with invalid hash", async () => { expect(before).toBeTruthy() // Create a change - await Bun.write(`${tmp.path}/test.txt`, "TEST") + await Filesystem.write(`${tmp.path}/test.txt`, "TEST") // Try to patch with invalid hash - should handle gracefully const patch = await Snapshot.patch("invalid-hash-12345") @@ -273,7 +300,7 @@ test("unicode filenames", async () => { ] for (const file of unicodeFiles) { - await Bun.write(file.path, file.content) + await Filesystem.write(file.path, file.content) } const patch = await Snapshot.patch(before!) @@ -286,7 +313,12 @@ test("unicode filenames", async () => { await Snapshot.revert([patch]) for (const file of unicodeFiles) { - expect(await Bun.file(file.path).exists()).toBe(false) + expect( + await fs + .access(file.path) + .then(() => true) + .catch(() => false), + ).toBe(false) } }, }) @@ -300,14 +332,14 @@ test.skip("unicode filenames modification and restore", async () => { const chineseFile = `${tmp.path}/文件.txt` const cyrillicFile = `${tmp.path}/файл.txt` - await Bun.write(chineseFile, "original chinese") - await Bun.write(cyrillicFile, "original cyrillic") + await Filesystem.write(chineseFile, "original chinese") + await Filesystem.write(cyrillicFile, "original cyrillic") const before = await Snapshot.track() expect(before).toBeTruthy() - await Bun.write(chineseFile, "modified chinese") - await Bun.write(cyrillicFile, "modified cyrillic") + await Filesystem.write(chineseFile, "modified chinese") + await Filesystem.write(cyrillicFile, "modified cyrillic") const patch = await Snapshot.patch(before!) expect(patch.files).toContain(chineseFile) @@ -315,8 +347,8 @@ test.skip("unicode filenames modification and restore", async () => { await Snapshot.revert([patch]) - expect(await Bun.file(chineseFile).text()).toBe("original chinese") - expect(await Bun.file(cyrillicFile).text()).toBe("original cyrillic") + expect(await fs.readFile(chineseFile, "utf-8")).toBe("original chinese") + expect(await fs.readFile(cyrillicFile, "utf-8")).toBe("original cyrillic") }, }) }) @@ -331,13 +363,18 @@ test("unicode filenames in subdirectories", async () => { await $`mkdir -p "${tmp.path}/目录/подкаталог"`.quiet() const deepFile = `${tmp.path}/目录/подкаталог/文件.txt` - await Bun.write(deepFile, "deep unicode content") + await Filesystem.write(deepFile, "deep unicode content") const patch = await Snapshot.patch(before!) expect(patch.files).toContain(deepFile) await Snapshot.revert([patch]) - expect(await Bun.file(deepFile).exists()).toBe(false) + expect( + await fs + .access(deepFile) + .then(() => true) + .catch(() => false), + ).toBe(false) }, }) }) @@ -353,13 +390,18 @@ test("very long filenames", async () => { const longName = "a".repeat(200) + ".txt" const longFile = `${tmp.path}/${longName}` - await Bun.write(longFile, "long filename content") + await Filesystem.write(longFile, "long filename content") const patch = await Snapshot.patch(before!) expect(patch.files).toContain(longFile) await Snapshot.revert([patch]) - expect(await Bun.file(longFile).exists()).toBe(false) + expect( + await fs + .access(longFile) + .then(() => true) + .catch(() => false), + ).toBe(false) }, }) }) @@ -372,9 +414,9 @@ test("hidden files", async () => { const before = await Snapshot.track() expect(before).toBeTruthy() - await Bun.write(`${tmp.path}/.hidden`, "hidden content") - await Bun.write(`${tmp.path}/.gitignore`, "*.log") - await Bun.write(`${tmp.path}/.config`, "config content") + await Filesystem.write(`${tmp.path}/.hidden`, "hidden content") + await Filesystem.write(`${tmp.path}/.gitignore`, "*.log") + await Filesystem.write(`${tmp.path}/.config`, "config content") const patch = await Snapshot.patch(before!) expect(patch.files).toContain(`${tmp.path}/.hidden`) @@ -393,7 +435,7 @@ test("nested symlinks", async () => { expect(before).toBeTruthy() await $`mkdir -p ${tmp.path}/sub/dir`.quiet() - await Bun.write(`${tmp.path}/sub/dir/target.txt`, "target content") + await Filesystem.write(`${tmp.path}/sub/dir/target.txt`, "target content") await $`ln -s ${tmp.path}/sub/dir/target.txt ${tmp.path}/sub/dir/link.txt`.quiet() await $`ln -s ${tmp.path}/sub ${tmp.path}/sub-link`.quiet() @@ -450,9 +492,9 @@ test("gitignore changes", async () => { const before = await Snapshot.track() expect(before).toBeTruthy() - await Bun.write(`${tmp.path}/.gitignore`, "*.ignored") - await Bun.write(`${tmp.path}/test.ignored`, "ignored content") - await Bun.write(`${tmp.path}/normal.txt`, "normal content") + await Filesystem.write(`${tmp.path}/.gitignore`, "*.ignored") + await Filesystem.write(`${tmp.path}/test.ignored`, "ignored content") + await Filesystem.write(`${tmp.path}/normal.txt`, "normal content") const patch = await Snapshot.patch(before!) @@ -477,7 +519,7 @@ test("concurrent file operations during patch", async () => { // Start creating files const createPromise = (async () => { for (let i = 0; i < 10; i++) { - await Bun.write(`${tmp.path}/concurrent${i}.txt`, `concurrent${i}`) + await Filesystem.write(`${tmp.path}/concurrent${i}.txt`, `concurrent${i}`) // Small delay to simulate concurrent operations await new Promise((resolve) => setTimeout(resolve, 1)) } @@ -504,7 +546,7 @@ test("snapshot state isolation between projects", async () => { directory: tmp1.path, fn: async () => { const before1 = await Snapshot.track() - await Bun.write(`${tmp1.path}/project1.txt`, "project1 content") + await Filesystem.write(`${tmp1.path}/project1.txt`, "project1 content") const patch1 = await Snapshot.patch(before1!) expect(patch1.files).toContain(`${tmp1.path}/project1.txt`) }, @@ -514,7 +556,7 @@ test("snapshot state isolation between projects", async () => { directory: tmp2.path, fn: async () => { const before2 = await Snapshot.track() - await Bun.write(`${tmp2.path}/project2.txt`, "project2 content") + await Filesystem.write(`${tmp2.path}/project2.txt`, "project2 content") const patch2 = await Snapshot.patch(before2!) expect(patch2.files).toContain(`${tmp2.path}/project2.txt`) @@ -544,7 +586,7 @@ test("patch detects changes in secondary worktree", async () => { expect(before).toBeTruthy() const worktreeFile = `${worktreePath}/worktree.txt` - await Bun.write(worktreeFile, "worktree content") + await Filesystem.write(worktreeFile, "worktree content") const patch = await Snapshot.patch(before!) expect(patch.files).toContain(worktreeFile) @@ -569,7 +611,7 @@ test("revert only removes files in invoking worktree", async () => { }, }) const primaryFile = `${tmp.path}/worktree.txt` - await Bun.write(primaryFile, "primary content") + await Filesystem.write(primaryFile, "primary content") await Instance.provide({ directory: worktreePath, @@ -578,16 +620,21 @@ test("revert only removes files in invoking worktree", async () => { expect(before).toBeTruthy() const worktreeFile = `${worktreePath}/worktree.txt` - await Bun.write(worktreeFile, "worktree content") + await Filesystem.write(worktreeFile, "worktree content") const patch = await Snapshot.patch(before!) await Snapshot.revert([patch]) - expect(await Bun.file(worktreeFile).exists()).toBe(false) + expect( + await fs + .access(worktreeFile) + .then(() => true) + .catch(() => false), + ).toBe(false) }, }) - expect(await Bun.file(primaryFile).text()).toBe("primary content") + expect(await fs.readFile(primaryFile, "utf-8")).toBe("primary content") } finally { await $`git worktree remove --force ${worktreePath}`.cwd(tmp.path).quiet().nothrow() await $`rm -rf ${worktreePath}`.quiet() @@ -614,10 +661,10 @@ test("diff reports worktree-only/shared edits and ignores primary-only", async ( const before = await Snapshot.track() expect(before).toBeTruthy() - await Bun.write(`${worktreePath}/worktree-only.txt`, "worktree diff content") - await Bun.write(`${worktreePath}/shared.txt`, "worktree edit") - await Bun.write(`${tmp.path}/shared.txt`, "primary edit") - await Bun.write(`${tmp.path}/primary-only.txt`, "primary change") + await Filesystem.write(`${worktreePath}/worktree-only.txt`, "worktree diff content") + await Filesystem.write(`${worktreePath}/shared.txt`, "worktree edit") + await Filesystem.write(`${tmp.path}/shared.txt`, "primary edit") + await Filesystem.write(`${tmp.path}/primary-only.txt`, "primary change") const diff = await Snapshot.diff(before!) expect(diff).toContain("worktree-only.txt") @@ -662,8 +709,8 @@ test("diff function with various changes", async () => { // Make various changes await $`rm ${tmp.path}/a.txt`.quiet() - await Bun.write(`${tmp.path}/new.txt`, "new content") - await Bun.write(`${tmp.path}/b.txt`, "modified content") + await Filesystem.write(`${tmp.path}/new.txt`, "new content") + await Filesystem.write(`${tmp.path}/b.txt`, "modified content") const diff = await Snapshot.diff(before!) expect(diff).toContain("a.txt") @@ -683,16 +730,26 @@ test("restore function", async () => { // Make changes await $`rm ${tmp.path}/a.txt`.quiet() - await Bun.write(`${tmp.path}/new.txt`, "new content") - await Bun.write(`${tmp.path}/b.txt`, "modified") + await Filesystem.write(`${tmp.path}/new.txt`, "new content") + await Filesystem.write(`${tmp.path}/b.txt`, "modified") // Restore to original state await Snapshot.restore(before!) - expect(await Bun.file(`${tmp.path}/a.txt`).exists()).toBe(true) - expect(await Bun.file(`${tmp.path}/a.txt`).text()).toBe(tmp.extra.aContent) - expect(await Bun.file(`${tmp.path}/new.txt`).exists()).toBe(true) // New files should remain - expect(await Bun.file(`${tmp.path}/b.txt`).text()).toBe(tmp.extra.bContent) + expect( + await fs + .access(`${tmp.path}/a.txt`) + .then(() => true) + .catch(() => false), + ).toBe(true) + expect(await fs.readFile(`${tmp.path}/a.txt`, "utf-8")).toBe(tmp.extra.aContent) + expect( + await fs + .access(`${tmp.path}/new.txt`) + .then(() => true) + .catch(() => false), + ).toBe(true) // New files should remain + expect(await fs.readFile(`${tmp.path}/b.txt`, "utf-8")).toBe(tmp.extra.bContent) }, }) }) @@ -710,14 +767,19 @@ test("revert should not delete files that existed but were deleted in snapshot", const snapshot2 = await Snapshot.track() expect(snapshot2).toBeTruthy() - await Bun.write(`${tmp.path}/a.txt`, "recreated content") + await Filesystem.write(`${tmp.path}/a.txt`, "recreated content") const patch = await Snapshot.patch(snapshot2!) expect(patch.files).toContain(`${tmp.path}/a.txt`) await Snapshot.revert([patch]) - expect(await Bun.file(`${tmp.path}/a.txt`).exists()).toBe(false) + expect( + await fs + .access(`${tmp.path}/a.txt`) + .then(() => true) + .catch(() => false), + ).toBe(false) }, }) }) @@ -727,14 +789,14 @@ test("revert preserves file that existed in snapshot when deleted then recreated await Instance.provide({ directory: tmp.path, fn: async () => { - await Bun.write(`${tmp.path}/existing.txt`, "original content") + await Filesystem.write(`${tmp.path}/existing.txt`, "original content") const snapshot = await Snapshot.track() expect(snapshot).toBeTruthy() await $`rm ${tmp.path}/existing.txt`.quiet() - await Bun.write(`${tmp.path}/existing.txt`, "recreated") - await Bun.write(`${tmp.path}/newfile.txt`, "new") + await Filesystem.write(`${tmp.path}/existing.txt`, "recreated") + await Filesystem.write(`${tmp.path}/newfile.txt`, "new") const patch = await Snapshot.patch(snapshot!) expect(patch.files).toContain(`${tmp.path}/existing.txt`) @@ -742,9 +804,19 @@ test("revert preserves file that existed in snapshot when deleted then recreated await Snapshot.revert([patch]) - expect(await Bun.file(`${tmp.path}/newfile.txt`).exists()).toBe(false) - expect(await Bun.file(`${tmp.path}/existing.txt`).exists()).toBe(true) - expect(await Bun.file(`${tmp.path}/existing.txt`).text()).toBe("original content") + expect( + await fs + .access(`${tmp.path}/newfile.txt`) + .then(() => true) + .catch(() => false), + ).toBe(false) + expect( + await fs + .access(`${tmp.path}/existing.txt`) + .then(() => true) + .catch(() => false), + ).toBe(true) + expect(await fs.readFile(`${tmp.path}/existing.txt`, "utf-8")).toBe("original content") }, }) }) @@ -754,17 +826,17 @@ test("diffFull sets status based on git change type", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await Bun.write(`${tmp.path}/grow.txt`, "one\n") - await Bun.write(`${tmp.path}/trim.txt`, "line1\nline2\n") - await Bun.write(`${tmp.path}/delete.txt`, "gone") + await Filesystem.write(`${tmp.path}/grow.txt`, "one\n") + await Filesystem.write(`${tmp.path}/trim.txt`, "line1\nline2\n") + await Filesystem.write(`${tmp.path}/delete.txt`, "gone") const before = await Snapshot.track() expect(before).toBeTruthy() - await Bun.write(`${tmp.path}/grow.txt`, "one\ntwo\n") - await Bun.write(`${tmp.path}/trim.txt`, "line1\n") + await Filesystem.write(`${tmp.path}/grow.txt`, "one\ntwo\n") + await Filesystem.write(`${tmp.path}/trim.txt`, "line1\n") await $`rm ${tmp.path}/delete.txt`.quiet() - await Bun.write(`${tmp.path}/added.txt`, "new") + await Filesystem.write(`${tmp.path}/added.txt`, "new") const after = await Snapshot.track() expect(after).toBeTruthy() @@ -803,7 +875,7 @@ test("diffFull with new file additions", async () => { const before = await Snapshot.track() expect(before).toBeTruthy() - await Bun.write(`${tmp.path}/new.txt`, "new content") + await Filesystem.write(`${tmp.path}/new.txt`, "new content") const after = await Snapshot.track() expect(after).toBeTruthy() @@ -829,7 +901,7 @@ test("diffFull with file modifications", async () => { const before = await Snapshot.track() expect(before).toBeTruthy() - await Bun.write(`${tmp.path}/b.txt`, "modified content") + await Filesystem.write(`${tmp.path}/b.txt`, "modified content") const after = await Snapshot.track() expect(after).toBeTruthy() @@ -881,7 +953,7 @@ test("diffFull with multiple line additions", async () => { const before = await Snapshot.track() expect(before).toBeTruthy() - await Bun.write(`${tmp.path}/multi.txt`, "line1\nline2\nline3") + await Filesystem.write(`${tmp.path}/multi.txt`, "line1\nline2\nline3") const after = await Snapshot.track() expect(after).toBeTruthy() @@ -907,7 +979,7 @@ test("diffFull with addition and deletion", async () => { const before = await Snapshot.track() expect(before).toBeTruthy() - await Bun.write(`${tmp.path}/added.txt`, "added content") + await Filesystem.write(`${tmp.path}/added.txt`, "added content") await $`rm ${tmp.path}/a.txt`.quiet() const after = await Snapshot.track() @@ -941,8 +1013,8 @@ test("diffFull with multiple additions and deletions", async () => { const before = await Snapshot.track() expect(before).toBeTruthy() - await Bun.write(`${tmp.path}/multi1.txt`, "line1\nline2\nline3") - await Bun.write(`${tmp.path}/multi2.txt`, "single line") + await Filesystem.write(`${tmp.path}/multi1.txt`, "line1\nline2\nline3") + await Filesystem.write(`${tmp.path}/multi2.txt`, "single line") await $`rm ${tmp.path}/a.txt`.quiet() await $`rm ${tmp.path}/b.txt`.quiet() @@ -1000,7 +1072,7 @@ test("diffFull with binary file changes", async () => { const before = await Snapshot.track() expect(before).toBeTruthy() - await Bun.write(`${tmp.path}/binary.bin`, new Uint8Array([0x00, 0x01, 0x02, 0x03])) + await Filesystem.write(`${tmp.path}/binary.bin`, new Uint8Array([0x00, 0x01, 0x02, 0x03])) const after = await Snapshot.track() expect(after).toBeTruthy() @@ -1020,11 +1092,11 @@ test("diffFull with whitespace changes", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await Bun.write(`${tmp.path}/whitespace.txt`, "line1\nline2") + await Filesystem.write(`${tmp.path}/whitespace.txt`, "line1\nline2") const before = await Snapshot.track() expect(before).toBeTruthy() - await Bun.write(`${tmp.path}/whitespace.txt`, "line1\n\nline2\n") + await Filesystem.write(`${tmp.path}/whitespace.txt`, "line1\n\nline2\n") const after = await Snapshot.track() expect(after).toBeTruthy() diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index fd03b7f98..3bd923b60 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import path from "path" import { BashTool } from "../../src/tool/bash" import { Instance } from "../../src/project/instance" +import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" import type { PermissionNext } from "../../src/permission/next" import { Truncate } from "../../src/tool/truncation" @@ -388,7 +389,7 @@ describe("tool.bash truncation", () => { const filepath = (result.metadata as any).outputPath expect(filepath).toBeTruthy() - const saved = await Bun.file(filepath).text() + const saved = await Filesystem.readText(filepath) const lines = saved.trim().split("\n") expect(lines.length).toBe(lineCount) expect(lines[0]).toBe("1") diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index cc9d1a33e..88228f14e 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import path from "path" import { ReadTool } from "../../src/tool/read" import { Instance } from "../../src/project/instance" +import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" import { PermissionNext } from "../../src/permission/next" import { Agent } from "../../src/agent/agent" @@ -199,10 +200,10 @@ describe("tool.read truncation", () => { test("truncates large file by bytes and sets truncated metadata", async () => { await using tmp = await tmpdir({ init: async (dir) => { - const base = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text() + const base = await Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json")) const target = 60 * 1024 const content = base.length >= target ? base : base.repeat(Math.ceil(target / base.length)) - await Bun.write(path.join(dir, "large.json"), content) + await Filesystem.write(path.join(dir, "large.json"), content) }, }) await Instance.provide({ diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index 09222f279..9e141b205 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect, afterAll } from "bun:test" import { Truncate } from "../../src/tool/truncation" import { Identifier } from "../../src/id/id" +import { Filesystem } from "../../src/util/filesystem" import fs from "fs/promises" import path from "path" @@ -9,7 +10,7 @@ const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") describe("Truncate", () => { describe("output", () => { test("truncates large json file by bytes", async () => { - const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text() + const content = await Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json")) const result = await Truncate.output(content) expect(result.truncated).toBe(true) @@ -69,7 +70,7 @@ describe("Truncate", () => { }) test("large single-line file truncates with byte message", async () => { - const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text() + const content = await Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json")) const result = await Truncate.output(content) expect(result.truncated).toBe(true) @@ -88,7 +89,7 @@ describe("Truncate", () => { expect(result.outputPath).toBeDefined() expect(result.outputPath).toContain("tool_") - const written = await Bun.file(result.outputPath).text() + const written = await Filesystem.readText(result.outputPath!) expect(written).toBe(lines) }) @@ -139,21 +140,21 @@ describe("Truncate", () => { const oldTimestamp = Date.now() - 10 * DAY_MS const oldId = Identifier.create("tool", false, oldTimestamp) oldFile = path.join(Truncate.DIR, oldId) - await Bun.write(Bun.file(oldFile), "old content") + await Filesystem.write(oldFile, "old content") // Create a recent file (3 days ago) const recentTimestamp = Date.now() - 3 * DAY_MS const recentId = Identifier.create("tool", false, recentTimestamp) recentFile = path.join(Truncate.DIR, recentId) - await Bun.write(Bun.file(recentFile), "recent content") + await Filesystem.write(recentFile, "recent content") await Truncate.cleanup() // Old file should be deleted - expect(await Bun.file(oldFile).exists()).toBe(false) + expect(await Filesystem.exists(oldFile)).toBe(false) // Recent file should still exist - expect(await Bun.file(recentFile).exists()).toBe(true) + expect(await Filesystem.exists(recentFile)).toBe(true) }) }) }) diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts index 3c3da0fc7..0f5447937 100644 --- a/packages/opencode/test/util/filesystem.test.ts +++ b/packages/opencode/test/util/filesystem.test.ts @@ -285,4 +285,125 @@ describe("filesystem", () => { expect(Filesystem.mimeType("Makefile")).toBe("application/octet-stream") }) }) + + describe("writeStream()", () => { + test("writes from Web ReadableStream", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "streamed.txt") + const content = "Hello from stream!" + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(content)) + controller.close() + }, + }) + + await Filesystem.writeStream(filepath, stream) + + expect(await fs.readFile(filepath, "utf-8")).toBe(content) + }) + + test("writes from Node.js Readable stream", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "node-streamed.txt") + const content = "Hello from Node stream!" + const { Readable } = await import("stream") + const stream = Readable.from([content]) + + await Filesystem.writeStream(filepath, stream) + + expect(await fs.readFile(filepath, "utf-8")).toBe(content) + }) + + test("writes binary data from Web ReadableStream", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "binary.dat") + const binaryData = new Uint8Array([0x00, 0x01, 0x02, 0x03, 0xff]) + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(binaryData) + controller.close() + }, + }) + + await Filesystem.writeStream(filepath, stream) + + const read = await fs.readFile(filepath) + expect(Buffer.from(read)).toEqual(Buffer.from(binaryData)) + }) + + test("writes large content in chunks", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "large.txt") + const chunks = ["chunk1", "chunk2", "chunk3", "chunk4", "chunk5"] + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(new TextEncoder().encode(chunk)) + } + controller.close() + }, + }) + + await Filesystem.writeStream(filepath, stream) + + expect(await fs.readFile(filepath, "utf-8")).toBe(chunks.join("")) + }) + + test("creates parent directories", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "nested", "deep", "streamed.txt") + const content = "nested stream content" + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(content)) + controller.close() + }, + }) + + await Filesystem.writeStream(filepath, stream) + + expect(await fs.readFile(filepath, "utf-8")).toBe(content) + }) + + test("writes with permissions", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "protected-stream.txt") + const content = "secret stream content" + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(content)) + controller.close() + }, + }) + + await Filesystem.writeStream(filepath, stream, 0o600) + + const stats = await fs.stat(filepath) + if (process.platform !== "win32") { + expect(stats.mode & 0o777).toBe(0o600) + } + }) + + test("writes executable with permissions", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "script.sh") + const content = "#!/bin/bash\necho hello" + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(content)) + controller.close() + }, + }) + + await Filesystem.writeStream(filepath, stream, 0o755) + + const stats = await fs.stat(filepath) + if (process.platform !== "win32") { + expect(stats.mode & 0o777).toBe(0o755) + } + expect(await fs.readFile(filepath, "utf-8")).toBe(content) + }) + }) }) |
