| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236 |
- import { NodeFileSystem } from "@effect/platform-node"
- import { dirname, join, relative, resolve as pathResolve } from "path"
- import { realpathSync } from "fs"
- import * as NFS from "fs/promises"
- import { lookup } from "mime-types"
- import { Effect, FileSystem, Layer, Schema, Context } from "effect"
- import type { PlatformError } from "effect/PlatformError"
- import { Glob } from "./util/glob"
- export namespace AppFileSystem {
- export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
- method: Schema.String,
- cause: Schema.optional(Schema.Defect),
- }) {}
- export type Error = PlatformError | FileSystemError
- export interface DirEntry {
- readonly name: string
- readonly type: "file" | "directory" | "symlink" | "other"
- }
- export interface Interface extends FileSystem.FileSystem {
- readonly isDir: (path: string) => Effect.Effect<boolean>
- readonly isFile: (path: string) => Effect.Effect<boolean>
- readonly existsSafe: (path: string) => Effect.Effect<boolean>
- readonly readJson: (path: string) => Effect.Effect<unknown, Error>
- readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect<void, Error>
- readonly ensureDir: (path: string) => Effect.Effect<void, Error>
- readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect<void, Error>
- readonly readDirectoryEntries: (path: string) => Effect.Effect<DirEntry[], Error>
- readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error>
- readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error>
- readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error>
- readonly glob: (pattern: string, options?: Glob.Options) => Effect.Effect<string[], Error>
- readonly globMatch: (pattern: string, filepath: string) => boolean
- }
- export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
- export const layer = Layer.effect(
- Service,
- Effect.gen(function* () {
- const fs = yield* FileSystem.FileSystem
- const existsSafe = Effect.fn("FileSystem.existsSafe")(function* (path: string) {
- return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false))
- })
- const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
- const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
- return info?.type === "Directory"
- })
- const isFile = Effect.fn("FileSystem.isFile")(function* (path: string) {
- const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
- return info?.type === "File"
- })
- const readDirectoryEntries = Effect.fn("FileSystem.readDirectoryEntries")(function* (dirPath: string) {
- return yield* Effect.tryPromise({
- try: async () => {
- const entries = await NFS.readdir(dirPath, { withFileTypes: true })
- return entries.map(
- (e): DirEntry => ({
- name: e.name,
- type: e.isDirectory() ? "directory" : e.isSymbolicLink() ? "symlink" : e.isFile() ? "file" : "other",
- }),
- )
- },
- catch: (cause) => new FileSystemError({ method: "readDirectoryEntries", cause }),
- })
- })
- const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) {
- const text = yield* fs.readFileString(path)
- return JSON.parse(text)
- })
- const writeJson = Effect.fn("FileSystem.writeJson")(function* (path: string, data: unknown, mode?: number) {
- const content = JSON.stringify(data, null, 2)
- yield* fs.writeFileString(path, content)
- if (mode) yield* fs.chmod(path, mode)
- })
- const ensureDir = Effect.fn("FileSystem.ensureDir")(function* (path: string) {
- yield* fs.makeDirectory(path, { recursive: true })
- })
- const writeWithDirs = Effect.fn("FileSystem.writeWithDirs")(function* (
- path: string,
- content: string | Uint8Array,
- mode?: number,
- ) {
- const write = typeof content === "string" ? fs.writeFileString(path, content) : fs.writeFile(path, content)
- yield* write.pipe(
- Effect.catchIf(
- (e) => e.reason._tag === "NotFound",
- () =>
- Effect.gen(function* () {
- yield* fs.makeDirectory(dirname(path), { recursive: true })
- yield* write
- }),
- ),
- )
- if (mode) yield* fs.chmod(path, mode)
- })
- const glob = Effect.fn("FileSystem.glob")(function* (pattern: string, options?: Glob.Options) {
- return yield* Effect.tryPromise({
- try: () => Glob.scan(pattern, options),
- catch: (cause) => new FileSystemError({ method: "glob", cause }),
- })
- })
- const findUp = Effect.fn("FileSystem.findUp")(function* (target: string, start: string, stop?: string) {
- const result: string[] = []
- let current = start
- while (true) {
- const search = join(current, target)
- if (yield* fs.exists(search)) result.push(search)
- if (stop === current) break
- const parent = dirname(current)
- if (parent === current) break
- current = parent
- }
- return result
- })
- const up = Effect.fn("FileSystem.up")(function* (options: { targets: string[]; start: string; stop?: string }) {
- const result: string[] = []
- let current = options.start
- while (true) {
- for (const target of options.targets) {
- const search = join(current, target)
- if (yield* fs.exists(search)) result.push(search)
- }
- if (options.stop === current) break
- const parent = dirname(current)
- if (parent === current) break
- current = parent
- }
- return result
- })
- const globUp = Effect.fn("FileSystem.globUp")(function* (pattern: string, start: string, stop?: string) {
- const result: string[] = []
- let current = start
- while (true) {
- const matches = yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true }).pipe(
- Effect.catch(() => Effect.succeed([] as string[])),
- )
- result.push(...matches)
- if (stop === current) break
- const parent = dirname(current)
- if (parent === current) break
- current = parent
- }
- return result
- })
- return Service.of({
- ...fs,
- existsSafe,
- isDir,
- isFile,
- readDirectoryEntries,
- readJson,
- writeJson,
- ensureDir,
- writeWithDirs,
- findUp,
- up,
- globUp,
- glob,
- globMatch: Glob.match,
- })
- }),
- )
- export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
- // Pure helpers that don't need Effect (path manipulation, sync operations)
- export function mimeType(p: string): string {
- return lookup(p) || "application/octet-stream"
- }
- export function normalizePath(p: string): string {
- if (process.platform !== "win32") return p
- const resolved = pathResolve(windowsPath(p))
- try {
- return realpathSync.native(resolved)
- } catch {
- return resolved
- }
- }
- export function normalizePathPattern(p: string): string {
- if (process.platform !== "win32") return p
- if (p === "*") return p
- const match = p.match(/^(.*)[\\/]\*$/)
- if (!match) return normalizePath(p)
- const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1]
- return join(normalizePath(dir), "*")
- }
- export function resolve(p: string): string {
- const resolved = pathResolve(windowsPath(p))
- try {
- return normalizePath(realpathSync(resolved))
- } catch (e: any) {
- if (e?.code === "ENOENT") return normalizePath(resolved)
- throw e
- }
- }
- export function windowsPath(p: string): string {
- if (process.platform !== "win32") return p
- return p
- .replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
- .replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
- .replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
- .replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
- }
- export function overlaps(a: string, b: string) {
- const relA = relative(a, b)
- const relB = relative(b, a)
- return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
- }
- export function contains(parent: string, child: string) {
- return !relative(parent, child).startsWith("..")
- }
- }
|