init claude-code
This commit is contained in:
@@ -0,0 +1,770 @@
|
||||
import * as fs from 'fs'
|
||||
import {
|
||||
mkdir as mkdirPromise,
|
||||
open,
|
||||
readdir as readdirPromise,
|
||||
readFile as readFilePromise,
|
||||
rename as renamePromise,
|
||||
rmdir as rmdirPromise,
|
||||
rm as rmPromise,
|
||||
stat as statPromise,
|
||||
unlink as unlinkPromise,
|
||||
} from 'fs/promises'
|
||||
import { homedir } from 'os'
|
||||
import * as nodePath from 'path'
|
||||
import { getErrnoCode } from './errors.js'
|
||||
import { slowLogging } from './slowOperations.js'
|
||||
|
||||
/**
|
||||
* Simplified filesystem operations interface based on Node.js fs module.
|
||||
* Provides a subset of commonly used sync operations with type safety.
|
||||
* Allows abstraction for alternative implementations (e.g., mock, virtual).
|
||||
*/
|
||||
export type FsOperations = {
|
||||
// File access and information operations
|
||||
/** Gets the current working directory */
|
||||
cwd(): string
|
||||
/** Checks if a file or directory exists */
|
||||
existsSync(path: string): boolean
|
||||
/** Gets file stats asynchronously */
|
||||
stat(path: string): Promise<fs.Stats>
|
||||
/** Lists directory contents with file type information asynchronously */
|
||||
readdir(path: string): Promise<fs.Dirent[]>
|
||||
/** Deletes file asynchronously */
|
||||
unlink(path: string): Promise<void>
|
||||
/** Removes an empty directory asynchronously */
|
||||
rmdir(path: string): Promise<void>
|
||||
/** Removes files and directories asynchronously (with recursive option) */
|
||||
rm(
|
||||
path: string,
|
||||
options?: { recursive?: boolean; force?: boolean },
|
||||
): Promise<void>
|
||||
/** Creates directory recursively asynchronously. */
|
||||
mkdir(path: string, options?: { mode?: number }): Promise<void>
|
||||
/** Reads file content as string asynchronously */
|
||||
readFile(path: string, options: { encoding: BufferEncoding }): Promise<string>
|
||||
/** Renames/moves file asynchronously */
|
||||
rename(oldPath: string, newPath: string): Promise<void>
|
||||
/** Gets file stats */
|
||||
statSync(path: string): fs.Stats
|
||||
/** Gets file stats without following symlinks */
|
||||
lstatSync(path: string): fs.Stats
|
||||
|
||||
// File content operations
|
||||
/** Reads file content as string with specified encoding */
|
||||
readFileSync(
|
||||
path: string,
|
||||
options: {
|
||||
encoding: BufferEncoding
|
||||
},
|
||||
): string
|
||||
/** Reads raw file bytes as Buffer */
|
||||
readFileBytesSync(path: string): Buffer
|
||||
/** Reads specified number of bytes from file start */
|
||||
readSync(
|
||||
path: string,
|
||||
options: {
|
||||
length: number
|
||||
},
|
||||
): {
|
||||
buffer: Buffer
|
||||
bytesRead: number
|
||||
}
|
||||
/** Appends string to file */
|
||||
appendFileSync(path: string, data: string, options?: { mode?: number }): void
|
||||
/** Copies file from source to destination */
|
||||
copyFileSync(src: string, dest: string): void
|
||||
/** Deletes file */
|
||||
unlinkSync(path: string): void
|
||||
/** Renames/moves file */
|
||||
renameSync(oldPath: string, newPath: string): void
|
||||
/** Creates hard link */
|
||||
linkSync(target: string, path: string): void
|
||||
/** Creates symbolic link */
|
||||
symlinkSync(
|
||||
target: string,
|
||||
path: string,
|
||||
type?: 'dir' | 'file' | 'junction',
|
||||
): void
|
||||
/** Reads symbolic link */
|
||||
readlinkSync(path: string): string
|
||||
/** Resolves symbolic links and returns the canonical pathname */
|
||||
realpathSync(path: string): string
|
||||
|
||||
// Directory operations
|
||||
/** Creates directory recursively. Mode defaults to 0o777 & ~umask if not specified. */
|
||||
mkdirSync(
|
||||
path: string,
|
||||
options?: {
|
||||
mode?: number
|
||||
},
|
||||
): void
|
||||
/** Lists directory contents with file type information */
|
||||
readdirSync(path: string): fs.Dirent[]
|
||||
/** Lists directory contents as strings */
|
||||
readdirStringSync(path: string): string[]
|
||||
/** Checks if the directory is empty */
|
||||
isDirEmptySync(path: string): boolean
|
||||
/** Removes an empty directory */
|
||||
rmdirSync(path: string): void
|
||||
/** Removes files and directories (with recursive option) */
|
||||
rmSync(
|
||||
path: string,
|
||||
options?: {
|
||||
recursive?: boolean
|
||||
force?: boolean
|
||||
},
|
||||
): void
|
||||
/** Create a writable stream for writing data to a file. */
|
||||
createWriteStream(path: string): fs.WriteStream
|
||||
/** Reads raw file bytes as Buffer asynchronously.
|
||||
* When maxBytes is set, only reads up to that many bytes. */
|
||||
readFileBytes(path: string, maxBytes?: number): Promise<Buffer>
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely resolves a file path, handling symlinks and errors gracefully.
|
||||
*
|
||||
* Error handling strategy:
|
||||
* - If the file doesn't exist, returns the original path (allows for file creation)
|
||||
* - If symlink resolution fails (broken symlink, permission denied, circular links),
|
||||
* returns the original path and marks it as not a symlink
|
||||
* - This ensures operations can continue with the original path rather than failing
|
||||
*
|
||||
* @param fs The filesystem implementation to use
|
||||
* @param filePath The path to resolve
|
||||
* @returns Object containing the resolved path and whether it was a symlink
|
||||
*/
|
||||
export function safeResolvePath(
|
||||
fs: FsOperations,
|
||||
filePath: string,
|
||||
): { resolvedPath: string; isSymlink: boolean; isCanonical: boolean } {
|
||||
// Block UNC paths before any filesystem access to prevent network
|
||||
// requests (DNS/SMB) during validation on Windows
|
||||
if (filePath.startsWith('//') || filePath.startsWith('\\\\')) {
|
||||
return { resolvedPath: filePath, isSymlink: false, isCanonical: false }
|
||||
}
|
||||
|
||||
try {
|
||||
// Check for special file types (FIFOs, sockets, devices) before calling realpathSync.
|
||||
// realpathSync can block on FIFOs waiting for a writer, causing hangs.
|
||||
// If the file doesn't exist, lstatSync throws ENOENT which the catch
|
||||
// below handles by returning the original path (allows file creation).
|
||||
const stats = fs.lstatSync(filePath)
|
||||
if (
|
||||
stats.isFIFO() ||
|
||||
stats.isSocket() ||
|
||||
stats.isCharacterDevice() ||
|
||||
stats.isBlockDevice()
|
||||
) {
|
||||
return { resolvedPath: filePath, isSymlink: false, isCanonical: false }
|
||||
}
|
||||
|
||||
const resolvedPath = fs.realpathSync(filePath)
|
||||
return {
|
||||
resolvedPath,
|
||||
isSymlink: resolvedPath !== filePath,
|
||||
// realpathSync returned: resolvedPath is canonical (all symlinks in
|
||||
// all path components resolved). Callers can skip further symlink
|
||||
// resolution on this path.
|
||||
isCanonical: true,
|
||||
}
|
||||
} catch (_error) {
|
||||
// If lstat/realpath fails for any reason (ENOENT, broken symlink,
|
||||
// EACCES, ELOOP, etc.), return the original path to allow operations
|
||||
// to proceed
|
||||
return { resolvedPath: filePath, isSymlink: false, isCanonical: false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file path is a duplicate and should be skipped.
|
||||
* Resolves symlinks to detect duplicates pointing to the same file.
|
||||
* If not a duplicate, adds the resolved path to loadedPaths.
|
||||
*
|
||||
* @returns true if the file should be skipped (is duplicate)
|
||||
*/
|
||||
export function isDuplicatePath(
|
||||
fs: FsOperations,
|
||||
filePath: string,
|
||||
loadedPaths: Set<string>,
|
||||
): boolean {
|
||||
const { resolvedPath } = safeResolvePath(fs, filePath)
|
||||
if (loadedPaths.has(resolvedPath)) {
|
||||
return true
|
||||
}
|
||||
loadedPaths.add(resolvedPath)
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the deepest existing ancestor of a path via realpathSync, walking
|
||||
* up until it succeeds. Detects dangling symlinks (link entry exists, target
|
||||
* doesn't) via lstat and resolves them via readlink.
|
||||
*
|
||||
* Use when the input path may not exist (new file writes) and you need to
|
||||
* know where the write would ACTUALLY land after the OS follows symlinks.
|
||||
*
|
||||
* Returns the resolved absolute path with non-existent tail segments
|
||||
* rejoined, or undefined if no symlink was found in any existing ancestor
|
||||
* (the path's existing ancestors all resolve to themselves).
|
||||
*
|
||||
* Handles: live parent symlinks, dangling file symlinks, dangling parent
|
||||
* symlinks. Same core algorithm as teamMemPaths.ts:realpathDeepestExisting.
|
||||
*/
|
||||
export function resolveDeepestExistingAncestorSync(
|
||||
fs: FsOperations,
|
||||
absolutePath: string,
|
||||
): string | undefined {
|
||||
let dir = absolutePath
|
||||
const segments: string[] = []
|
||||
// Walk up using lstat (cheap, O(1)) to find the first existing component.
|
||||
// lstat does not follow symlinks, so dangling symlinks are detected here.
|
||||
// Only call realpathSync (expensive, O(depth)) once at the end.
|
||||
while (dir !== nodePath.dirname(dir)) {
|
||||
let st: fs.Stats
|
||||
try {
|
||||
st = fs.lstatSync(dir)
|
||||
} catch {
|
||||
// lstat failed: truly non-existent. Walk up.
|
||||
segments.unshift(nodePath.basename(dir))
|
||||
dir = nodePath.dirname(dir)
|
||||
continue
|
||||
}
|
||||
if (st.isSymbolicLink()) {
|
||||
// Found a symlink (live or dangling). Try realpath first (resolves
|
||||
// chained symlinks); fall back to readlink for dangling symlinks.
|
||||
try {
|
||||
const resolved = fs.realpathSync(dir)
|
||||
return segments.length === 0
|
||||
? resolved
|
||||
: nodePath.join(resolved, ...segments)
|
||||
} catch {
|
||||
// Dangling: realpath failed but lstat saw the link entry.
|
||||
const target = fs.readlinkSync(dir)
|
||||
const absTarget = nodePath.isAbsolute(target)
|
||||
? target
|
||||
: nodePath.resolve(nodePath.dirname(dir), target)
|
||||
return segments.length === 0
|
||||
? absTarget
|
||||
: nodePath.join(absTarget, ...segments)
|
||||
}
|
||||
}
|
||||
// Existing non-symlink component. One realpath call resolves any
|
||||
// symlinks in its ancestors. If none, return undefined (no symlink).
|
||||
try {
|
||||
const resolved = fs.realpathSync(dir)
|
||||
if (resolved !== dir) {
|
||||
return segments.length === 0
|
||||
? resolved
|
||||
: nodePath.join(resolved, ...segments)
|
||||
}
|
||||
} catch {
|
||||
// realpath can still fail (e.g. EACCES in ancestors). Return
|
||||
// undefined — we can't resolve, and the logical path is already
|
||||
// in pathSet for the caller.
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all paths that should be checked for permissions.
|
||||
* This includes the original path, all intermediate symlink targets in the chain,
|
||||
* and the final resolved path.
|
||||
*
|
||||
* For example, if test.txt -> /etc/passwd -> /private/etc/passwd:
|
||||
* - test.txt (original path)
|
||||
* - /etc/passwd (intermediate symlink target)
|
||||
* - /private/etc/passwd (final resolved path)
|
||||
*
|
||||
* This is important for security: a deny rule for /etc/passwd should block
|
||||
* access even if the file is actually at /private/etc/passwd (as on macOS).
|
||||
*
|
||||
* @param path - The path to check (will be converted to absolute)
|
||||
* @returns An array of absolute paths to check permissions for
|
||||
*/
|
||||
export function getPathsForPermissionCheck(inputPath: string): string[] {
|
||||
// Expand tilde notation defensively - tools should do this in getPath(),
|
||||
// but we normalize here as defense in depth for permission checking
|
||||
let path = inputPath
|
||||
if (path === '~') {
|
||||
path = homedir().normalize('NFC')
|
||||
} else if (path.startsWith('~/')) {
|
||||
path = nodePath.join(homedir().normalize('NFC'), path.slice(2))
|
||||
}
|
||||
|
||||
const pathSet = new Set<string>()
|
||||
const fsImpl = getFsImplementation()
|
||||
|
||||
// Always check the original path
|
||||
pathSet.add(path)
|
||||
|
||||
// Block UNC paths before any filesystem access to prevent network
|
||||
// requests (DNS/SMB) during validation on Windows
|
||||
if (path.startsWith('//') || path.startsWith('\\\\')) {
|
||||
return Array.from(pathSet)
|
||||
}
|
||||
|
||||
// Follow the symlink chain, collecting ALL intermediate targets
|
||||
// This handles cases like: test.txt -> /etc/passwd -> /private/etc/passwd
|
||||
// We want to check all three paths, not just test.txt and /private/etc/passwd
|
||||
try {
|
||||
let currentPath = path
|
||||
const visited = new Set<string>()
|
||||
const maxDepth = 40 // Prevent runaway loops, matches typical SYMLOOP_MAX
|
||||
|
||||
for (let depth = 0; depth < maxDepth; depth++) {
|
||||
// Prevent infinite loops from circular symlinks
|
||||
if (visited.has(currentPath)) {
|
||||
break
|
||||
}
|
||||
visited.add(currentPath)
|
||||
|
||||
if (!fsImpl.existsSync(currentPath)) {
|
||||
// Path doesn't exist (new file case). existsSync follows symlinks,
|
||||
// so this is also reached for DANGLING symlinks (link entry exists,
|
||||
// target doesn't). Resolve symlinks in the path and its ancestors
|
||||
// so permission checks see the real destination. Without this,
|
||||
// `./data -> /etc/cron.d/` (live parent symlink) or
|
||||
// `./evil.txt -> ~/.ssh/authorized_keys2` (dangling file symlink)
|
||||
// would allow writes that escape the working directory.
|
||||
if (currentPath === path) {
|
||||
const resolved = resolveDeepestExistingAncestorSync(fsImpl, path)
|
||||
if (resolved !== undefined) {
|
||||
pathSet.add(resolved)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
const stats = fsImpl.lstatSync(currentPath)
|
||||
|
||||
// Skip special file types that can cause issues
|
||||
if (
|
||||
stats.isFIFO() ||
|
||||
stats.isSocket() ||
|
||||
stats.isCharacterDevice() ||
|
||||
stats.isBlockDevice()
|
||||
) {
|
||||
break
|
||||
}
|
||||
|
||||
if (!stats.isSymbolicLink()) {
|
||||
break
|
||||
}
|
||||
|
||||
// Get the immediate symlink target
|
||||
const target = fsImpl.readlinkSync(currentPath)
|
||||
|
||||
// If target is relative, resolve it relative to the symlink's directory
|
||||
const absoluteTarget = nodePath.isAbsolute(target)
|
||||
? target
|
||||
: nodePath.resolve(nodePath.dirname(currentPath), target)
|
||||
|
||||
// Add this intermediate target to the set
|
||||
pathSet.add(absoluteTarget)
|
||||
currentPath = absoluteTarget
|
||||
}
|
||||
} catch {
|
||||
// If anything fails during chain traversal, continue with what we have
|
||||
}
|
||||
|
||||
// Also add the final resolved path using realpathSync for completeness
|
||||
// This handles any remaining symlinks in directory components
|
||||
const { resolvedPath, isSymlink } = safeResolvePath(fsImpl, path)
|
||||
if (isSymlink && resolvedPath !== path) {
|
||||
pathSet.add(resolvedPath)
|
||||
}
|
||||
|
||||
return Array.from(pathSet)
|
||||
}
|
||||
|
||||
export const NodeFsOperations: FsOperations = {
|
||||
cwd() {
|
||||
return process.cwd()
|
||||
},
|
||||
|
||||
existsSync(fsPath) {
|
||||
using _ = slowLogging`fs.existsSync(${fsPath})`
|
||||
return fs.existsSync(fsPath)
|
||||
},
|
||||
|
||||
async stat(fsPath) {
|
||||
return statPromise(fsPath)
|
||||
},
|
||||
|
||||
async readdir(fsPath) {
|
||||
return readdirPromise(fsPath, { withFileTypes: true })
|
||||
},
|
||||
|
||||
async unlink(fsPath) {
|
||||
return unlinkPromise(fsPath)
|
||||
},
|
||||
|
||||
async rmdir(fsPath) {
|
||||
return rmdirPromise(fsPath)
|
||||
},
|
||||
|
||||
async rm(fsPath, options) {
|
||||
return rmPromise(fsPath, options)
|
||||
},
|
||||
|
||||
async mkdir(dirPath, options) {
|
||||
try {
|
||||
await mkdirPromise(dirPath, { recursive: true, ...options })
|
||||
} catch (e) {
|
||||
// Bun/Windows: recursive:true throws EEXIST on directories with the
|
||||
// FILE_ATTRIBUTE_READONLY bit set (Group Policy, OneDrive, desktop.ini).
|
||||
// Bun's directoryExistsAt misclassifies DIRECTORY+READONLY as not-a-dir
|
||||
// (bun-internal src/sys.zig existsAtType). The dir exists; ignore.
|
||||
// https://github.com/anthropics/claude-code/issues/30924
|
||||
if (getErrnoCode(e) !== 'EEXIST') throw e
|
||||
}
|
||||
},
|
||||
|
||||
async readFile(fsPath, options) {
|
||||
return readFilePromise(fsPath, { encoding: options.encoding })
|
||||
},
|
||||
|
||||
async rename(oldPath, newPath) {
|
||||
return renamePromise(oldPath, newPath)
|
||||
},
|
||||
|
||||
statSync(fsPath) {
|
||||
using _ = slowLogging`fs.statSync(${fsPath})`
|
||||
return fs.statSync(fsPath)
|
||||
},
|
||||
|
||||
lstatSync(fsPath) {
|
||||
using _ = slowLogging`fs.lstatSync(${fsPath})`
|
||||
return fs.lstatSync(fsPath)
|
||||
},
|
||||
|
||||
readFileSync(fsPath, options) {
|
||||
using _ = slowLogging`fs.readFileSync(${fsPath})`
|
||||
return fs.readFileSync(fsPath, { encoding: options.encoding })
|
||||
},
|
||||
|
||||
readFileBytesSync(fsPath) {
|
||||
using _ = slowLogging`fs.readFileBytesSync(${fsPath})`
|
||||
return fs.readFileSync(fsPath)
|
||||
},
|
||||
|
||||
readSync(fsPath, options) {
|
||||
using _ = slowLogging`fs.readSync(${fsPath}, ${options.length} bytes)`
|
||||
let fd: number | undefined = undefined
|
||||
try {
|
||||
fd = fs.openSync(fsPath, 'r')
|
||||
const buffer = Buffer.alloc(options.length)
|
||||
const bytesRead = fs.readSync(fd, buffer, 0, options.length, 0)
|
||||
return { buffer, bytesRead }
|
||||
} finally {
|
||||
if (fd) fs.closeSync(fd)
|
||||
}
|
||||
},
|
||||
|
||||
appendFileSync(path, data, options) {
|
||||
using _ = slowLogging`fs.appendFileSync(${path}, ${data.length} chars)`
|
||||
// For new files with explicit mode, use 'ax' (atomic create-with-mode) to avoid
|
||||
// TOCTOU race between existence check and open. Fall back to normal append if exists.
|
||||
if (options?.mode !== undefined) {
|
||||
try {
|
||||
const fd = fs.openSync(path, 'ax', options.mode)
|
||||
try {
|
||||
fs.appendFileSync(fd, data)
|
||||
} finally {
|
||||
fs.closeSync(fd)
|
||||
}
|
||||
return
|
||||
} catch (e) {
|
||||
if (getErrnoCode(e) !== 'EEXIST') throw e
|
||||
// File exists — fall through to normal append
|
||||
}
|
||||
}
|
||||
fs.appendFileSync(path, data)
|
||||
},
|
||||
|
||||
copyFileSync(src, dest) {
|
||||
using _ = slowLogging`fs.copyFileSync(${src} → ${dest})`
|
||||
fs.copyFileSync(src, dest)
|
||||
},
|
||||
|
||||
unlinkSync(path: string) {
|
||||
using _ = slowLogging`fs.unlinkSync(${path})`
|
||||
fs.unlinkSync(path)
|
||||
},
|
||||
|
||||
renameSync(oldPath: string, newPath: string) {
|
||||
using _ = slowLogging`fs.renameSync(${oldPath} → ${newPath})`
|
||||
fs.renameSync(oldPath, newPath)
|
||||
},
|
||||
|
||||
linkSync(target: string, path: string) {
|
||||
using _ = slowLogging`fs.linkSync(${target} → ${path})`
|
||||
fs.linkSync(target, path)
|
||||
},
|
||||
|
||||
symlinkSync(
|
||||
target: string,
|
||||
path: string,
|
||||
type?: 'dir' | 'file' | 'junction',
|
||||
) {
|
||||
using _ = slowLogging`fs.symlinkSync(${target} → ${path})`
|
||||
fs.symlinkSync(target, path, type)
|
||||
},
|
||||
|
||||
readlinkSync(path: string) {
|
||||
using _ = slowLogging`fs.readlinkSync(${path})`
|
||||
return fs.readlinkSync(path)
|
||||
},
|
||||
|
||||
realpathSync(path: string) {
|
||||
using _ = slowLogging`fs.realpathSync(${path})`
|
||||
return fs.realpathSync(path).normalize('NFC')
|
||||
},
|
||||
|
||||
mkdirSync(dirPath, options) {
|
||||
using _ = slowLogging`fs.mkdirSync(${dirPath})`
|
||||
const mkdirOptions: { recursive: boolean; mode?: number } = {
|
||||
recursive: true,
|
||||
}
|
||||
if (options?.mode !== undefined) {
|
||||
mkdirOptions.mode = options.mode
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(dirPath, mkdirOptions)
|
||||
} catch (e) {
|
||||
// Bun/Windows: recursive:true throws EEXIST on directories with the
|
||||
// FILE_ATTRIBUTE_READONLY bit set (Group Policy, OneDrive, desktop.ini).
|
||||
// Bun's directoryExistsAt misclassifies DIRECTORY+READONLY as not-a-dir
|
||||
// (bun-internal src/sys.zig existsAtType). The dir exists; ignore.
|
||||
// https://github.com/anthropics/claude-code/issues/30924
|
||||
if (getErrnoCode(e) !== 'EEXIST') throw e
|
||||
}
|
||||
},
|
||||
|
||||
readdirSync(dirPath) {
|
||||
using _ = slowLogging`fs.readdirSync(${dirPath})`
|
||||
return fs.readdirSync(dirPath, { withFileTypes: true })
|
||||
},
|
||||
|
||||
readdirStringSync(dirPath) {
|
||||
using _ = slowLogging`fs.readdirStringSync(${dirPath})`
|
||||
return fs.readdirSync(dirPath)
|
||||
},
|
||||
|
||||
isDirEmptySync(dirPath) {
|
||||
using _ = slowLogging`fs.isDirEmptySync(${dirPath})`
|
||||
const files = this.readdirSync(dirPath)
|
||||
return files.length === 0
|
||||
},
|
||||
|
||||
rmdirSync(dirPath) {
|
||||
using _ = slowLogging`fs.rmdirSync(${dirPath})`
|
||||
fs.rmdirSync(dirPath)
|
||||
},
|
||||
|
||||
rmSync(path, options) {
|
||||
using _ = slowLogging`fs.rmSync(${path})`
|
||||
fs.rmSync(path, options)
|
||||
},
|
||||
|
||||
createWriteStream(path: string) {
|
||||
return fs.createWriteStream(path)
|
||||
},
|
||||
|
||||
async readFileBytes(fsPath: string, maxBytes?: number) {
|
||||
if (maxBytes === undefined) {
|
||||
return readFilePromise(fsPath)
|
||||
}
|
||||
const handle = await open(fsPath, 'r')
|
||||
try {
|
||||
const { size } = await handle.stat()
|
||||
const readSize = Math.min(size, maxBytes)
|
||||
const buffer = Buffer.allocUnsafe(readSize)
|
||||
let offset = 0
|
||||
while (offset < readSize) {
|
||||
const { bytesRead } = await handle.read(
|
||||
buffer,
|
||||
offset,
|
||||
readSize - offset,
|
||||
offset,
|
||||
)
|
||||
if (bytesRead === 0) break
|
||||
offset += bytesRead
|
||||
}
|
||||
return offset < readSize ? buffer.subarray(0, offset) : buffer
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// The currently active filesystem implementation
|
||||
let activeFs: FsOperations = NodeFsOperations
|
||||
|
||||
/**
|
||||
* Overrides the filesystem implementation. Note: This function does not
|
||||
* automatically update cwd.
|
||||
* @param implementation The filesystem implementation to use
|
||||
*/
|
||||
export function setFsImplementation(implementation: FsOperations): void {
|
||||
activeFs = implementation
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the currently active filesystem implementation
|
||||
* @returns The currently active filesystem implementation
|
||||
*/
|
||||
export function getFsImplementation(): FsOperations {
|
||||
return activeFs
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the filesystem implementation to the default Node.js implementation.
|
||||
* Note: This function does not automatically update cwd.
|
||||
*/
|
||||
export function setOriginalFsImplementation(): void {
|
||||
activeFs = NodeFsOperations
|
||||
}
|
||||
|
||||
export type ReadFileRangeResult = {
|
||||
content: string
|
||||
bytesRead: number
|
||||
bytesTotal: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Read up to `maxBytes` from a file starting at `offset`.
|
||||
* Returns a flat string from Buffer — no sliced string references to a
|
||||
* larger parent. Returns null if the file is smaller than the offset.
|
||||
*/
|
||||
export async function readFileRange(
|
||||
path: string,
|
||||
offset: number,
|
||||
maxBytes: number,
|
||||
): Promise<ReadFileRangeResult | null> {
|
||||
await using fh = await open(path, 'r')
|
||||
const size = (await fh.stat()).size
|
||||
if (size <= offset) {
|
||||
return null
|
||||
}
|
||||
const bytesToRead = Math.min(size - offset, maxBytes)
|
||||
const buffer = Buffer.allocUnsafe(bytesToRead)
|
||||
|
||||
let totalRead = 0
|
||||
while (totalRead < bytesToRead) {
|
||||
const { bytesRead } = await fh.read(
|
||||
buffer,
|
||||
totalRead,
|
||||
bytesToRead - totalRead,
|
||||
offset + totalRead,
|
||||
)
|
||||
if (bytesRead === 0) {
|
||||
break
|
||||
}
|
||||
totalRead += bytesRead
|
||||
}
|
||||
|
||||
return {
|
||||
content: buffer.toString('utf8', 0, totalRead),
|
||||
bytesRead: totalRead,
|
||||
bytesTotal: size,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the last `maxBytes` of a file.
|
||||
* Returns the whole file if it's smaller than maxBytes.
|
||||
*/
|
||||
export async function tailFile(
|
||||
path: string,
|
||||
maxBytes: number,
|
||||
): Promise<ReadFileRangeResult> {
|
||||
await using fh = await open(path, 'r')
|
||||
const size = (await fh.stat()).size
|
||||
if (size === 0) {
|
||||
return { content: '', bytesRead: 0, bytesTotal: 0 }
|
||||
}
|
||||
const offset = Math.max(0, size - maxBytes)
|
||||
const bytesToRead = size - offset
|
||||
const buffer = Buffer.allocUnsafe(bytesToRead)
|
||||
|
||||
let totalRead = 0
|
||||
while (totalRead < bytesToRead) {
|
||||
const { bytesRead } = await fh.read(
|
||||
buffer,
|
||||
totalRead,
|
||||
bytesToRead - totalRead,
|
||||
offset + totalRead,
|
||||
)
|
||||
if (bytesRead === 0) {
|
||||
break
|
||||
}
|
||||
totalRead += bytesRead
|
||||
}
|
||||
|
||||
return {
|
||||
content: buffer.toString('utf8', 0, totalRead),
|
||||
bytesRead: totalRead,
|
||||
bytesTotal: size,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Async generator that yields lines from a file in reverse order.
|
||||
* Reads the file backwards in chunks to avoid loading the entire file into memory.
|
||||
* @param path - The path to the file to read
|
||||
* @returns An async generator that yields lines in reverse order
|
||||
*/
|
||||
export async function* readLinesReverse(
|
||||
path: string,
|
||||
): AsyncGenerator<string, void, undefined> {
|
||||
const CHUNK_SIZE = 1024 * 4
|
||||
const fileHandle = await open(path, 'r')
|
||||
try {
|
||||
const stats = await fileHandle.stat()
|
||||
let position = stats.size
|
||||
// Carry raw bytes (not a decoded string) across chunk boundaries so that
|
||||
// multi-byte UTF-8 sequences split by the 4KB boundary are not corrupted.
|
||||
// Decoding per-chunk would turn a split sequence into U+FFFD on both sides,
|
||||
// which for history.jsonl means JSON.parse throws and the entry is dropped.
|
||||
let remainder = Buffer.alloc(0)
|
||||
const buffer = Buffer.alloc(CHUNK_SIZE)
|
||||
|
||||
while (position > 0) {
|
||||
const currentChunkSize = Math.min(CHUNK_SIZE, position)
|
||||
position -= currentChunkSize
|
||||
|
||||
await fileHandle.read(buffer, 0, currentChunkSize, position)
|
||||
const combined = Buffer.concat([
|
||||
buffer.subarray(0, currentChunkSize),
|
||||
remainder,
|
||||
])
|
||||
|
||||
const firstNewline = combined.indexOf(0x0a)
|
||||
if (firstNewline === -1) {
|
||||
remainder = combined
|
||||
continue
|
||||
}
|
||||
|
||||
remainder = Buffer.from(combined.subarray(0, firstNewline))
|
||||
const lines = combined.toString('utf8', firstNewline + 1).split('\n')
|
||||
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i]!
|
||||
if (line) {
|
||||
yield line
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (remainder.length > 0) {
|
||||
yield remainder.toString('utf8')
|
||||
}
|
||||
} finally {
|
||||
await fileHandle.close()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user