From ccb207f946e79685a1c16a2135688c0dfde3f84c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 13 May 2026 20:25:37 -0400 Subject: [PATCH] effect(util): migrate filesystem callers to AppFileSystem.Service (#27152) --- packages/opencode/src/cli/cmd/import.ts | 9 ++-- .../opencode/src/control-plane/workspace.ts | 6 ++- .../test/control-plane/workspace.test.ts | 2 + packages/opencode/test/file/index.test.ts | 49 ++++++++++--------- .../test/plugin/loader-shared.test.ts | 20 ++++---- .../test/plugin/workspace-adapter.test.ts | 1 + packages/opencode/test/tool/shell.test.ts | 2 +- .../opencode/test/tool/truncation.test.ts | 20 +++++--- 8 files changed, 63 insertions(+), 46 deletions(-) diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index 419e81379b..2fcf286f46 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -7,7 +7,7 @@ import { SessionTable, MessageTable, PartTable } from "../../session/session.sql import { InstanceRef } from "@/effect/instance-ref" import { ShareNext } from "@/share/share-next" import { EOL } from "os" -import { Filesystem } from "@/util/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Effect, Schema } from "effect" const decodeMessageInfo = Schema.decodeUnknownSync(MessageV2.Info) @@ -95,6 +95,7 @@ export const ImportCommand = effectCmd({ const runImport = Effect.fn("Cli.import.body")(function* (file: string, projectID: string) { const share = yield* ShareNext.Service + const fs = yield* AppFileSystem.Service let exportData: ExportData | undefined @@ -149,9 +150,9 @@ const runImport = Effect.fn("Cli.import.body")(function* (file: string, projectI exportData = transformed } else { - exportData = yield* Effect.promise(() => - Filesystem.readJson>(file).catch(() => undefined), - ) + exportData = (yield* fs.readJson(file).pipe(Effect.orElseSucceed(() => undefined))) as + | NonNullable + | undefined if (!exportData) { process.stdout.write(`File not found: ${file}`) process.stdout.write(EOL) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 4a21e2e65e..5b7f867ca9 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -10,9 +10,9 @@ import { GlobalBus } from "@/bus/global" import { Auth } from "@/auth" import { SyncEvent } from "@/sync" import { EventSequenceTable, EventTable } from "@/sync/event.sql" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import * as Log from "@opencode-ai/core/util/log" import { RuntimeFlags } from "@/effect/runtime-flags" -import { Filesystem } from "@/util/filesystem" import { ProjectID } from "@/project/schema" import { Slug } from "@opencode-ai/core/util/slug" import { WorkspaceTable } from "./workspace.sql" @@ -176,6 +176,7 @@ export const layer = Layer.effect( const sync = yield* SyncEvent.Service const vcs = yield* Vcs.Service const flags = yield* RuntimeFlags.Service + const fs = yield* AppFileSystem.Service const connections = new Map() const syncFibers = yield* FiberMap.make() @@ -501,7 +502,7 @@ export const layer = Layer.effect( if (!target) return if (target.type === "local") { - setStatus(space.id, (yield* Effect.promise(() => Filesystem.exists(target.directory))) ? "connected" : "error") + setStatus(space.id, (yield* fs.existsSafe(target.directory)) ? "connected" : "error") return } @@ -1040,6 +1041,7 @@ export const defaultLayer = layer.pipe( Layer.provide(SessionPrompt.defaultLayer), Layer.provide(Project.defaultLayer), Layer.provide(Vcs.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), Layer.provide(FetchHttpClient.layer), Layer.provide(RuntimeFlags.defaultLayer), ) diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index 21bf4be6be..1c8156ae65 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -8,6 +8,7 @@ import { NodeHttpServer } from "@effect/platform-node" import { Effect, Layer, Schema } from "effect" import { FetchHttpClient, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { eq } from "drizzle-orm" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import * as Log from "@opencode-ai/core/util/log" import { Flag } from "@opencode-ai/core/flag/flag" import { GlobalBus, type GlobalEvent } from "@/bus/global" @@ -59,6 +60,7 @@ const workspaceLayer = (experimentalWorkspaces: boolean) => Layer.provide(Project.defaultLayer), Layer.provide(Vcs.defaultLayer), Layer.provide(FetchHttpClient.layer), + Layer.provide(AppFileSystem.defaultLayer), Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces })), Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))), ) diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index 8b48fff5e4..b7d531c63d 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -5,7 +5,6 @@ import { Cause, Effect, Exit, Layer } from "effect" import path from "path" import fs from "fs/promises" import { File } from "../../src/file" -import { Filesystem } from "@/util/filesystem" import { disposeAllInstances, TestInstance, withTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -161,7 +160,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(test.directory, "test.json") yield* Effect.promise(() => fs.writeFile(filepath, '{"key": "value"}', "utf-8")) - expect(yield* Effect.promise(() => Filesystem.mimeType(filepath))).toContain("application/json") + expect(AppFileSystem.mimeType(filepath)).toContain("application/json") const result = yield* read("test.json") expect(result.type).toBe("text") @@ -181,7 +180,7 @@ describe("file/index Filesystem patterns", () => { for (const testCase of testCases) { const filepath = path.join(test.directory, `test.${testCase.ext}`) yield* Effect.promise(() => fs.writeFile(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00]))) - expect(yield* Effect.promise(() => Filesystem.mimeType(filepath))).toContain(testCase.mime) + expect(AppFileSystem.mimeType(filepath)).toContain(testCase.mime) } }), ) @@ -189,15 +188,16 @@ describe("file/index Filesystem patterns", () => { describe("list() - Filesystem.exists() and readText()", () => { it.instance( - "reads .gitignore via Filesystem.exists() and readText()", + "reads .gitignore via AppFileSystem.existsSafe() and readFileString()", () => Effect.gen(function* () { + const fsys = yield* AppFileSystem.Service const test = yield* TestInstance const gitignorePath = path.join(test.directory, ".gitignore") - yield* Effect.promise(() => fs.writeFile(gitignorePath, "node_modules\ndist\n", "utf-8")) + yield* fsys.writeFileString(gitignorePath, "node_modules\ndist\n") - expect(yield* Effect.promise(() => Filesystem.exists(gitignorePath))).toBe(true) - expect(yield* Effect.promise(() => Filesystem.readText(gitignorePath))).toContain("node_modules") + expect(yield* fsys.existsSafe(gitignorePath)).toBe(true) + expect(yield* fsys.readFileString(gitignorePath)).toContain("node_modules") }), { git: true }, ) @@ -206,12 +206,13 @@ describe("file/index Filesystem patterns", () => { "reads .ignore file similarly", () => Effect.gen(function* () { + const fsys = yield* AppFileSystem.Service const test = yield* TestInstance const ignorePath = path.join(test.directory, ".ignore") - yield* Effect.promise(() => fs.writeFile(ignorePath, "*.log\n.env\n", "utf-8")) + yield* fsys.writeFileString(ignorePath, "*.log\n.env\n") - expect(yield* Effect.promise(() => Filesystem.exists(ignorePath))).toBe(true) - expect(yield* Effect.promise(() => Filesystem.readText(ignorePath))).toContain("*.log") + expect(yield* fsys.existsSafe(ignorePath)).toBe(true) + expect(yield* fsys.readFileString(ignorePath)).toContain("*.log") }), { git: true }, ) @@ -220,9 +221,10 @@ describe("file/index Filesystem patterns", () => { "handles missing .gitignore gracefully", () => Effect.gen(function* () { + const fsys = yield* AppFileSystem.Service const test = yield* TestInstance const gitignorePath = path.join(test.directory, ".gitignore") - expect(yield* Effect.promise(() => Filesystem.exists(gitignorePath))).toBe(false) + expect(yield* fsys.existsSafe(gitignorePath)).toBe(false) const nodes = yield* list() expect(Array.isArray(nodes)).toBe(true) @@ -231,16 +233,17 @@ describe("file/index Filesystem patterns", () => { ) }) - describe("File.changed() - Filesystem.readText() for untracked files", () => { + describe("File.changed() - AppFileSystem.readFileString() for untracked files", () => { it.instance( - "reads untracked files via Filesystem.readText()", + "reads untracked files via AppFileSystem.readFileString()", () => Effect.gen(function* () { + const fsys = yield* AppFileSystem.Service const test = yield* TestInstance const untrackedPath = path.join(test.directory, "untracked.txt") - yield* Effect.promise(() => fs.writeFile(untrackedPath, "new content\nwith multiple lines", "utf-8")) + yield* fsys.writeFileString(untrackedPath, "new content\nwith multiple lines") - const content = yield* Effect.promise(() => Filesystem.readText(untrackedPath)) + const content = yield* fsys.readFileString(untrackedPath) expect(content.split("\n").length).toBe(2) }), { git: true }, @@ -248,28 +251,26 @@ describe("file/index Filesystem patterns", () => { }) describe("Error handling", () => { - it.instance("handles errors gracefully in Filesystem.readText()", () => + it.instance("handles errors gracefully in AppFileSystem.readFileString()", () => Effect.gen(function* () { + const fsys = yield* AppFileSystem.Service const test = yield* TestInstance - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "readonly.txt"), "content", "utf-8")) + yield* fsys.writeFileString(path.join(test.directory, "readonly.txt"), "content") const nonExistentPath = path.join(test.directory, "does-not-exist.txt") - expect( - Exit.isFailure(yield* Effect.promise(() => Filesystem.readText(nonExistentPath)).pipe(Effect.exit)), - ).toBe(true) + expect(Exit.isFailure(yield* fsys.readFileString(nonExistentPath).pipe(Effect.exit))).toBe(true) const result = yield* read("does-not-exist.txt") expect(result.content).toBe("") }), ) - it.instance("handles errors in Filesystem.readArrayBuffer()", () => + it.instance("handles errors in AppFileSystem.readFile()", () => Effect.gen(function* () { + const fsys = yield* AppFileSystem.Service const test = yield* TestInstance const nonExistentPath = path.join(test.directory, "does-not-exist.bin") - const buffer = yield* Effect.promise(() => - Filesystem.readArrayBuffer(nonExistentPath).catch(() => new ArrayBuffer(0)), - ) + const buffer = yield* fsys.readFile(nonExistentPath).pipe(Effect.orElseSucceed(() => new Uint8Array(0))) expect(buffer.byteLength).toBe(0) }), ) diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index 6b1dd306dc..c283488632 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -4,9 +4,9 @@ import fs from "fs/promises" import path from "path" import { pathToFileURL } from "url" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -import { Filesystem } from "@/util/filesystem" const { Plugin } = await import("../../src/plugin/index") const { PluginLoader } = await import("../../src/plugin/loader") @@ -20,7 +20,7 @@ afterEach(async () => { await disposeAllInstances() }) -const it = testEffect(CrossSpawnSpawner.defaultLayer) +const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer)) function withTmp( init: (dir: string) => Promise, @@ -837,7 +837,7 @@ describe("plugin.loader.shared", () => { Effect.gen(function* () { yield* load(tmp.path) expect( - yield* Effect.promise(() => Filesystem.readJson<{ source: string; enabled: boolean }>(tmp.extra.mark)), + (yield* (yield* AppFileSystem.Service).readJson(tmp.extra.mark)) as { source: string; enabled: boolean }, ).toEqual({ source: "tuple", enabled: true, @@ -960,7 +960,8 @@ export default { (tmp) => Effect.gen(function* () { const file = path.join(tmp.extra.mod, "package.json") - const json = yield* Effect.promise(() => Filesystem.readJson>(file)) + const fsys = yield* AppFileSystem.Service + const json = (yield* fsys.readJson(file)) as Record const list = readPackageThemes("acme-plugin", { dir: tmp.extra.mod, pkg: file, @@ -968,8 +969,8 @@ export default { }) expect(list).toEqual([ - Filesystem.resolve(path.join(tmp.extra.mod, "themes", "one.json")), - Filesystem.resolve(path.join(tmp.extra.mod, "themes", "two.json")), + AppFileSystem.resolve(path.join(tmp.extra.mod, "themes", "one.json")), + AppFileSystem.resolve(path.join(tmp.extra.mod, "themes", "two.json")), ]) }), ), @@ -1033,7 +1034,7 @@ export default { { spec: "acme-plugin@1.0.0", target: tmp.extra.mod, - themes: [Filesystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))], + themes: [AppFileSystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))], }, ]) expect(missing).toHaveLength(0) @@ -1096,7 +1097,7 @@ export default { expect(loaded).toEqual([ { spec: "acme-plugin@1.0.0", - themes: [Filesystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))], + themes: [AppFileSystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))], }, ]) } finally { @@ -1117,7 +1118,8 @@ export default { }, (tmp) => Effect.gen(function* () { - const json = yield* Effect.promise(() => Filesystem.readJson>(tmp.extra.file)) + const fsys = yield* AppFileSystem.Service + const json = (yield* fsys.readJson(tmp.extra.file)) as Record expect(() => readPackageThemes("acme", { dir: tmp.extra.mod, diff --git a/packages/opencode/test/plugin/workspace-adapter.test.ts b/packages/opencode/test/plugin/workspace-adapter.test.ts index 0cf603fa3b..31cdf45bff 100644 --- a/packages/opencode/test/plugin/workspace-adapter.test.ts +++ b/packages/opencode/test/plugin/workspace-adapter.test.ts @@ -56,6 +56,7 @@ const workspaceLayer = Workspace.layer.pipe( Layer.provide(Project.defaultLayer), Layer.provide(Vcs.defaultLayer), Layer.provide(FetchHttpClient.layer), + Layer.provide(AppFileSystem.defaultLayer), Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrapLayer))), Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: true })), ) diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index 6ce0e5c081..26c04e6bee 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -1195,7 +1195,7 @@ describe("tool.shell truncation", () => { const filepath = (result.metadata as { outputPath?: string }).outputPath expect(filepath).toBeTruthy() - const saved = yield* Effect.promise(() => Filesystem.readText(filepath!)) + const saved = yield* (yield* AppFileSystem.Service).readFileString(filepath!) const lines = saved.trim().split(/\r?\n/) expect(lines.length).toBe(lineCount) expect(lines[0]).toBe("1") diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index e948a6dcb3..804bbd6726 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -1,11 +1,11 @@ import { describe, test, expect } from "bun:test" import { NodeFileSystem } from "@effect/platform-node" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Effect, FileSystem, Layer } from "effect" import { Truncate } from "@/tool/truncate" import { Config } from "@/config/config" import { Identifier } from "../../src/id/id" import { Process } from "@/util/process" -import { Filesystem } from "@/util/filesystem" import path from "path" import { testEffect } from "../lib/effect" import { writeFileStringScoped } from "../lib/filesystem" @@ -14,10 +14,15 @@ import { TestConfig } from "../fixture/config" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") const ROOT = path.resolve(import.meta.dir, "..", "..") -const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, NodeFileSystem.layer)) +const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, NodeFileSystem.layer, AppFileSystem.defaultLayer)) const configuredLayer = (cfg: Config.Info) => - Layer.mergeAll(Truncate.defaultLayer, NodeFileSystem.layer, TestConfig.layer({ get: () => Effect.succeed(cfg) })) + Layer.mergeAll( + Truncate.defaultLayer, + NodeFileSystem.layer, + AppFileSystem.defaultLayer, + TestConfig.layer({ get: () => Effect.succeed(cfg) }), + ) const configuredIt = (cfg: Config.Info) => testEffect(configuredLayer(cfg)) describe("Truncate", () => { @@ -25,7 +30,8 @@ describe("Truncate", () => { it.live("truncates large json file by bytes", () => Effect.gen(function* () { const svc = yield* Truncate.Service - const content = yield* Effect.promise(() => Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json"))) + const fsys = yield* AppFileSystem.Service + const content = yield* fsys.readFileString(path.join(FIXTURES_DIR, "models-api.json")) const result = yield* svc.output(content) expect(result.truncated).toBe(true) @@ -158,7 +164,8 @@ describe("Truncate", () => { it.live("large single-line file truncates with byte message", () => Effect.gen(function* () { const svc = yield* Truncate.Service - const content = yield* Effect.promise(() => Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json"))) + const fsys = yield* AppFileSystem.Service + const content = yield* fsys.readFileString(path.join(FIXTURES_DIR, "models-api.json")) const result = yield* svc.output(content) expect(result.truncated).toBe(true) @@ -180,7 +187,8 @@ describe("Truncate", () => { expect(result.outputPath).toBeDefined() expect(result.outputPath).toContain("tool_") - const written = yield* Effect.promise(() => Filesystem.readText(result.outputPath!)) + const fsys = yield* AppFileSystem.Service + const written = yield* fsys.readFileString(result.outputPath!) expect(written).toBe(lines) }), )