import fs from "fs/promises"; import { createReadStream } from "fs"; import path from "path"; import readline from "readline"; const WORKSPACE_PATH = process.env.WORKSPACE_PATH || "/workspace"; export interface SearchMatch { file: string; line: number; column: number; text: string; matchLength: number; } export interface SearchResult { matches: SearchMatch[]; totalMatches: number; filesSearched: number; } interface SearchOptions { regex?: boolean; caseSensitive?: boolean; includePattern?: string; excludePattern?: string; maxResults?: number; } export interface FileNode { name: string; path: string; type: "file" | "directory"; children?: FileNode[]; } const IGNORED_DIRS = new Set(["node_modules", ".git", ".nasher"]); export async function getDirectoryTree( repo: string, subPath: string = "", ): Promise { const fullPath = resolveRepoPath(repo, subPath); const entries = await fs.readdir(fullPath, { withFileTypes: true }); const nodes: FileNode[] = []; const sorted = entries.sort((a, b) => { if (a.isDirectory() && !b.isDirectory()) return -1; if (!a.isDirectory() && b.isDirectory()) return 1; return a.name.localeCompare(b.name); }); for (const entry of sorted) { if (entry.name.startsWith(".") && entry.name !== ".gitignore") continue; const relativePath = subPath ? `${subPath}/${entry.name}` : entry.name; if (entry.isDirectory()) { if (IGNORED_DIRS.has(entry.name)) continue; const children = await getDirectoryTree(repo, relativePath); nodes.push({ name: entry.name, path: relativePath, type: "directory", children, }); } else { nodes.push({ name: entry.name, path: relativePath, type: "file" }); } } return nodes; } export async function readFile( repo: string, filePath: string, ): Promise { const fullPath = resolveRepoPath(repo, filePath); return fs.readFile(fullPath, "utf-8"); } export async function writeFile( repo: string, filePath: string, content: string, ): Promise { const fullPath = resolveRepoPath(repo, filePath); await fs.mkdir(path.dirname(fullPath), { recursive: true }); await fs.writeFile(fullPath, content, "utf-8"); } export async function deleteFile( repo: string, filePath: string, ): Promise { const fullPath = resolveRepoPath(repo, filePath); await fs.unlink(fullPath); } const BINARY_EXTENSIONS = new Set([ ".mod", ".hak", ".bic", ".tlk", ".png", ".jpg", ".tga", ".wav", ".bmu", ".ncs", ".gic", ]); function matchesExtPattern(filePath: string, pattern: string): boolean { if (pattern.startsWith("*.")) { return filePath.endsWith(pattern.slice(1)); } return filePath.endsWith(pattern); } async function walkFiles(dir: string, relativeTo: string): Promise { const results: string[] = []; const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { if (IGNORED_DIRS.has(entry.name)) continue; results.push(...(await walkFiles(fullPath, relativeTo))); } else { const ext = path.extname(entry.name).toLowerCase(); if (!BINARY_EXTENSIONS.has(ext)) { results.push(path.relative(relativeTo, fullPath)); } } } return results; } export async function searchFiles( repo: string, query: string, options: SearchOptions = {}, ): Promise { const maxResults = options.maxResults ?? 1000; const repoRoot = resolveRepoPath(repo, ""); const files = await walkFiles(repoRoot, repoRoot); const matches: SearchMatch[] = []; let filesSearched = 0; let pattern: RegExp | null = null; if (options.regex) { pattern = new RegExp(query, options.caseSensitive ? "g" : "gi"); } for (const file of files) { if (matches.length >= maxResults) break; if (options.includePattern && !matchesExtPattern(file, options.includePattern)) continue; if (options.excludePattern && matchesExtPattern(file, options.excludePattern)) continue; const fullPath = path.join(repoRoot, file); filesSearched++; const rl = readline.createInterface({ input: createReadStream(fullPath, { encoding: "utf-8" }), crlfDelay: Infinity, }); let lineNum = 0; for await (const line of rl) { lineNum++; if (matches.length >= maxResults) { rl.close(); break; } if (pattern) { pattern.lastIndex = 0; let m: RegExpExecArray | null; while ((m = pattern.exec(line)) !== null) { matches.push({ file, line: lineNum, column: m.index, text: line, matchLength: m[0].length, }); if (matches.length >= maxResults) break; } } else { const haystack = options.caseSensitive ? line : line.toLowerCase(); const needle = options.caseSensitive ? query : query.toLowerCase(); let startIdx = 0; let idx: number; while ((idx = haystack.indexOf(needle, startIdx)) !== -1) { matches.push({ file, line: lineNum, column: idx, text: line, matchLength: needle.length, }); startIdx = idx + 1; if (matches.length >= maxResults) break; } } } } return { matches, totalMatches: matches.length, filesSearched }; } function resolveRepoPath(repo: string, filePath: string): string { const repoRoot = path.resolve(path.join(WORKSPACE_PATH, "repos", repo)); const resolved = path.resolve(repoRoot, filePath); if (!resolved.startsWith(repoRoot)) { throw new Error("Path traversal detected"); } return resolved; }