effect(util): migrate filesystem callers to AppFileSystem.Service

Swap Filesystem.* calls in Effect-context callers (one production
service, one CLI handler, and six tests) to yield AppFileSystem.Service.
This commit is contained in:
Kit Langton
2026-05-12 16:31:06 -04:00
parent baef5cd43b
commit ce8dab4d1a
6 changed files with 60 additions and 46 deletions

View File

@@ -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<NonNullable<typeof exportData>>(file).catch(() => undefined),
)
exportData = (yield* fs.readJson(file).pipe(Effect.orElseSucceed(() => undefined))) as
| NonNullable<typeof exportData>
| undefined
if (!exportData) {
process.stdout.write(`File not found: ${file}`)
process.stdout.write(EOL)

View File

@@ -11,8 +11,8 @@ import { Auth } from "@/auth"
import { SyncEvent } from "@/sync"
import { EventSequenceTable, EventTable } from "@/sync/event.sql"
import { Flag } from "@opencode-ai/core/flag/flag"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import * as Log from "@opencode-ai/core/util/log"
import { Filesystem } from "@/util/filesystem"
import { ProjectID } from "@/project/schema"
import { Slug } from "@opencode-ai/core/util/slug"
import { WorkspaceTable } from "./workspace.sql"
@@ -175,6 +175,7 @@ export const layer = Layer.effect(
const http = yield* HttpClient.HttpClient
const sync = yield* SyncEvent.Service
const vcs = yield* Vcs.Service
const fs = yield* AppFileSystem.Service
const connections = new Map<WorkspaceID, ConnectionStatus>()
const syncFibers = yield* FiberMap.make<WorkspaceID, void, SyncLoopError>()
@@ -500,7 +501,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
}
@@ -1039,6 +1040,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),
)

View File

@@ -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)
}),
)

View File

@@ -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 disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
@@ -30,7 +30,7 @@ afterEach(async () => {
await disposeAllInstances()
})
const it = testEffect(CrossSpawnSpawner.defaultLayer)
const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer))
function withTmp<T, A, E, R>(
init: (dir: string) => Promise<T>,
@@ -846,7 +846,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,
@@ -980,7 +980,8 @@ export default {
(tmp) =>
Effect.gen(function* () {
const file = path.join(tmp.extra.mod, "package.json")
const json = yield* Effect.promise(() => Filesystem.readJson<Record<string, unknown>>(file))
const fsys = yield* AppFileSystem.Service
const json = (yield* fsys.readJson(file)) as Record<string, unknown>
const list = readPackageThemes("acme-plugin", {
dir: tmp.extra.mod,
pkg: file,
@@ -988,8 +989,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")),
])
}),
),
@@ -1053,7 +1054,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)
@@ -1116,7 +1117,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 {
@@ -1137,7 +1138,8 @@ export default {
},
(tmp) =>
Effect.gen(function* () {
const json = yield* Effect.promise(() => Filesystem.readJson<Record<string, unknown>>(tmp.extra.file))
const fsys = yield* AppFileSystem.Service
const json = (yield* fsys.readJson(tmp.extra.file)) as Record<string, unknown>
expect(() =>
readPackageThemes("acme", {
dir: tmp.extra.mod,

View File

@@ -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")

View File

@@ -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)
}),
)