Przeglądaj źródła

refactor(opencode): move plugin io to AppFileSystem

Kit Langton 5 dni temu
rodzic
commit
35331114ac

+ 9 - 6
packages/opencode/src/cli/cmd/plug.ts

@@ -1,5 +1,7 @@
 import { intro, log, outro, spinner } from "@clack/prompts"
 import type { Argv } from "yargs"
+import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+import { Effect } from "effect"
 
 import { ConfigPaths } from "../../config/paths"
 import { Global } from "../../global"
@@ -7,7 +9,6 @@ import { installPlugin, patchPluginConfig, readPluginManifest } from "../../plug
 import { resolvePluginTarget } from "../../plugin/shared"
 import { Instance } from "../../project/instance"
 import { errorMessage } from "../../util/error"
-import { Filesystem } from "../../util/filesystem"
 import { Process } from "../../util/process"
 import { UI } from "../ui"
 import { cmd } from "./cmd"
@@ -44,6 +45,10 @@ export type PlugCtx = {
   directory: string
 }
 
+function file<T>(fn: (fs: AppFileSystem.Interface) => Effect.Effect<T, AppFileSystem.Error>) {
+  return Effect.runPromise(AppFileSystem.Service.use(fn).pipe(Effect.provide(AppFileSystem.defaultLayer)))
+}
+
 const defaultPlugDeps: PlugDeps = {
   spinner: () => spinner(),
   log: {
@@ -52,11 +57,9 @@ const defaultPlugDeps: PlugDeps = {
     success: (msg) => log.success(msg),
   },
   resolve: (spec) => resolvePluginTarget(spec),
-  readText: (file) => Filesystem.readText(file),
-  write: async (file, text) => {
-    await Filesystem.write(file, text)
-  },
-  exists: (file) => Filesystem.exists(file),
+  readText: (path) => file((fs) => fs.readFileString(path)),
+  write: (path, text) => file((fs) => fs.writeWithDirs(path, text)),
+  exists: (path) => file((fs) => fs.existsSafe(path)),
   files: (dir, name) => ConfigPaths.fileInDirectory(dir, name),
   global: Global.Path.config,
 }

+ 14 - 9
packages/opencode/src/cli/cmd/tui/plugin/runtime.ts

@@ -10,6 +10,8 @@ import {
   type TuiSlotPlugin,
   type TuiTheme,
 } from "@opencode-ai/plugin/tui"
+import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+import { Effect, Option } from "effect"
 import path from "path"
 import { fileURLToPath } from "url"
 
@@ -32,7 +34,6 @@ import { PluginMeta } from "@/plugin/meta"
 import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install"
 import { hasTheme, upsertTheme } from "../context/theme"
 import { Global } from "@/global"
-import { Filesystem } from "@/util/filesystem"
 import { Process } from "@/util/process"
 import { Flock } from "@/util/flock"
 import { Flag } from "@/flag/flag"
@@ -87,6 +88,10 @@ const EMPTY_TUI: TuiPluginModule = {
   tui: async () => {},
 }
 
+function io<T>(fn: (fs: AppFileSystem.Interface) => Effect.Effect<T, AppFileSystem.Error>) {
+  return Effect.runPromise(AppFileSystem.Service.use(fn).pipe(Effect.provide(AppFileSystem.defaultLayer)))
+}
+
 function fail(message: string, data: Record<string, unknown>) {
   if (!("error" in data)) {
     log.error(message, data)
@@ -163,13 +168,13 @@ function createThemeInstaller(
         : path.join(source_dir, ".opencode", "themes")
     const dest_dir = meta.scope === "local" ? local_dir : path.join(Global.Path.config, "themes")
     const dest = path.join(dest_dir, `${name}.json`)
-    const stat = await Filesystem.statAsync(src)
-    const mtime = stat ? Math.floor(typeof stat.mtimeMs === "bigint" ? Number(stat.mtimeMs) : stat.mtimeMs) : undefined
-    const size = stat ? (typeof stat.size === "bigint" ? Number(stat.size) : stat.size) : undefined
+    const stat = await io((fs) => fs.stat(src)).catch(() => undefined)
+    const mtime = stat ? Option.getOrUndefined(stat.mtime)?.getTime() : undefined
+    const size = stat ? Number(stat.size) : undefined
     const info = {
       src,
       dest,
-      mtime,
+      mtime: mtime === undefined ? undefined : Math.floor(mtime),
       size,
     }
 
@@ -191,7 +196,7 @@ function createThemeInstaller(
       const prev = plugin.themes[name]
       if (exists) {
         if (plugin.meta.state !== "updated") {
-          if (!prev && (await Filesystem.exists(dest))) {
+          if (!prev && (await io((fs) => fs.existsSafe(dest)))) {
             await save()
           }
           return
@@ -199,7 +204,7 @@ function createThemeInstaller(
         if (prev?.dest === dest && prev.mtime === mtime && prev.size === size) return
       }
 
-      const text = await Filesystem.readText(src).catch((error) => {
+      const text = await io((fs) => fs.readFileString(src)).catch((error) => {
         log.warn("failed to read tui plugin theme", { path: spec, theme: src, error })
         return
       })
@@ -219,8 +224,8 @@ function createThemeInstaller(
         return
       }
 
-      if (exists || !(await Filesystem.exists(dest))) {
-        await Filesystem.write(dest, text).catch((error) => {
+      if (exists || !(await io((fs) => fs.existsSafe(dest)))) {
+        await io((fs) => fs.writeWithDirs(dest, text)).catch((error) => {
           log.warn("failed to persist tui plugin theme", { path: spec, theme: src, dest, error })
         })
       }

+ 27 - 8
packages/opencode/src/npm/index.ts

@@ -1,17 +1,32 @@
 import semver from "semver"
 import z from "zod"
 import { NamedError } from "@opencode-ai/shared/util/error"
+import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+import { Effect } from "effect"
 import { Global } from "../global"
 import { Log } from "../util/log"
 import path from "path"
 import { readdir, rm } from "fs/promises"
-import { Filesystem } from "@/util/filesystem"
 import { Flock } from "@/util/flock"
 import { Arborist } from "@npmcli/arborist"
 
 export namespace Npm {
   const log = Log.create({ service: "npm" })
   const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
+  type Deps = Record<string, string>
+  type Manifest = {
+    dependencies?: Deps
+    devDependencies?: Deps
+    peerDependencies?: Deps
+    optionalDependencies?: Deps
+  }
+  type Lock = {
+    packages?: Record<string, Manifest>
+  }
+
+  function file<T>(fn: (fs: AppFileSystem.Interface) => Effect.Effect<T, AppFileSystem.Error>) {
+    return Effect.runPromise(AppFileSystem.Service.use(fn).pipe(Effect.provide(AppFileSystem.defaultLayer)))
+  }
 
   export const InstallFailedError = NamedError.create(
     "NpmInstallFailedError",
@@ -63,7 +78,7 @@ export namespace Npm {
 
   export async function add(pkg: string) {
     const dir = directory(pkg)
-    await using _ = await Flock.acquire(`npm-install:${Filesystem.resolve(dir)}`)
+    await using _ = await Flock.acquire(`npm-install:${AppFileSystem.resolve(dir)}`)
     log.info("installing package", {
       pkg,
     })
@@ -118,14 +133,18 @@ export namespace Npm {
       await arb.reify().catch(() => {})
     }
 
-    if (!(await Filesystem.exists(path.join(dir, "node_modules")))) {
+    if (!(await file((fs) => fs.existsSafe(path.join(dir, "node_modules"))))) {
       log.info("node_modules missing, reifying")
       await reify()
       return
     }
 
-    const pkg = await Filesystem.readJson(path.join(dir, "package.json")).catch(() => ({}))
-    const lock = await Filesystem.readJson(path.join(dir, "package-lock.json")).catch(() => ({}))
+    const pkg = await file((fs) => fs.readJson(path.join(dir, "package.json")))
+      .then((item) => item as Manifest)
+      .catch(() => ({}))
+    const lock = await file((fs) => fs.readJson(path.join(dir, "package-lock.json")))
+      .then((item) => item as Lock)
+      .catch(() => ({}))
 
     const declared = new Set([
       ...Object.keys(pkg.dependencies || {}),
@@ -162,9 +181,9 @@ export namespace Npm {
       if (files.length === 0) return undefined
       if (files.length === 1) return files[0]
       // Multiple binaries — resolve from package.json bin field like npx does
-      const pkgJson = await Filesystem.readJson<{ bin?: string | Record<string, string> }>(
-        path.join(dir, "node_modules", pkg, "package.json"),
-      ).catch(() => undefined)
+      const pkgJson = await file((fs) => fs.readJson(path.join(dir, "node_modules", pkg, "package.json")))
+        .then((item) => item as { bin?: string | Record<string, string> })
+        .catch(() => undefined)
       if (pkgJson?.bin) {
         const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
         const bin = pkgJson.bin

+ 10 - 7
packages/opencode/src/plugin/install.ts

@@ -6,10 +6,11 @@ import {
   parse as parseJsonc,
   printParseErrorCode,
 } from "jsonc-parser"
+import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+import { Effect } from "effect"
 
 import { ConfigPaths } from "@/config/paths"
 import { Global } from "@/global"
-import { Filesystem } from "@/util/filesystem"
 import { Flock } from "@/util/flock"
 import { isRecord } from "@/util/record"
 
@@ -79,12 +80,14 @@ const defaultInstallDeps: InstallDeps = {
   resolve: (spec) => resolvePluginTarget(spec),
 }
 
+function file<T>(fn: (fs: AppFileSystem.Interface) => Effect.Effect<T, AppFileSystem.Error>) {
+  return Effect.runPromise(AppFileSystem.Service.use(fn).pipe(Effect.provide(AppFileSystem.defaultLayer)))
+}
+
 const defaultPatchDeps: PatchDeps = {
-  readText: (file) => Filesystem.readText(file),
-  write: async (file, text) => {
-    await Filesystem.write(file, text)
-  },
-  exists: (file) => Filesystem.exists(file),
+  readText: (path) => file((fs) => fs.readFileString(path)),
+  write: (path, text) => file((fs) => fs.writeWithDirs(path, text)),
+  exists: (path) => file((fs) => fs.existsSafe(path)),
   files: (dir, name) => ConfigPaths.fileInDirectory(dir, name),
 }
 
@@ -344,7 +347,7 @@ function patchName(kind: Kind): "opencode" | "tui" {
 
 async function patchOne(dir: string, target: Target, spec: string, force: boolean, dep: PatchDeps): Promise<PatchOne> {
   const name = patchName(target.kind)
-  await using _ = await Flock.acquire(`plug-config:${Filesystem.resolve(path.join(dir, name))}`)
+  await using _ = await Flock.acquire(`plug-config:${AppFileSystem.resolve(path.join(dir, name))}`)
 
   const files = dep.files(dir, name)
   let cfg = files[0]

+ 19 - 10
packages/opencode/src/plugin/meta.ts

@@ -1,14 +1,19 @@
 import path from "path"
 import { fileURLToPath } from "url"
+import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+import { Effect, Option } from "effect"
 
 import { Flag } from "@/flag/flag"
 import { Global } from "@/global"
-import { Filesystem } from "@/util/filesystem"
 import { Flock } from "@/util/flock"
 
 import { parsePluginSpecifier, pluginSource } from "./shared"
 
 export namespace PluginMeta {
+  function io<T>(fn: (fs: AppFileSystem.Interface) => Effect.Effect<T, AppFileSystem.Error>) {
+    return Effect.runPromise(AppFileSystem.Service.use(fn).pipe(Effect.provide(AppFileSystem.defaultLayer)))
+  }
+
   type Source = "file" | "npm"
 
   export type Theme = {
@@ -61,10 +66,11 @@ export namespace PluginMeta {
   }
 
   async function modifiedAt(file: string) {
-    const stat = await Filesystem.statAsync(file)
+    const stat = await io((fs) => fs.stat(file)).catch(() => undefined)
     if (!stat) return
-    const mtime = stat.mtimeMs
-    return Math.floor(typeof mtime === "bigint" ? Number(mtime) : mtime)
+    const mtime = Option.getOrUndefined(stat.mtime)?.getTime()
+    if (mtime === undefined) return
+    return Math.floor(mtime)
   }
 
   function resolvedTarget(target: string) {
@@ -74,9 +80,10 @@ export namespace PluginMeta {
 
   async function npmVersion(target: string) {
     const resolved = resolvedTarget(target)
-    const stat = await Filesystem.statAsync(resolved)
-    const dir = stat?.isDirectory() ? resolved : path.dirname(resolved)
-    return Filesystem.readJson<{ version?: string }>(path.join(dir, "package.json"))
+    const stat = await io((fs) => fs.stat(resolved)).catch(() => undefined)
+    const dir = stat?.type === "Directory" ? resolved : path.dirname(resolved)
+    return io((fs) => fs.readJson(path.join(dir, "package.json")))
+      .then((item) => item as { version?: string })
       .then((item) => item.version)
       .catch(() => undefined)
   }
@@ -112,7 +119,9 @@ export namespace PluginMeta {
   }
 
   async function read(file: string): Promise<Store> {
-    return Filesystem.readJson<Store>(file).catch(() => ({}) as Store)
+    return io((fs) => fs.readJson(file))
+      .then((item) => item as Store)
+      .catch(() => ({}) as Store)
   }
 
   async function row(item: Touch): Promise<Row> {
@@ -154,7 +163,7 @@ export namespace PluginMeta {
         store[item.id] = hit.entry
         out.push(hit)
       }
-      await Filesystem.writeJson(file, store)
+      await io((fs) => fs.writeWithDirs(file, JSON.stringify(store, null, 2)))
       return out
     })
   }
@@ -177,7 +186,7 @@ export namespace PluginMeta {
         ...(entry.themes ?? {}),
         [name]: theme,
       }
-      await Filesystem.writeJson(file, store)
+      await io((fs) => fs.writeWithDirs(file, JSON.stringify(store, null, 2)))
     })
   }
 

+ 18 - 13
packages/opencode/src/plugin/shared.ts

@@ -2,8 +2,9 @@ import path from "path"
 import { fileURLToPath, pathToFileURL } from "url"
 import npa from "npm-package-arg"
 import semver from "semver"
+import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+import { Effect } from "effect"
 import { Npm } from "../npm"
-import { Filesystem } from "@/util/filesystem"
 import { isRecord } from "@/util/record"
 
 // Old npm package names for plugins that are now built-in
@@ -53,6 +54,10 @@ export type PluginEntry = {
 
 const INDEX_FILES = ["index.ts", "index.tsx", "index.js", "index.mjs", "index.cjs"]
 
+function io<T>(fn: (fs: AppFileSystem.Interface) => Effect.Effect<T, AppFileSystem.Error>) {
+  return Effect.runPromise(AppFileSystem.Service.use(fn).pipe(Effect.provide(AppFileSystem.defaultLayer)))
+}
+
 export function pluginSource(spec: string): PluginSource {
   if (isPathPluginSpec(spec)) return "file"
   return "npm"
@@ -88,9 +93,9 @@ function packageMain(pkg: PluginPackage) {
 
 function resolvePackageFile(spec: string, raw: string, kind: string, pkg: PluginPackage) {
   const resolved = resolveExportPath(raw, pkg.dir)
-  const root = Filesystem.resolve(pkg.dir)
-  const next = Filesystem.resolve(resolved)
-  if (!Filesystem.contains(root, next)) {
+  const root = AppFileSystem.resolve(pkg.dir)
+  const next = AppFileSystem.resolve(resolved)
+  if (!AppFileSystem.contains(root, next)) {
     throw new Error(`Plugin ${spec} resolved ${kind} entry outside plugin directory`)
   }
   return next
@@ -121,15 +126,15 @@ function targetPath(target: string) {
 async function resolveDirectoryIndex(dir: string) {
   for (const name of INDEX_FILES) {
     const file = path.join(dir, name)
-    if (await Filesystem.exists(file)) return file
+    if (await io((fs) => fs.existsSafe(file))) return file
   }
 }
 
 async function resolveTargetDirectory(target: string) {
   const file = targetPath(target)
   if (!file) return
-  const stat = await Filesystem.statAsync(file)
-  if (!stat?.isDirectory()) return
+  const stat = await io((fs) => fs.stat(file)).catch(() => undefined)
+  if (stat?.type !== "Directory") return
   return file
 }
 
@@ -175,13 +180,13 @@ export function isPathPluginSpec(spec: string) {
 export async function resolvePathPluginTarget(spec: string) {
   const raw = spec.startsWith("file://") ? fileURLToPath(spec) : spec
   const file = path.isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw) ? raw : path.resolve(raw)
-  const stat = await Filesystem.statAsync(file)
-  if (!stat?.isDirectory()) {
+  const stat = await io((fs) => fs.stat(file)).catch(() => undefined)
+  if (stat?.type !== "Directory") {
     if (spec.startsWith("file://")) return spec
     return pathToFileURL(file).href
   }
 
-  if (await Filesystem.exists(path.join(file, "package.json"))) {
+  if (await io((fs) => fs.existsSafe(path.join(file, "package.json")))) {
     return pathToFileURL(file).href
   }
 
@@ -214,10 +219,10 @@ export async function resolvePluginTarget(spec: string) {
 
 export async function readPluginPackage(target: string): Promise<PluginPackage> {
   const file = target.startsWith("file://") ? fileURLToPath(target) : target
-  const stat = await Filesystem.statAsync(file)
-  const dir = stat?.isDirectory() ? file : path.dirname(file)
+  const stat = await io((fs) => fs.stat(file)).catch(() => undefined)
+  const dir = stat?.type === "Directory" ? file : path.dirname(file)
   const pkg = path.join(dir, "package.json")
-  const json = await Filesystem.readJson<Record<string, unknown>>(pkg)
+  const json = await io((fs) => fs.readJson(pkg)).then((item) => item as Record<string, unknown>)
   return { dir, pkg, json }
 }
 

+ 12 - 6
packages/opencode/src/provider/models.ts

@@ -2,10 +2,12 @@ import { Global } from "../global"
 import { Log } from "../util/log"
 import path from "path"
 import z from "zod"
+import { statSync } from "fs"
+import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+import { Effect } from "effect"
 import { Installation } from "../installation"
 import { Flag } from "../flag/flag"
 import { lazy } from "@/util/lazy"
-import { Filesystem } from "../util/filesystem"
 import { Flock } from "@/util/flock"
 import { Hash } from "@/util/hash"
 
@@ -24,6 +26,10 @@ export namespace ModelsDev {
 
   type JsonValue = string | number | boolean | null | { [key: string]: JsonValue } | JsonValue[]
 
+  function file<T>(fn: (fs: AppFileSystem.Interface) => Effect.Effect<T, AppFileSystem.Error>) {
+    return Effect.runPromise(AppFileSystem.Service.use(fn).pipe(Effect.provide(AppFileSystem.defaultLayer)))
+  }
+
   const JsonValue: z.ZodType<JsonValue> = z.lazy(() =>
     z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(JsonValue), z.record(z.string(), JsonValue)]),
   )
@@ -113,7 +119,7 @@ export namespace ModelsDev {
   }
 
   function fresh() {
-    return Date.now() - Number(Filesystem.stat(filepath)?.mtimeMs ?? 0) < ttl
+    return Date.now() - Number(statSync(filepath, { throwIfNoEntry: false })?.mtimeMs ?? 0) < ttl
   }
 
   function skip(force: boolean) {
@@ -129,7 +135,7 @@ export namespace ModelsDev {
   }
 
   export const Data = lazy(async () => {
-    const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {})
+    const result = await file((fs) => fs.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath)).catch(() => {})
     if (result) return result
     // @ts-ignore
     const snapshot = await import("./models-snapshot.js")
@@ -138,11 +144,11 @@ export namespace ModelsDev {
     if (snapshot) return snapshot
     if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {}
     return Flock.withLock(`models-dev:${filepath}`, async () => {
-      const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {})
+      const result = await file((fs) => fs.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath)).catch(() => {})
       if (result) return result
       const result2 = await fetchApi()
       if (result2.ok) {
-        await Filesystem.write(filepath, result2.text).catch((e) => {
+        await file((fs) => fs.writeWithDirs(filepath, result2.text)).catch((e) => {
           log.error("Failed to write models cache", { error: e })
         })
       }
@@ -161,7 +167,7 @@ export namespace ModelsDev {
       if (skip(force)) return ModelsDev.Data.reset()
       const result = await fetchApi()
       if (!result.ok) return
-      await Filesystem.write(filepath, result.text)
+      await file((fs) => fs.writeWithDirs(filepath, result.text))
       ModelsDev.Data.reset()
     }).catch((e) => {
       log.error("Failed to fetch models.dev", {

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

@@ -1,5 +1,7 @@
 import type { SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
 import type { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite"
+import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+import { Effect } from "effect"
 import { Global } from "../global"
 import { Log } from "../util/log"
 import { ProjectTable } from "../project/project.sql"
@@ -7,12 +9,15 @@ 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"
 import { Glob } from "@opencode-ai/shared/util/glob"
 
 export namespace JsonMigration {
   const log = Log.create({ service: "json-migration" })
 
+  function file<T>(fn: (fs: AppFileSystem.Interface) => Effect.Effect<T, AppFileSystem.Error>) {
+    return Effect.runPromise(AppFileSystem.Service.use(fn).pipe(Effect.provide(AppFileSystem.defaultLayer)))
+  }
+
   export type Progress = {
     current: number
     total: number
@@ -79,7 +84,7 @@ export namespace JsonMigration {
       const count = end - start
       const tasks = new Array(count)
       for (let i = 0; i < count; i++) {
-        tasks[i] = Filesystem.readJson(files[start + i])
+        tasks[i] = file((fs) => fs.readJson(files[start + i]))
       }
       const results = await Promise.allSettled(tasks)
       const items = new Array(count)