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..866ee2e5f 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, @@ -1694,7 +1694,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) const ok = await Archive.extractZip(tempPath, Global.Path.bin) .then(() => true) @@ -1707,7 +1707,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 +1784,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 +1803,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 +1832,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 +1990,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 +2008,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/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index 7b196eb84..b60b06e08 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 @@ -68,6 +70,25 @@ export namespace Filesystem { return write(p, JSON.stringify(data, null, 2), mode) } + export async function writeStream( + p: string, + stream: ReadableStream | Readable, + mode?: number, + ): Promise { + 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/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) + }) + }) })