chore: initialize recovered claude workspace
This commit is contained in:
584
src/utils/file.ts
Normal file
584
src/utils/file.ts
Normal file
@@ -0,0 +1,584 @@
|
||||
import { chmodSync, writeFileSync as fsWriteFileSync } from 'fs'
|
||||
import { realpath, stat } from 'fs/promises'
|
||||
import { homedir } from 'os'
|
||||
import {
|
||||
basename,
|
||||
dirname,
|
||||
extname,
|
||||
isAbsolute,
|
||||
join,
|
||||
normalize,
|
||||
relative,
|
||||
resolve,
|
||||
sep,
|
||||
} from 'path'
|
||||
import { logEvent } from 'src/services/analytics/index.js'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
|
||||
import { getCwd } from '../utils/cwd.js'
|
||||
import { logForDebugging } from './debug.js'
|
||||
import { isENOENT, isFsInaccessible } from './errors.js'
|
||||
import {
|
||||
detectEncodingForResolvedPath,
|
||||
detectLineEndingsForString,
|
||||
type LineEndingType,
|
||||
} from './fileRead.js'
|
||||
import { fileReadCache } from './fileReadCache.js'
|
||||
import { getFsImplementation, safeResolvePath } from './fsOperations.js'
|
||||
import { logError } from './log.js'
|
||||
import { expandPath } from './path.js'
|
||||
import { getPlatform } from './platform.js'
|
||||
|
||||
export type File = {
|
||||
filename: string
|
||||
content: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path exists asynchronously.
|
||||
*/
|
||||
export async function pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(path)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const MAX_OUTPUT_SIZE = 0.25 * 1024 * 1024 // 0.25MB in bytes
|
||||
|
||||
export function readFileSafe(filepath: string): string | null {
|
||||
try {
|
||||
const fs = getFsImplementation()
|
||||
return fs.readFileSync(filepath, { encoding: 'utf8' })
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the normalized modification time of a file in milliseconds.
|
||||
* Uses Math.floor to ensure consistent timestamp comparisons across file operations,
|
||||
* reducing false positives from sub-millisecond precision changes (e.g., from IDE
|
||||
* file watchers that touch files without changing content).
|
||||
*/
|
||||
export function getFileModificationTime(filePath: string): number {
|
||||
const fs = getFsImplementation()
|
||||
return Math.floor(fs.statSync(filePath).mtimeMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Async variant of getFileModificationTime. Same floor semantics.
|
||||
* Use this in async paths (getChangedFiles runs every turn on every readFileState
|
||||
* entry — sync statSync there triggers the slow-operation indicator on network/
|
||||
* slow disks).
|
||||
*/
|
||||
export async function getFileModificationTimeAsync(
|
||||
filePath: string,
|
||||
): Promise<number> {
|
||||
const s = await getFsImplementation().stat(filePath)
|
||||
return Math.floor(s.mtimeMs)
|
||||
}
|
||||
|
||||
export function writeTextContent(
|
||||
filePath: string,
|
||||
content: string,
|
||||
encoding: BufferEncoding,
|
||||
endings: LineEndingType,
|
||||
): void {
|
||||
let toWrite = content
|
||||
if (endings === 'CRLF') {
|
||||
// Normalize any existing CRLF to LF first so a new_string that already
|
||||
// contains \r\n (raw model output) doesn't become \r\r\n after the join.
|
||||
toWrite = content.replaceAll('\r\n', '\n').split('\n').join('\r\n')
|
||||
}
|
||||
|
||||
writeFileSyncAndFlush_DEPRECATED(filePath, toWrite, { encoding })
|
||||
}
|
||||
|
||||
export function detectFileEncoding(filePath: string): BufferEncoding {
|
||||
try {
|
||||
const fs = getFsImplementation()
|
||||
const { resolvedPath } = safeResolvePath(fs, filePath)
|
||||
return detectEncodingForResolvedPath(resolvedPath)
|
||||
} catch (error) {
|
||||
if (isFsInaccessible(error)) {
|
||||
logForDebugging(
|
||||
`detectFileEncoding failed for expected reason: ${error.code}`,
|
||||
{
|
||||
level: 'debug',
|
||||
},
|
||||
)
|
||||
} else {
|
||||
logError(error)
|
||||
}
|
||||
return 'utf8'
|
||||
}
|
||||
}
|
||||
|
||||
export function detectLineEndings(
|
||||
filePath: string,
|
||||
encoding: BufferEncoding = 'utf8',
|
||||
): LineEndingType {
|
||||
try {
|
||||
const fs = getFsImplementation()
|
||||
const { resolvedPath } = safeResolvePath(fs, filePath)
|
||||
const { buffer, bytesRead } = fs.readSync(resolvedPath, { length: 4096 })
|
||||
|
||||
const content = buffer.toString(encoding, 0, bytesRead)
|
||||
return detectLineEndingsForString(content)
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
return 'LF'
|
||||
}
|
||||
}
|
||||
|
||||
export function convertLeadingTabsToSpaces(content: string): string {
|
||||
// The /gm regex scans every line even on no-match; skip it entirely
|
||||
// for the common tab-free case.
|
||||
if (!content.includes('\t')) return content
|
||||
return content.replace(/^\t+/gm, _ => ' '.repeat(_.length))
|
||||
}
|
||||
|
||||
export function getAbsoluteAndRelativePaths(path: string | undefined): {
|
||||
absolutePath: string | undefined
|
||||
relativePath: string | undefined
|
||||
} {
|
||||
const absolutePath = path ? expandPath(path) : undefined
|
||||
const relativePath = absolutePath
|
||||
? relative(getCwd(), absolutePath)
|
||||
: undefined
|
||||
return { absolutePath, relativePath }
|
||||
}
|
||||
|
||||
export function getDisplayPath(filePath: string): string {
|
||||
// Use relative path if file is in the current working directory
|
||||
const { relativePath } = getAbsoluteAndRelativePaths(filePath)
|
||||
if (relativePath && !relativePath.startsWith('..')) {
|
||||
return relativePath
|
||||
}
|
||||
|
||||
// Use tilde notation for files in home directory
|
||||
const homeDir = homedir()
|
||||
if (filePath.startsWith(homeDir + sep)) {
|
||||
return '~' + filePath.slice(homeDir.length)
|
||||
}
|
||||
|
||||
// Otherwise return the absolute path
|
||||
return filePath
|
||||
}
|
||||
|
||||
/**
|
||||
* Find files with the same name but different extensions in the same directory
|
||||
* @param filePath The path to the file that doesn't exist
|
||||
* @returns The found file with a different extension, or undefined if none found
|
||||
*/
|
||||
|
||||
export function findSimilarFile(filePath: string): string | undefined {
|
||||
const fs = getFsImplementation()
|
||||
try {
|
||||
const dir = dirname(filePath)
|
||||
const fileBaseName = basename(filePath, extname(filePath))
|
||||
|
||||
// Get all files in the directory
|
||||
const files = fs.readdirSync(dir)
|
||||
|
||||
// Find files with the same base name but different extension
|
||||
const similarFiles = files.filter(
|
||||
file =>
|
||||
basename(file.name, extname(file.name)) === fileBaseName &&
|
||||
join(dir, file.name) !== filePath,
|
||||
)
|
||||
|
||||
// Return just the filename of the first match if found
|
||||
const firstMatch = similarFiles[0]
|
||||
if (firstMatch) {
|
||||
return firstMatch.name
|
||||
}
|
||||
return undefined
|
||||
} catch (error) {
|
||||
// Missing dir (ENOENT) is expected; for other errors log and return undefined
|
||||
if (!isENOENT(error)) {
|
||||
logError(error)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marker included in file-not-found error messages that contain a cwd note.
|
||||
* UI renderers check for this to show a short "File not found" message.
|
||||
*/
|
||||
export const FILE_NOT_FOUND_CWD_NOTE = 'Note: your current working directory is'
|
||||
|
||||
/**
|
||||
* Suggests a corrected path under the current working directory when a file/directory
|
||||
* is not found. Detects the "dropped repo folder" pattern where the model constructs
|
||||
* an absolute path missing the repo directory component.
|
||||
*
|
||||
* Example:
|
||||
* cwd = /Users/zeeg/src/currentRepo
|
||||
* requestedPath = /Users/zeeg/src/foobar (doesn't exist)
|
||||
* returns /Users/zeeg/src/currentRepo/foobar (if it exists)
|
||||
*
|
||||
* @param requestedPath - The absolute path that was not found
|
||||
* @returns The corrected path if found under cwd, undefined otherwise
|
||||
*/
|
||||
export async function suggestPathUnderCwd(
|
||||
requestedPath: string,
|
||||
): Promise<string | undefined> {
|
||||
const cwd = getCwd()
|
||||
const cwdParent = dirname(cwd)
|
||||
|
||||
// Resolve symlinks in the requested path's parent directory (e.g., /tmp -> /private/tmp on macOS)
|
||||
// so the prefix comparison works correctly against the cwd (which is already realpath-resolved).
|
||||
let resolvedPath = requestedPath
|
||||
try {
|
||||
const resolvedDir = await realpath(dirname(requestedPath))
|
||||
resolvedPath = join(resolvedDir, basename(requestedPath))
|
||||
} catch {
|
||||
// Parent directory doesn't exist, use the original path
|
||||
}
|
||||
|
||||
// Only check if the requested path is under cwd's parent but not under cwd itself.
|
||||
// When cwdParent is the root directory (e.g., '/'), use it directly as the prefix
|
||||
// to avoid a double-separator '//' that would never match.
|
||||
const cwdParentPrefix = cwdParent === sep ? sep : cwdParent + sep
|
||||
if (
|
||||
!resolvedPath.startsWith(cwdParentPrefix) ||
|
||||
resolvedPath.startsWith(cwd + sep) ||
|
||||
resolvedPath === cwd
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Get the relative path from the parent directory
|
||||
const relFromParent = relative(cwdParent, resolvedPath)
|
||||
|
||||
// Check if the same relative path exists under cwd
|
||||
const correctedPath = join(cwd, relFromParent)
|
||||
try {
|
||||
await stat(correctedPath)
|
||||
return correctedPath
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to use the compact line-number prefix format (`N\t` instead of
|
||||
* ` N→`). The padded-arrow format costs 9 bytes/line overhead; at
|
||||
* 1.35B Read calls × 132 lines avg this is 2.18% of fleet uncached input
|
||||
* (bq-queries/read_line_prefix_overhead_verify.sql).
|
||||
*
|
||||
* Ant soak validated no Edit error regression (6.29% vs 6.86% baseline).
|
||||
* Killswitch pattern: GB can disable if issues surface externally.
|
||||
*/
|
||||
export function isCompactLinePrefixEnabled(): boolean {
|
||||
// 3P default: killswitch off = compact format enabled. Client-side only —
|
||||
// no server support needed, safe for Bedrock/Vertex/Foundry.
|
||||
return !getFeatureValue_CACHED_MAY_BE_STALE(
|
||||
'tengu_compact_line_prefix_killswitch',
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds cat -n style line numbers to the content.
|
||||
*/
|
||||
export function addLineNumbers({
|
||||
content,
|
||||
// 1-indexed
|
||||
startLine,
|
||||
}: {
|
||||
content: string
|
||||
startLine: number
|
||||
}): string {
|
||||
if (!content) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const lines = content.split(/\r?\n/)
|
||||
|
||||
if (isCompactLinePrefixEnabled()) {
|
||||
return lines
|
||||
.map((line, index) => `${index + startLine}\t${line}`)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
return lines
|
||||
.map((line, index) => {
|
||||
const numStr = String(index + startLine)
|
||||
if (numStr.length >= 6) {
|
||||
return `${numStr}→${line}`
|
||||
}
|
||||
return `${numStr.padStart(6, ' ')}→${line}`
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of addLineNumbers — strips the `N→` or `N\t` prefix from a single
|
||||
* line. Co-located so format changes here and in addLineNumbers stay in sync.
|
||||
*/
|
||||
export function stripLineNumberPrefix(line: string): string {
|
||||
const match = line.match(/^\s*\d+[\u2192\t](.*)$/)
|
||||
return match?.[1] ?? line
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a directory is empty.
|
||||
* @param dirPath The path to the directory to check
|
||||
* @returns true if the directory is empty or does not exist, false otherwise
|
||||
*/
|
||||
export function isDirEmpty(dirPath: string): boolean {
|
||||
try {
|
||||
return getFsImplementation().isDirEmptySync(dirPath)
|
||||
} catch (e) {
|
||||
// ENOENT: directory doesn't exist, consider it empty
|
||||
// Other errors (EPERM on macOS protected folders, etc.): assume not empty
|
||||
return isENOENT(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a file with caching to avoid redundant I/O operations.
|
||||
* This is the preferred method for FileEditTool operations.
|
||||
*/
|
||||
export function readFileSyncCached(filePath: string): string {
|
||||
const { content } = fileReadCache.readFile(filePath)
|
||||
return content
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes to a file and flushes the file to disk
|
||||
* @param filePath The path to the file to write to
|
||||
* @param content The content to write to the file
|
||||
* @param options Options for writing the file, including encoding and mode
|
||||
* @deprecated Use `fs.promises.writeFile` with flush option instead for non-blocking writes.
|
||||
* Sync file writes block the event loop and cause performance issues.
|
||||
*/
|
||||
export function writeFileSyncAndFlush_DEPRECATED(
|
||||
filePath: string,
|
||||
content: string,
|
||||
options: { encoding: BufferEncoding; mode?: number } = { encoding: 'utf-8' },
|
||||
): void {
|
||||
const fs = getFsImplementation()
|
||||
|
||||
// Check if the target file is a symlink to preserve it for all users
|
||||
// Note: We don't use safeResolvePath here because we need to manually handle
|
||||
// symlinks to ensure we write to the target while preserving the symlink itself
|
||||
let targetPath = filePath
|
||||
try {
|
||||
// Try to read the symlink - if successful, it's a symlink
|
||||
const linkTarget = fs.readlinkSync(filePath)
|
||||
// Resolve to absolute path
|
||||
targetPath = isAbsolute(linkTarget)
|
||||
? linkTarget
|
||||
: resolve(dirname(filePath), linkTarget)
|
||||
logForDebugging(`Writing through symlink: ${filePath} -> ${targetPath}`)
|
||||
} catch {
|
||||
// ENOENT (doesn't exist) or EINVAL (not a symlink) — keep targetPath = filePath
|
||||
}
|
||||
|
||||
// Try atomic write first
|
||||
const tempPath = `${targetPath}.tmp.${process.pid}.${Date.now()}`
|
||||
|
||||
// Check if target file exists and get its permissions (single stat, reused in both atomic and fallback paths)
|
||||
let targetMode: number | undefined
|
||||
let targetExists = false
|
||||
try {
|
||||
targetMode = fs.statSync(targetPath).mode
|
||||
targetExists = true
|
||||
logForDebugging(`Preserving file permissions: ${targetMode.toString(8)}`)
|
||||
} catch (e) {
|
||||
if (!isENOENT(e)) throw e
|
||||
if (options.mode !== undefined) {
|
||||
// Use provided mode for new files
|
||||
targetMode = options.mode
|
||||
logForDebugging(
|
||||
`Setting permissions for new file: ${targetMode.toString(8)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
logForDebugging(`Writing to temp file: ${tempPath}`)
|
||||
|
||||
// Write to temp file with flush and mode (if specified for new file)
|
||||
const writeOptions: {
|
||||
encoding: BufferEncoding
|
||||
flush: boolean
|
||||
mode?: number
|
||||
} = {
|
||||
encoding: options.encoding,
|
||||
flush: true,
|
||||
}
|
||||
// Only set mode in writeFileSync for new files to ensure atomic permission setting
|
||||
if (!targetExists && options.mode !== undefined) {
|
||||
writeOptions.mode = options.mode
|
||||
}
|
||||
|
||||
fsWriteFileSync(tempPath, content, writeOptions)
|
||||
logForDebugging(
|
||||
`Temp file written successfully, size: ${content.length} bytes`,
|
||||
)
|
||||
|
||||
// For existing files or if mode was not set atomically, apply permissions
|
||||
if (targetExists && targetMode !== undefined) {
|
||||
chmodSync(tempPath, targetMode)
|
||||
logForDebugging(`Applied original permissions to temp file`)
|
||||
}
|
||||
|
||||
// Atomic rename (on POSIX systems, this is atomic)
|
||||
// On Windows, this will overwrite the destination if it exists
|
||||
logForDebugging(`Renaming ${tempPath} to ${targetPath}`)
|
||||
fs.renameSync(tempPath, targetPath)
|
||||
logForDebugging(`File ${targetPath} written atomically`)
|
||||
} catch (atomicError) {
|
||||
logForDebugging(`Failed to write file atomically: ${atomicError}`, {
|
||||
level: 'error',
|
||||
})
|
||||
logEvent('tengu_atomic_write_error', {})
|
||||
|
||||
// Clean up temp file on error
|
||||
try {
|
||||
logForDebugging(`Cleaning up temp file: ${tempPath}`)
|
||||
fs.unlinkSync(tempPath)
|
||||
} catch (cleanupError) {
|
||||
logForDebugging(`Failed to clean up temp file: ${cleanupError}`)
|
||||
}
|
||||
|
||||
// Fallback to non-atomic write
|
||||
logForDebugging(`Falling back to non-atomic write for ${targetPath}`)
|
||||
try {
|
||||
const fallbackOptions: {
|
||||
encoding: BufferEncoding
|
||||
flush: boolean
|
||||
mode?: number
|
||||
} = {
|
||||
encoding: options.encoding,
|
||||
flush: true,
|
||||
}
|
||||
// Only set mode for new files
|
||||
if (!targetExists && options.mode !== undefined) {
|
||||
fallbackOptions.mode = options.mode
|
||||
}
|
||||
|
||||
fsWriteFileSync(targetPath, content, fallbackOptions)
|
||||
logForDebugging(
|
||||
`File ${targetPath} written successfully with non-atomic fallback`,
|
||||
)
|
||||
} catch (fallbackError) {
|
||||
logForDebugging(`Non-atomic write also failed: ${fallbackError}`)
|
||||
throw fallbackError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getDesktopPath(): string {
|
||||
const platform = getPlatform()
|
||||
const homeDir = homedir()
|
||||
|
||||
if (platform === 'macos') {
|
||||
return join(homeDir, 'Desktop')
|
||||
}
|
||||
|
||||
if (platform === 'windows') {
|
||||
// For WSL, try to access Windows desktop
|
||||
const windowsHome = process.env.USERPROFILE
|
||||
? process.env.USERPROFILE.replace(/\\/g, '/')
|
||||
: null
|
||||
|
||||
if (windowsHome) {
|
||||
const wslPath = windowsHome.replace(/^[A-Z]:/, '')
|
||||
const desktopPath = `/mnt/c${wslPath}/Desktop`
|
||||
|
||||
if (getFsImplementation().existsSync(desktopPath)) {
|
||||
return desktopPath
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try to find desktop in typical Windows user location
|
||||
try {
|
||||
const usersDir = '/mnt/c/Users'
|
||||
const userDirs = getFsImplementation().readdirSync(usersDir)
|
||||
|
||||
for (const user of userDirs) {
|
||||
if (
|
||||
user.name === 'Public' ||
|
||||
user.name === 'Default' ||
|
||||
user.name === 'Default User' ||
|
||||
user.name === 'All Users'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
const potentialDesktopPath = join(usersDir, user.name, 'Desktop')
|
||||
|
||||
if (getFsImplementation().existsSync(potentialDesktopPath)) {
|
||||
return potentialDesktopPath
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Linux/unknown platform fallback
|
||||
const desktopPath = join(homeDir, 'Desktop')
|
||||
if (getFsImplementation().existsSync(desktopPath)) {
|
||||
return desktopPath
|
||||
}
|
||||
|
||||
// If Desktop folder doesn't exist, fallback to home directory
|
||||
return homeDir
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a file size is within the specified limit.
|
||||
* Returns true if the file is within the limit, false otherwise.
|
||||
*
|
||||
* @param filePath The path to the file to validate
|
||||
* @param maxSizeBytes The maximum allowed file size in bytes
|
||||
* @returns true if file size is within limit, false otherwise
|
||||
*/
|
||||
export function isFileWithinReadSizeLimit(
|
||||
filePath: string,
|
||||
maxSizeBytes: number = MAX_OUTPUT_SIZE,
|
||||
): boolean {
|
||||
try {
|
||||
const stats = getFsImplementation().statSync(filePath)
|
||||
return stats.size <= maxSizeBytes
|
||||
} catch {
|
||||
// If we can't stat the file, return false to indicate validation failure
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a file path for comparison, handling platform differences.
|
||||
* On Windows, normalizes path separators and converts to lowercase for
|
||||
* case-insensitive comparison.
|
||||
*/
|
||||
export function normalizePathForComparison(filePath: string): string {
|
||||
// Use path.normalize() to clean up redundant separators and resolve . and ..
|
||||
let normalized = normalize(filePath)
|
||||
|
||||
// On Windows, normalize for case-insensitive comparison:
|
||||
// - Convert forward slashes to backslashes (path.normalize only does this on actual Windows)
|
||||
// - Convert to lowercase (Windows paths are case-insensitive)
|
||||
if (getPlatform() === 'windows') {
|
||||
normalized = normalized.replace(/\//g, '\\').toLowerCase()
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two file paths for equality, handling Windows case-insensitivity.
|
||||
*/
|
||||
export function pathsEqual(path1: string, path2: string): boolean {
|
||||
return normalizePathForComparison(path1) === normalizePathForComparison(path2)
|
||||
}
|
||||
Reference in New Issue
Block a user