fs-util.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. import { NodeFileSystem } from "@effect/platform-node"
  2. import { dirname, isAbsolute, join, relative, resolve as pathResolve, sep } from "path"
  3. import { realpathSync } from "fs"
  4. import * as NFS from "fs/promises"
  5. import { lookup } from "mime-types"
  6. import { Context, Effect, FileSystem, Layer, Schema } from "effect"
  7. import type { PlatformError } from "effect/PlatformError"
  8. import { Glob } from "./util/glob"
  9. import { serviceUse } from "./effect/service-use"
  10. import { makeGlobalNode } from "./effect/app-node"
  11. import { filesystem } from "./effect/app-node-platform"
  12. export namespace FSUtil {
  13. export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
  14. method: Schema.String,
  15. cause: Schema.optional(Schema.Defect()),
  16. }) {
  17. override get message() {
  18. const detail = this.cause instanceof Error ? this.cause.message : this.cause && String(this.cause)
  19. return `Filesystem operation failed: ${this.method}${detail ? `: ${detail}` : ""}`
  20. }
  21. }
  22. export type Error = PlatformError | FileSystemError
  23. export interface DirEntry {
  24. readonly name: string
  25. readonly type: "file" | "directory" | "symlink" | "other"
  26. }
  27. export interface Interface extends FileSystem.FileSystem {
  28. readonly isDir: (path: string) => Effect.Effect<boolean>
  29. readonly isFile: (path: string) => Effect.Effect<boolean>
  30. readonly existsSafe: (path: string) => Effect.Effect<boolean>
  31. readonly readFileStringSafe: (path: string) => Effect.Effect<string | undefined, Error>
  32. readonly readJson: (path: string) => Effect.Effect<unknown, Error>
  33. readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect<void, Error>
  34. readonly ensureDir: (path: string) => Effect.Effect<void, Error>
  35. readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect<void, Error>
  36. readonly readDirectoryEntries: (path: string) => Effect.Effect<DirEntry[], Error>
  37. readonly resolve: (path: string) => Effect.Effect<string>
  38. readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error>
  39. readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error>
  40. readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error>
  41. readonly glob: (pattern: string, options?: Glob.Options) => Effect.Effect<string[], Error>
  42. readonly globMatch: (pattern: string, filepath: string) => boolean
  43. }
  44. export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
  45. export const use = serviceUse(Service)
  46. const layer = Layer.effect(
  47. Service,
  48. Effect.gen(function* () {
  49. const fs = yield* FileSystem.FileSystem
  50. const existsSafe = Effect.fn("FileSystem.existsSafe")(function* (path: string) {
  51. return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false))
  52. })
  53. const readFileStringSafe = Effect.fn("FileSystem.readFileStringSafe")(function* (path: string) {
  54. return yield* fs.readFileString(path).pipe(
  55. Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
  56. Effect.catchReason("PlatformError", "PermissionDenied", () => Effect.succeed(undefined)),
  57. )
  58. })
  59. const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
  60. const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
  61. return info?.type === "Directory"
  62. })
  63. const isFile = Effect.fn("FileSystem.isFile")(function* (path: string) {
  64. const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
  65. return info?.type === "File"
  66. })
  67. const readDirectoryEntries = Effect.fn("FileSystem.readDirectoryEntries")(function* (dirPath: string) {
  68. return yield* Effect.tryPromise({
  69. try: async () => {
  70. const entries = await NFS.readdir(dirPath, { withFileTypes: true })
  71. return entries.map(
  72. (e): DirEntry => ({
  73. name: e.name,
  74. type: e.isDirectory() ? "directory" : e.isSymbolicLink() ? "symlink" : e.isFile() ? "file" : "other",
  75. }),
  76. )
  77. },
  78. catch: (cause) => new FileSystemError({ method: "readDirectoryEntries", cause }),
  79. })
  80. })
  81. const resolve = Effect.fn("FileSystem.resolve")(function* (path: string) {
  82. const resolved = pathResolve(windowsPath(path))
  83. return yield* fs.realPath(resolved).pipe(
  84. Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(resolved)),
  85. Effect.orDie,
  86. )
  87. })
  88. const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) {
  89. const text = yield* fs.readFileString(path)
  90. return yield* Effect.try({
  91. try: () => JSON.parse(text),
  92. catch: (cause) => new FileSystemError({ method: "readJson", cause }),
  93. })
  94. })
  95. const writeJson = Effect.fn("FileSystem.writeJson")(function* (path: string, data: unknown, mode?: number) {
  96. const content = JSON.stringify(data, null, 2)
  97. yield* fs.writeFileString(path, content)
  98. if (mode) yield* fs.chmod(path, mode)
  99. })
  100. const ensureDir = Effect.fn("FileSystem.ensureDir")(function* (path: string) {
  101. yield* fs.makeDirectory(path, { recursive: true }).pipe(
  102. // Bun on Windows can throw EEXIST here despite recursive mode.
  103. // https://github.com/oven-sh/bun/issues/21901
  104. Effect.catchIf(
  105. (error) => error.reason._tag === "AlreadyExists",
  106. (error) => isDir(path).pipe(Effect.flatMap((exists) => (exists ? Effect.void : Effect.fail(error)))),
  107. ),
  108. )
  109. })
  110. const writeWithDirs = Effect.fn("FileSystem.writeWithDirs")(function* (
  111. path: string,
  112. content: string | Uint8Array,
  113. mode?: number,
  114. ) {
  115. const write = typeof content === "string" ? fs.writeFileString(path, content) : fs.writeFile(path, content)
  116. yield* write.pipe(
  117. Effect.catchIf(
  118. (e) => e.reason._tag === "NotFound",
  119. () =>
  120. Effect.gen(function* () {
  121. yield* fs.makeDirectory(dirname(path), { recursive: true })
  122. yield* write
  123. }),
  124. ),
  125. )
  126. if (mode) yield* fs.chmod(path, mode)
  127. })
  128. const glob = Effect.fn("FileSystem.glob")(function* (pattern: string, options?: Glob.Options) {
  129. return yield* Effect.tryPromise({
  130. try: () => Glob.scan(pattern, options),
  131. catch: (cause) => new FileSystemError({ method: "glob", cause }),
  132. })
  133. })
  134. const findUp = Effect.fn("FileSystem.findUp")(function* (target: string, start: string, stop?: string) {
  135. const result: string[] = []
  136. let current = start
  137. while (true) {
  138. const search = join(current, target)
  139. if (yield* fs.exists(search)) result.push(search)
  140. if (stop === current) break
  141. const parent = dirname(current)
  142. if (parent === current) break
  143. current = parent
  144. }
  145. return result
  146. })
  147. const up = Effect.fn("FileSystem.up")(function* (options: { targets: string[]; start: string; stop?: string }) {
  148. const result: string[] = []
  149. let current = options.start
  150. while (true) {
  151. for (const target of options.targets) {
  152. const search = join(current, target)
  153. if (yield* fs.exists(search)) result.push(search)
  154. }
  155. if (options.stop === current) break
  156. const parent = dirname(current)
  157. if (parent === current) break
  158. current = parent
  159. }
  160. return result
  161. })
  162. const globUp = Effect.fn("FileSystem.globUp")(function* (pattern: string, start: string, stop?: string) {
  163. const result: string[] = []
  164. let current = start
  165. while (true) {
  166. const matches = yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true }).pipe(
  167. Effect.catch(() => Effect.succeed([] as string[])),
  168. )
  169. result.push(...matches)
  170. if (stop === current) break
  171. const parent = dirname(current)
  172. if (parent === current) break
  173. current = parent
  174. }
  175. return result
  176. })
  177. return Service.of({
  178. ...fs,
  179. existsSafe,
  180. readFileStringSafe,
  181. isDir,
  182. isFile,
  183. readDirectoryEntries,
  184. resolve,
  185. readJson,
  186. writeJson,
  187. ensureDir,
  188. writeWithDirs,
  189. findUp,
  190. up,
  191. globUp,
  192. glob,
  193. globMatch: Glob.match,
  194. })
  195. }),
  196. )
  197. export const node = makeGlobalNode({ service: Service, layer: layer, deps: [filesystem] })
  198. // Pure helpers that don't need Effect (path manipulation, sync operations)
  199. export function mimeType(p: string): string {
  200. return lookup(p) || "application/octet-stream"
  201. }
  202. export function normalizePath(p: string): string {
  203. if (process.platform !== "win32") return p
  204. const resolved = pathResolve(windowsPath(p))
  205. try {
  206. return realpathSync.native(resolved)
  207. } catch {
  208. return resolved
  209. }
  210. }
  211. export function normalizePathPattern(p: string): string {
  212. if (process.platform !== "win32") return p
  213. if (p === "*") return p
  214. const match = p.match(/^(.*)[\\/]\*$/)
  215. if (!match) return normalizePath(p)
  216. const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1]
  217. return join(normalizePath(dir), "*")
  218. }
  219. export function resolve(p: string): string {
  220. const resolved = pathResolve(windowsPath(p))
  221. try {
  222. return normalizePath(realpathSync(resolved))
  223. } catch (e: any) {
  224. if (e?.code === "ENOENT") return normalizePath(resolved)
  225. throw e
  226. }
  227. }
  228. export function windowsPath(p: string): string {
  229. if (process.platform !== "win32") return p
  230. return p
  231. .replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
  232. .replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
  233. .replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
  234. .replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
  235. }
  236. export function overlaps(a: string, b: string) {
  237. return contains(a, b) || contains(b, a)
  238. }
  239. export function contains(parent: string, child: string) {
  240. const result = relative(parent, child)
  241. return result === "" || (!isAbsolute(result) && result !== ".." && !result.startsWith(`..${sep}`))
  242. }
  243. }