filesystem.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. import { chmod, mkdir, readFile, stat as statFile, writeFile } from "fs/promises"
  2. import { createWriteStream, existsSync, statSync } from "fs"
  3. import { realpathSync } from "fs"
  4. import { dirname, isAbsolute, join, relative, resolve as pathResolve, win32 } from "path"
  5. import { Readable } from "stream"
  6. import { pipeline } from "stream/promises"
  7. import { Glob } from "@opencode-ai/core/util/glob"
  8. import { fileURLToPath } from "url"
  9. // Fast sync version for metadata checks
  10. export async function exists(p: string): Promise<boolean> {
  11. return existsSync(p)
  12. }
  13. export async function isDir(p: string): Promise<boolean> {
  14. try {
  15. return statSync(p).isDirectory()
  16. } catch {
  17. return false
  18. }
  19. }
  20. export function stat(p: string): ReturnType<typeof statSync> | undefined {
  21. return statSync(p, { throwIfNoEntry: false }) ?? undefined
  22. }
  23. export async function statAsync(p: string): Promise<ReturnType<typeof statSync> | undefined> {
  24. return statFile(p).catch((e) => {
  25. if (isEnoent(e)) return undefined
  26. throw e
  27. })
  28. }
  29. export async function size(p: string): Promise<number> {
  30. const s = stat(p)?.size ?? 0
  31. return typeof s === "bigint" ? Number(s) : s
  32. }
  33. export async function readText(p: string): Promise<string> {
  34. return readFile(p, "utf-8")
  35. }
  36. export async function readJson<T = unknown>(p: string): Promise<T> {
  37. return JSON.parse(await readFile(p, "utf-8"))
  38. }
  39. export async function readBytes(p: string): Promise<Buffer> {
  40. return readFile(p)
  41. }
  42. export async function readArrayBuffer(p: string): Promise<ArrayBuffer> {
  43. const buf = await readFile(p)
  44. return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer
  45. }
  46. function isEnoent(e: unknown): e is { code: "ENOENT" } {
  47. return typeof e === "object" && e !== null && "code" in e && (e as { code: string }).code === "ENOENT"
  48. }
  49. export async function write(p: string, content: string | Buffer | Uint8Array, mode?: number): Promise<void> {
  50. try {
  51. if (mode) {
  52. await writeFile(p, content, { mode })
  53. } else {
  54. await writeFile(p, content)
  55. }
  56. } catch (e) {
  57. if (isEnoent(e)) {
  58. await mkdir(dirname(p), { recursive: true })
  59. if (mode) {
  60. await writeFile(p, content, { mode })
  61. } else {
  62. await writeFile(p, content)
  63. }
  64. return
  65. }
  66. throw e
  67. }
  68. }
  69. export async function writeJson(p: string, data: unknown, mode?: number): Promise<void> {
  70. return write(p, JSON.stringify(data, null, 2), mode)
  71. }
  72. export async function writeStream(
  73. p: string,
  74. stream: ReadableStream<Uint8Array> | Readable,
  75. mode?: number,
  76. ): Promise<void> {
  77. const dir = dirname(p)
  78. if (!existsSync(dir)) {
  79. await mkdir(dir, { recursive: true })
  80. }
  81. const nodeStream = stream instanceof ReadableStream ? Readable.fromWeb(stream as any) : stream
  82. const writeStream = createWriteStream(p)
  83. await pipeline(nodeStream, writeStream)
  84. if (mode) {
  85. await chmod(p, mode)
  86. }
  87. }
  88. export async function mimeType(p: string): Promise<string> {
  89. const { lookup } = await import("mime-types")
  90. return lookup(p) || "application/octet-stream"
  91. }
  92. /**
  93. * On Windows, normalize a path to its canonical casing using the filesystem.
  94. * This is needed because Windows paths are case-insensitive but LSP servers
  95. * may return paths with different casing than what we send them.
  96. */
  97. export function normalizePath(p: string): string {
  98. if (process.platform !== "win32") return p
  99. const resolved = win32.normalize(win32.resolve(windowsPath(p)))
  100. try {
  101. return realpathSync.native(resolved)
  102. } catch {
  103. return resolved
  104. }
  105. }
  106. export function normalizePathPattern(p: string): string {
  107. if (process.platform !== "win32") return p
  108. if (p === "*") return p
  109. const match = p.match(/^(.*)[\\/]\*$/)
  110. if (!match) return normalizePath(p)
  111. const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1]
  112. return join(normalizePath(dir), "*")
  113. }
  114. // We cannot rely on path.resolve() here because git.exe may come from Git Bash, Cygwin, or MSYS2, so we need to translate these paths at the boundary.
  115. // Also resolves symlinks so that callers using the result as a cache key
  116. // always get the same canonical path for a given physical directory.
  117. export function resolve(p: string): string {
  118. const resolved = pathResolve(windowsPath(p))
  119. try {
  120. return normalizePath(realpathSync(resolved))
  121. } catch (e) {
  122. if (isEnoent(e)) return normalizePath(resolved)
  123. throw e
  124. }
  125. }
  126. export function resolveFilePath(root: string, file: string): string {
  127. const raw = file.startsWith("file://") ? fileURLToPath(file) : file
  128. if (isAbsolute(raw)) return raw
  129. return pathResolve(root, raw)
  130. }
  131. export function windowsPath(p: string): string {
  132. if (process.platform !== "win32") return p
  133. return (
  134. p
  135. .replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
  136. // Git Bash for Windows paths are typically /<drive>/...
  137. .replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
  138. // Cygwin git paths are typically /cygdrive/<drive>/...
  139. .replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
  140. // WSL paths are typically /mnt/<drive>/...
  141. .replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
  142. )
  143. }
  144. export function overlaps(a: string, b: string) {
  145. const relA = relative(a, b)
  146. const relB = relative(b, a)
  147. return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
  148. }
  149. export function contains(parent: string, child: string) {
  150. return !relative(parent, child).startsWith("..")
  151. }
  152. export async function findUp(
  153. target: string,
  154. start: string,
  155. stop?: string,
  156. options?: { rootFirst?: boolean },
  157. ): Promise<string[]>
  158. export async function findUp(
  159. target: string[],
  160. start: string,
  161. stop?: string,
  162. options?: { rootFirst?: boolean },
  163. ): Promise<string[]>
  164. export async function findUp(
  165. target: string | string[],
  166. start: string,
  167. stop?: string,
  168. options?: { rootFirst?: boolean },
  169. ) {
  170. const dirs = [start]
  171. let current = start
  172. while (true) {
  173. if (stop === current) break
  174. const parent = dirname(current)
  175. if (parent === current) break
  176. dirs.push(parent)
  177. current = parent
  178. }
  179. const targets = Array.isArray(target) ? target : [target]
  180. const result = []
  181. for (const dir of options?.rootFirst ? dirs.toReversed() : dirs) {
  182. for (const item of targets) {
  183. const search = join(dir, item)
  184. if (await exists(search)) result.push(search)
  185. }
  186. }
  187. return result
  188. }
  189. export async function* up(options: { targets: string[]; start: string; stop?: string }) {
  190. const { targets, start, stop } = options
  191. let current = start
  192. while (true) {
  193. for (const target of targets) {
  194. const search = join(current, target)
  195. if (await exists(search)) yield search
  196. }
  197. if (stop === current) break
  198. const parent = dirname(current)
  199. if (parent === current) break
  200. current = parent
  201. }
  202. }
  203. export async function globUp(pattern: string, start: string, stop?: string) {
  204. let current = start
  205. const result = []
  206. while (true) {
  207. try {
  208. const matches = await Glob.scan(pattern, {
  209. cwd: current,
  210. absolute: true,
  211. include: "file",
  212. dot: true,
  213. })
  214. result.push(...matches)
  215. } catch {
  216. // Skip invalid glob patterns
  217. }
  218. if (stop === current) break
  219. const parent = dirname(current)
  220. if (parent === current) break
  221. current = parent
  222. }
  223. return result
  224. }
  225. export * as Filesystem from "./filesystem"