diff --git a/packages/opencode/src/cli/cmd/plug.ts b/packages/opencode/src/cli/cmd/plug.ts index 692c556b24b8..1e1497032631 100644 --- a/packages/opencode/src/cli/cmd/plug.ts +++ b/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(fn: (fs: AppFileSystem.Interface) => Effect.Effect) { + 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, } diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index 2f7fd516439f..22e4272b97bb 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/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(fn: (fs: AppFileSystem.Interface) => Effect.Effect) { + return Effect.runPromise(AppFileSystem.Service.use(fn).pipe(Effect.provide(AppFileSystem.defaultLayer))) +} + function fail(message: string, data: Record) { 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 }) }) } diff --git a/packages/opencode/src/npm/index.ts b/packages/opencode/src/npm/index.ts index 5b708431c6af..476f067b299a 100644 --- a/packages/opencode/src/npm/index.ts +++ b/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 + type Manifest = { + dependencies?: Deps + devDependencies?: Deps + peerDependencies?: Deps + optionalDependencies?: Deps + } + type Lock = { + packages?: Record + } + + function file(fn: (fs: AppFileSystem.Interface) => Effect.Effect) { + 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 }>( - 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 }) + .catch(() => undefined) if (pkgJson?.bin) { const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg const bin = pkgJson.bin diff --git a/packages/opencode/src/plugin/install.ts b/packages/opencode/src/plugin/install.ts index b6bac42a7f97..fecce2ef33ae 100644 --- a/packages/opencode/src/plugin/install.ts +++ b/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(fn: (fs: AppFileSystem.Interface) => Effect.Effect) { + 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 { 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] diff --git a/packages/opencode/src/plugin/meta.ts b/packages/opencode/src/plugin/meta.ts index cbfaf6ae155d..9a7982239737 100644 --- a/packages/opencode/src/plugin/meta.ts +++ b/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(fn: (fs: AppFileSystem.Interface) => Effect.Effect) { + 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 { - return Filesystem.readJson(file).catch(() => ({}) as Store) + return io((fs) => fs.readJson(file)) + .then((item) => item as Store) + .catch(() => ({}) as Store) } async function row(item: Touch): Promise { @@ -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))) }) } diff --git a/packages/opencode/src/plugin/shared.ts b/packages/opencode/src/plugin/shared.ts index 54cc32af5ba9..7047e1cce7c5 100644 --- a/packages/opencode/src/plugin/shared.ts +++ b/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(fn: (fs: AppFileSystem.Interface) => Effect.Effect) { + 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 { 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>(pkg) + const json = await io((fs) => fs.readJson(pkg)).then((item) => item as Record) return { dir, pkg, json } } diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 2d787588b0b5..a2d8057ab404 100644 --- a/packages/opencode/src/provider/models.ts +++ b/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(fn: (fs: AppFileSystem.Interface) => Effect.Effect) { + return Effect.runPromise(AppFileSystem.Service.use(fn).pipe(Effect.provide(AppFileSystem.defaultLayer))) + } + const JsonValue: z.ZodType = 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", { diff --git a/packages/opencode/src/storage/json-migration.ts b/packages/opencode/src/storage/json-migration.ts index 89d27b9a7b03..a0100602a343 100644 --- a/packages/opencode/src/storage/json-migration.ts +++ b/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(fn: (fs: AppFileSystem.Interface) => Effect.Effect) { + 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)