/ utils / fsOperations.ts
fsOperations.ts
  1  import * as fs from 'fs'
  2  import {
  3    mkdir as mkdirPromise,
  4    open,
  5    readdir as readdirPromise,
  6    readFile as readFilePromise,
  7    rename as renamePromise,
  8    rmdir as rmdirPromise,
  9    rm as rmPromise,
 10    stat as statPromise,
 11    unlink as unlinkPromise,
 12  } from 'fs/promises'
 13  import { homedir } from 'os'
 14  import * as nodePath from 'path'
 15  import { getErrnoCode } from './errors.js'
 16  import { slowLogging } from './slowOperations.js'
 17  
 18  /**
 19   * Simplified filesystem operations interface based on Node.js fs module.
 20   * Provides a subset of commonly used sync operations with type safety.
 21   * Allows abstraction for alternative implementations (e.g., mock, virtual).
 22   */
 23  export type FsOperations = {
 24    // File access and information operations
 25    /** Gets the current working directory */
 26    cwd(): string
 27    /** Checks if a file or directory exists */
 28    existsSync(path: string): boolean
 29    /** Gets file stats asynchronously */
 30    stat(path: string): Promise<fs.Stats>
 31    /** Lists directory contents with file type information asynchronously */
 32    readdir(path: string): Promise<fs.Dirent[]>
 33    /** Deletes file asynchronously */
 34    unlink(path: string): Promise<void>
 35    /** Removes an empty directory asynchronously */
 36    rmdir(path: string): Promise<void>
 37    /** Removes files and directories asynchronously (with recursive option) */
 38    rm(
 39      path: string,
 40      options?: { recursive?: boolean; force?: boolean },
 41    ): Promise<void>
 42    /** Creates directory recursively asynchronously. */
 43    mkdir(path: string, options?: { mode?: number }): Promise<void>
 44    /** Reads file content as string asynchronously */
 45    readFile(path: string, options: { encoding: BufferEncoding }): Promise<string>
 46    /** Renames/moves file asynchronously */
 47    rename(oldPath: string, newPath: string): Promise<void>
 48    /** Gets file stats */
 49    statSync(path: string): fs.Stats
 50    /** Gets file stats without following symlinks */
 51    lstatSync(path: string): fs.Stats
 52  
 53    // File content operations
 54    /** Reads file content as string with specified encoding */
 55    readFileSync(
 56      path: string,
 57      options: {
 58        encoding: BufferEncoding
 59      },
 60    ): string
 61    /** Reads raw file bytes as Buffer */
 62    readFileBytesSync(path: string): Buffer
 63    /** Reads specified number of bytes from file start */
 64    readSync(
 65      path: string,
 66      options: {
 67        length: number
 68      },
 69    ): {
 70      buffer: Buffer
 71      bytesRead: number
 72    }
 73    /** Appends string to file */
 74    appendFileSync(path: string, data: string, options?: { mode?: number }): void
 75    /** Copies file from source to destination */
 76    copyFileSync(src: string, dest: string): void
 77    /** Deletes file */
 78    unlinkSync(path: string): void
 79    /** Renames/moves file */
 80    renameSync(oldPath: string, newPath: string): void
 81    /** Creates hard link */
 82    linkSync(target: string, path: string): void
 83    /** Creates symbolic link */
 84    symlinkSync(
 85      target: string,
 86      path: string,
 87      type?: 'dir' | 'file' | 'junction',
 88    ): void
 89    /** Reads symbolic link */
 90    readlinkSync(path: string): string
 91    /** Resolves symbolic links and returns the canonical pathname */
 92    realpathSync(path: string): string
 93  
 94    // Directory operations
 95    /** Creates directory recursively. Mode defaults to 0o777 & ~umask if not specified. */
 96    mkdirSync(
 97      path: string,
 98      options?: {
 99        mode?: number
100      },
101    ): void
102    /** Lists directory contents with file type information */
103    readdirSync(path: string): fs.Dirent[]
104    /** Lists directory contents as strings */
105    readdirStringSync(path: string): string[]
106    /** Checks if the directory is empty */
107    isDirEmptySync(path: string): boolean
108    /** Removes an empty directory */
109    rmdirSync(path: string): void
110    /** Removes files and directories (with recursive option) */
111    rmSync(
112      path: string,
113      options?: {
114        recursive?: boolean
115        force?: boolean
116      },
117    ): void
118    /** Create a writable stream for writing data to a file. */
119    createWriteStream(path: string): fs.WriteStream
120    /** Reads raw file bytes as Buffer asynchronously.
121     *  When maxBytes is set, only reads up to that many bytes. */
122    readFileBytes(path: string, maxBytes?: number): Promise<Buffer>
123  }
124  
125  /**
126   * Safely resolves a file path, handling symlinks and errors gracefully.
127   *
128   * Error handling strategy:
129   * - If the file doesn't exist, returns the original path (allows for file creation)
130   * - If symlink resolution fails (broken symlink, permission denied, circular links),
131   *   returns the original path and marks it as not a symlink
132   * - This ensures operations can continue with the original path rather than failing
133   *
134   * @param fs The filesystem implementation to use
135   * @param filePath The path to resolve
136   * @returns Object containing the resolved path and whether it was a symlink
137   */
138  export function safeResolvePath(
139    fs: FsOperations,
140    filePath: string,
141  ): { resolvedPath: string; isSymlink: boolean; isCanonical: boolean } {
142    // Block UNC paths before any filesystem access to prevent network
143    // requests (DNS/SMB) during validation on Windows
144    if (filePath.startsWith('//') || filePath.startsWith('\\\\')) {
145      return { resolvedPath: filePath, isSymlink: false, isCanonical: false }
146    }
147  
148    try {
149      // Check for special file types (FIFOs, sockets, devices) before calling realpathSync.
150      // realpathSync can block on FIFOs waiting for a writer, causing hangs.
151      // If the file doesn't exist, lstatSync throws ENOENT which the catch
152      // below handles by returning the original path (allows file creation).
153      const stats = fs.lstatSync(filePath)
154      if (
155        stats.isFIFO() ||
156        stats.isSocket() ||
157        stats.isCharacterDevice() ||
158        stats.isBlockDevice()
159      ) {
160        return { resolvedPath: filePath, isSymlink: false, isCanonical: false }
161      }
162  
163      const resolvedPath = fs.realpathSync(filePath)
164      return {
165        resolvedPath,
166        isSymlink: resolvedPath !== filePath,
167        // realpathSync returned: resolvedPath is canonical (all symlinks in
168        // all path components resolved). Callers can skip further symlink
169        // resolution on this path.
170        isCanonical: true,
171      }
172    } catch (_error) {
173      // If lstat/realpath fails for any reason (ENOENT, broken symlink,
174      // EACCES, ELOOP, etc.), return the original path to allow operations
175      // to proceed
176      return { resolvedPath: filePath, isSymlink: false, isCanonical: false }
177    }
178  }
179  
180  /**
181   * Check if a file path is a duplicate and should be skipped.
182   * Resolves symlinks to detect duplicates pointing to the same file.
183   * If not a duplicate, adds the resolved path to loadedPaths.
184   *
185   * @returns true if the file should be skipped (is duplicate)
186   */
187  export function isDuplicatePath(
188    fs: FsOperations,
189    filePath: string,
190    loadedPaths: Set<string>,
191  ): boolean {
192    const { resolvedPath } = safeResolvePath(fs, filePath)
193    if (loadedPaths.has(resolvedPath)) {
194      return true
195    }
196    loadedPaths.add(resolvedPath)
197    return false
198  }
199  
200  /**
201   * Resolve the deepest existing ancestor of a path via realpathSync, walking
202   * up until it succeeds. Detects dangling symlinks (link entry exists, target
203   * doesn't) via lstat and resolves them via readlink.
204   *
205   * Use when the input path may not exist (new file writes) and you need to
206   * know where the write would ACTUALLY land after the OS follows symlinks.
207   *
208   * Returns the resolved absolute path with non-existent tail segments
209   * rejoined, or undefined if no symlink was found in any existing ancestor
210   * (the path's existing ancestors all resolve to themselves).
211   *
212   * Handles: live parent symlinks, dangling file symlinks, dangling parent
213   * symlinks. Same core algorithm as teamMemPaths.ts:realpathDeepestExisting.
214   */
215  export function resolveDeepestExistingAncestorSync(
216    fs: FsOperations,
217    absolutePath: string,
218  ): string | undefined {
219    let dir = absolutePath
220    const segments: string[] = []
221    // Walk up using lstat (cheap, O(1)) to find the first existing component.
222    // lstat does not follow symlinks, so dangling symlinks are detected here.
223    // Only call realpathSync (expensive, O(depth)) once at the end.
224    while (dir !== nodePath.dirname(dir)) {
225      let st: fs.Stats
226      try {
227        st = fs.lstatSync(dir)
228      } catch {
229        // lstat failed: truly non-existent. Walk up.
230        segments.unshift(nodePath.basename(dir))
231        dir = nodePath.dirname(dir)
232        continue
233      }
234      if (st.isSymbolicLink()) {
235        // Found a symlink (live or dangling). Try realpath first (resolves
236        // chained symlinks); fall back to readlink for dangling symlinks.
237        try {
238          const resolved = fs.realpathSync(dir)
239          return segments.length === 0
240            ? resolved
241            : nodePath.join(resolved, ...segments)
242        } catch {
243          // Dangling: realpath failed but lstat saw the link entry.
244          const target = fs.readlinkSync(dir)
245          const absTarget = nodePath.isAbsolute(target)
246            ? target
247            : nodePath.resolve(nodePath.dirname(dir), target)
248          return segments.length === 0
249            ? absTarget
250            : nodePath.join(absTarget, ...segments)
251        }
252      }
253      // Existing non-symlink component. One realpath call resolves any
254      // symlinks in its ancestors. If none, return undefined (no symlink).
255      try {
256        const resolved = fs.realpathSync(dir)
257        if (resolved !== dir) {
258          return segments.length === 0
259            ? resolved
260            : nodePath.join(resolved, ...segments)
261        }
262      } catch {
263        // realpath can still fail (e.g. EACCES in ancestors). Return
264        // undefined — we can't resolve, and the logical path is already
265        // in pathSet for the caller.
266      }
267      return undefined
268    }
269    return undefined
270  }
271  
272  /**
273   * Gets all paths that should be checked for permissions.
274   * This includes the original path, all intermediate symlink targets in the chain,
275   * and the final resolved path.
276   *
277   * For example, if test.txt -> /etc/passwd -> /private/etc/passwd:
278   * - test.txt (original path)
279   * - /etc/passwd (intermediate symlink target)
280   * - /private/etc/passwd (final resolved path)
281   *
282   * This is important for security: a deny rule for /etc/passwd should block
283   * access even if the file is actually at /private/etc/passwd (as on macOS).
284   *
285   * @param path - The path to check (will be converted to absolute)
286   * @returns An array of absolute paths to check permissions for
287   */
288  export function getPathsForPermissionCheck(inputPath: string): string[] {
289    // Expand tilde notation defensively - tools should do this in getPath(),
290    // but we normalize here as defense in depth for permission checking
291    let path = inputPath
292    if (path === '~') {
293      path = homedir().normalize('NFC')
294    } else if (path.startsWith('~/')) {
295      path = nodePath.join(homedir().normalize('NFC'), path.slice(2))
296    }
297  
298    const pathSet = new Set<string>()
299    const fsImpl = getFsImplementation()
300  
301    // Always check the original path
302    pathSet.add(path)
303  
304    // Block UNC paths before any filesystem access to prevent network
305    // requests (DNS/SMB) during validation on Windows
306    if (path.startsWith('//') || path.startsWith('\\\\')) {
307      return Array.from(pathSet)
308    }
309  
310    // Follow the symlink chain, collecting ALL intermediate targets
311    // This handles cases like: test.txt -> /etc/passwd -> /private/etc/passwd
312    // We want to check all three paths, not just test.txt and /private/etc/passwd
313    try {
314      let currentPath = path
315      const visited = new Set<string>()
316      const maxDepth = 40 // Prevent runaway loops, matches typical SYMLOOP_MAX
317  
318      for (let depth = 0; depth < maxDepth; depth++) {
319        // Prevent infinite loops from circular symlinks
320        if (visited.has(currentPath)) {
321          break
322        }
323        visited.add(currentPath)
324  
325        if (!fsImpl.existsSync(currentPath)) {
326          // Path doesn't exist (new file case). existsSync follows symlinks,
327          // so this is also reached for DANGLING symlinks (link entry exists,
328          // target doesn't). Resolve symlinks in the path and its ancestors
329          // so permission checks see the real destination. Without this,
330          // `./data -> /etc/cron.d/` (live parent symlink) or
331          // `./evil.txt -> ~/.ssh/authorized_keys2` (dangling file symlink)
332          // would allow writes that escape the working directory.
333          if (currentPath === path) {
334            const resolved = resolveDeepestExistingAncestorSync(fsImpl, path)
335            if (resolved !== undefined) {
336              pathSet.add(resolved)
337            }
338          }
339          break
340        }
341  
342        const stats = fsImpl.lstatSync(currentPath)
343  
344        // Skip special file types that can cause issues
345        if (
346          stats.isFIFO() ||
347          stats.isSocket() ||
348          stats.isCharacterDevice() ||
349          stats.isBlockDevice()
350        ) {
351          break
352        }
353  
354        if (!stats.isSymbolicLink()) {
355          break
356        }
357  
358        // Get the immediate symlink target
359        const target = fsImpl.readlinkSync(currentPath)
360  
361        // If target is relative, resolve it relative to the symlink's directory
362        const absoluteTarget = nodePath.isAbsolute(target)
363          ? target
364          : nodePath.resolve(nodePath.dirname(currentPath), target)
365  
366        // Add this intermediate target to the set
367        pathSet.add(absoluteTarget)
368        currentPath = absoluteTarget
369      }
370    } catch {
371      // If anything fails during chain traversal, continue with what we have
372    }
373  
374    // Also add the final resolved path using realpathSync for completeness
375    // This handles any remaining symlinks in directory components
376    const { resolvedPath, isSymlink } = safeResolvePath(fsImpl, path)
377    if (isSymlink && resolvedPath !== path) {
378      pathSet.add(resolvedPath)
379    }
380  
381    return Array.from(pathSet)
382  }
383  
384  export const NodeFsOperations: FsOperations = {
385    cwd() {
386      return process.cwd()
387    },
388  
389    existsSync(fsPath) {
390      using _ = slowLogging`fs.existsSync(${fsPath})`
391      return fs.existsSync(fsPath)
392    },
393  
394    async stat(fsPath) {
395      return statPromise(fsPath)
396    },
397  
398    async readdir(fsPath) {
399      return readdirPromise(fsPath, { withFileTypes: true })
400    },
401  
402    async unlink(fsPath) {
403      return unlinkPromise(fsPath)
404    },
405  
406    async rmdir(fsPath) {
407      return rmdirPromise(fsPath)
408    },
409  
410    async rm(fsPath, options) {
411      return rmPromise(fsPath, options)
412    },
413  
414    async mkdir(dirPath, options) {
415      try {
416        await mkdirPromise(dirPath, { recursive: true, ...options })
417      } catch (e) {
418        // Bun/Windows: recursive:true throws EEXIST on directories with the
419        // FILE_ATTRIBUTE_READONLY bit set (Group Policy, OneDrive, desktop.ini).
420        // Bun's directoryExistsAt misclassifies DIRECTORY+READONLY as not-a-dir
421        // (bun-internal src/sys.zig existsAtType). The dir exists; ignore.
422        // https://github.com/anthropics/claude-code/issues/30924
423        if (getErrnoCode(e) !== 'EEXIST') throw e
424      }
425    },
426  
427    async readFile(fsPath, options) {
428      return readFilePromise(fsPath, { encoding: options.encoding })
429    },
430  
431    async rename(oldPath, newPath) {
432      return renamePromise(oldPath, newPath)
433    },
434  
435    statSync(fsPath) {
436      using _ = slowLogging`fs.statSync(${fsPath})`
437      return fs.statSync(fsPath)
438    },
439  
440    lstatSync(fsPath) {
441      using _ = slowLogging`fs.lstatSync(${fsPath})`
442      return fs.lstatSync(fsPath)
443    },
444  
445    readFileSync(fsPath, options) {
446      using _ = slowLogging`fs.readFileSync(${fsPath})`
447      return fs.readFileSync(fsPath, { encoding: options.encoding })
448    },
449  
450    readFileBytesSync(fsPath) {
451      using _ = slowLogging`fs.readFileBytesSync(${fsPath})`
452      return fs.readFileSync(fsPath)
453    },
454  
455    readSync(fsPath, options) {
456      using _ = slowLogging`fs.readSync(${fsPath}, ${options.length} bytes)`
457      let fd: number | undefined = undefined
458      try {
459        fd = fs.openSync(fsPath, 'r')
460        const buffer = Buffer.alloc(options.length)
461        const bytesRead = fs.readSync(fd, buffer, 0, options.length, 0)
462        return { buffer, bytesRead }
463      } finally {
464        if (fd) fs.closeSync(fd)
465      }
466    },
467  
468    appendFileSync(path, data, options) {
469      using _ = slowLogging`fs.appendFileSync(${path}, ${data.length} chars)`
470      // For new files with explicit mode, use 'ax' (atomic create-with-mode) to avoid
471      // TOCTOU race between existence check and open. Fall back to normal append if exists.
472      if (options?.mode !== undefined) {
473        try {
474          const fd = fs.openSync(path, 'ax', options.mode)
475          try {
476            fs.appendFileSync(fd, data)
477          } finally {
478            fs.closeSync(fd)
479          }
480          return
481        } catch (e) {
482          if (getErrnoCode(e) !== 'EEXIST') throw e
483          // File exists — fall through to normal append
484        }
485      }
486      fs.appendFileSync(path, data)
487    },
488  
489    copyFileSync(src, dest) {
490      using _ = slowLogging`fs.copyFileSync(${src} → ${dest})`
491      fs.copyFileSync(src, dest)
492    },
493  
494    unlinkSync(path: string) {
495      using _ = slowLogging`fs.unlinkSync(${path})`
496      fs.unlinkSync(path)
497    },
498  
499    renameSync(oldPath: string, newPath: string) {
500      using _ = slowLogging`fs.renameSync(${oldPath} → ${newPath})`
501      fs.renameSync(oldPath, newPath)
502    },
503  
504    linkSync(target: string, path: string) {
505      using _ = slowLogging`fs.linkSync(${target} → ${path})`
506      fs.linkSync(target, path)
507    },
508  
509    symlinkSync(
510      target: string,
511      path: string,
512      type?: 'dir' | 'file' | 'junction',
513    ) {
514      using _ = slowLogging`fs.symlinkSync(${target} → ${path})`
515      fs.symlinkSync(target, path, type)
516    },
517  
518    readlinkSync(path: string) {
519      using _ = slowLogging`fs.readlinkSync(${path})`
520      return fs.readlinkSync(path)
521    },
522  
523    realpathSync(path: string) {
524      using _ = slowLogging`fs.realpathSync(${path})`
525      return fs.realpathSync(path).normalize('NFC')
526    },
527  
528    mkdirSync(dirPath, options) {
529      using _ = slowLogging`fs.mkdirSync(${dirPath})`
530      const mkdirOptions: { recursive: boolean; mode?: number } = {
531        recursive: true,
532      }
533      if (options?.mode !== undefined) {
534        mkdirOptions.mode = options.mode
535      }
536      try {
537        fs.mkdirSync(dirPath, mkdirOptions)
538      } catch (e) {
539        // Bun/Windows: recursive:true throws EEXIST on directories with the
540        // FILE_ATTRIBUTE_READONLY bit set (Group Policy, OneDrive, desktop.ini).
541        // Bun's directoryExistsAt misclassifies DIRECTORY+READONLY as not-a-dir
542        // (bun-internal src/sys.zig existsAtType). The dir exists; ignore.
543        // https://github.com/anthropics/claude-code/issues/30924
544        if (getErrnoCode(e) !== 'EEXIST') throw e
545      }
546    },
547  
548    readdirSync(dirPath) {
549      using _ = slowLogging`fs.readdirSync(${dirPath})`
550      return fs.readdirSync(dirPath, { withFileTypes: true })
551    },
552  
553    readdirStringSync(dirPath) {
554      using _ = slowLogging`fs.readdirStringSync(${dirPath})`
555      return fs.readdirSync(dirPath)
556    },
557  
558    isDirEmptySync(dirPath) {
559      using _ = slowLogging`fs.isDirEmptySync(${dirPath})`
560      const files = this.readdirSync(dirPath)
561      return files.length === 0
562    },
563  
564    rmdirSync(dirPath) {
565      using _ = slowLogging`fs.rmdirSync(${dirPath})`
566      fs.rmdirSync(dirPath)
567    },
568  
569    rmSync(path, options) {
570      using _ = slowLogging`fs.rmSync(${path})`
571      fs.rmSync(path, options)
572    },
573  
574    createWriteStream(path: string) {
575      return fs.createWriteStream(path)
576    },
577  
578    async readFileBytes(fsPath: string, maxBytes?: number) {
579      if (maxBytes === undefined) {
580        return readFilePromise(fsPath)
581      }
582      const handle = await open(fsPath, 'r')
583      try {
584        const { size } = await handle.stat()
585        const readSize = Math.min(size, maxBytes)
586        const buffer = Buffer.allocUnsafe(readSize)
587        let offset = 0
588        while (offset < readSize) {
589          const { bytesRead } = await handle.read(
590            buffer,
591            offset,
592            readSize - offset,
593            offset,
594          )
595          if (bytesRead === 0) break
596          offset += bytesRead
597        }
598        return offset < readSize ? buffer.subarray(0, offset) : buffer
599      } finally {
600        await handle.close()
601      }
602    },
603  }
604  
605  // The currently active filesystem implementation
606  let activeFs: FsOperations = NodeFsOperations
607  
608  /**
609   * Overrides the filesystem implementation. Note: This function does not
610   * automatically update cwd.
611   * @param implementation The filesystem implementation to use
612   */
613  export function setFsImplementation(implementation: FsOperations): void {
614    activeFs = implementation
615  }
616  
617  /**
618   * Gets the currently active filesystem implementation
619   * @returns The currently active filesystem implementation
620   */
621  export function getFsImplementation(): FsOperations {
622    return activeFs
623  }
624  
625  /**
626   * Resets the filesystem implementation to the default Node.js implementation.
627   * Note: This function does not automatically update cwd.
628   */
629  export function setOriginalFsImplementation(): void {
630    activeFs = NodeFsOperations
631  }
632  
633  export type ReadFileRangeResult = {
634    content: string
635    bytesRead: number
636    bytesTotal: number
637  }
638  
639  /**
640   * Read up to `maxBytes` from a file starting at `offset`.
641   * Returns a flat string from Buffer — no sliced string references to a
642   * larger parent. Returns null if the file is smaller than the offset.
643   */
644  export async function readFileRange(
645    path: string,
646    offset: number,
647    maxBytes: number,
648  ): Promise<ReadFileRangeResult | null> {
649    await using fh = await open(path, 'r')
650    const size = (await fh.stat()).size
651    if (size <= offset) {
652      return null
653    }
654    const bytesToRead = Math.min(size - offset, maxBytes)
655    const buffer = Buffer.allocUnsafe(bytesToRead)
656  
657    let totalRead = 0
658    while (totalRead < bytesToRead) {
659      const { bytesRead } = await fh.read(
660        buffer,
661        totalRead,
662        bytesToRead - totalRead,
663        offset + totalRead,
664      )
665      if (bytesRead === 0) {
666        break
667      }
668      totalRead += bytesRead
669    }
670  
671    return {
672      content: buffer.toString('utf8', 0, totalRead),
673      bytesRead: totalRead,
674      bytesTotal: size,
675    }
676  }
677  
678  /**
679   * Read the last `maxBytes` of a file.
680   * Returns the whole file if it's smaller than maxBytes.
681   */
682  export async function tailFile(
683    path: string,
684    maxBytes: number,
685  ): Promise<ReadFileRangeResult> {
686    await using fh = await open(path, 'r')
687    const size = (await fh.stat()).size
688    if (size === 0) {
689      return { content: '', bytesRead: 0, bytesTotal: 0 }
690    }
691    const offset = Math.max(0, size - maxBytes)
692    const bytesToRead = size - offset
693    const buffer = Buffer.allocUnsafe(bytesToRead)
694  
695    let totalRead = 0
696    while (totalRead < bytesToRead) {
697      const { bytesRead } = await fh.read(
698        buffer,
699        totalRead,
700        bytesToRead - totalRead,
701        offset + totalRead,
702      )
703      if (bytesRead === 0) {
704        break
705      }
706      totalRead += bytesRead
707    }
708  
709    return {
710      content: buffer.toString('utf8', 0, totalRead),
711      bytesRead: totalRead,
712      bytesTotal: size,
713    }
714  }
715  
716  /**
717   * Async generator that yields lines from a file in reverse order.
718   * Reads the file backwards in chunks to avoid loading the entire file into memory.
719   * @param path - The path to the file to read
720   * @returns An async generator that yields lines in reverse order
721   */
722  export async function* readLinesReverse(
723    path: string,
724  ): AsyncGenerator<string, void, undefined> {
725    const CHUNK_SIZE = 1024 * 4
726    const fileHandle = await open(path, 'r')
727    try {
728      const stats = await fileHandle.stat()
729      let position = stats.size
730      // Carry raw bytes (not a decoded string) across chunk boundaries so that
731      // multi-byte UTF-8 sequences split by the 4KB boundary are not corrupted.
732      // Decoding per-chunk would turn a split sequence into U+FFFD on both sides,
733      // which for history.jsonl means JSON.parse throws and the entry is dropped.
734      let remainder = Buffer.alloc(0)
735      const buffer = Buffer.alloc(CHUNK_SIZE)
736  
737      while (position > 0) {
738        const currentChunkSize = Math.min(CHUNK_SIZE, position)
739        position -= currentChunkSize
740  
741        await fileHandle.read(buffer, 0, currentChunkSize, position)
742        const combined = Buffer.concat([
743          buffer.subarray(0, currentChunkSize),
744          remainder,
745        ])
746  
747        const firstNewline = combined.indexOf(0x0a)
748        if (firstNewline === -1) {
749          remainder = combined
750          continue
751        }
752  
753        remainder = Buffer.from(combined.subarray(0, firstNewline))
754        const lines = combined.toString('utf8', firstNewline + 1).split('\n')
755  
756        for (let i = lines.length - 1; i >= 0; i--) {
757          const line = lines[i]!
758          if (line) {
759            yield line
760          }
761        }
762      }
763  
764      if (remainder.length > 0) {
765        yield remainder.toString('utf8')
766      }
767    } finally {
768      await fileHandle.close()
769    }
770  }