import { posix as posixPath } from "node:path"; import type { RemoteTransport } from "./ssh2-transport.ts"; export interface RemoteFindInput { pattern: string; path?: string; caseSensitive?: boolean; includeHidden?: boolean; limit?: number; } export interface RemoteGrepInput { pattern: string; path?: string; literal?: boolean; caseSensitive?: boolean; include?: string; includeHidden?: boolean; limit?: number; } export interface RemoteSearchResult { text: string; backend: string; matchCount: number; truncated: boolean; } const MARKER = "__PI_SSH_SEARCH_BACKEND__:"; const DEFAULT_LIMIT = 50; const MAX_LIMIT = 200; const MAX_LINE_CHARS = 800; const MAX_CAPTURE_CHARS = 512_000; const SEARCH_TIMEOUT_SECONDS = 30; const MAX_DIAGNOSTIC_CHARS = 16_000; function shellQuote(value: string): string { return `'${value.replace(/'/g, `'"'"'`)}'`; } function validateField(value: unknown, name: string, optional = false): string | undefined { if (value === undefined && optional) return undefined; if (typeof value !== "string" || value.length === 0) throw new Error(`${name} must be a non-empty string`); if (/[\0\r\n]/u.test(value)) throw new Error(`${name} must not contain NUL or newline characters`); return value; } function normalizeLimit(value: unknown): number { if (value === undefined) return DEFAULT_LIMIT; if (!Number.isInteger(value) || (value as number) < 1 || (value as number) > MAX_LIMIT) { throw new Error(`limit must be an integer from 1 to ${MAX_LIMIT}`); } return value as number; } export function resolveRemoteSearchPath(path: string | undefined, remoteCwd: string, remoteHome: string): string { const value = path ?? "."; validateField(value, "path"); if (value === "~") return remoteHome; if (value.startsWith("~/")) return posixPath.normalize(posixPath.join(remoteHome, value.slice(2))); if (value.startsWith("~")) throw new Error("path supports only ~ or ~/... remote HOME expansion"); if (value.startsWith("/")) return posixPath.normalize(value); return posixPath.normalize(posixPath.join(remoteCwd, value)); } const STATUS_HELPER = [ `pi_ssh_accept_status() {`, ` local actual="$1" accepted`, ` shift`, ` for accepted in "$@"; do`, ` if [ "$actual" -eq "$accepted" ]; then return 0; fi`, ` done`, ` return "$actual"`, `}`, ].join("\n"); const GREP_RECORD_HELPER = [ `pi_ssh_limit_colon_records() {`, ` local path match line content count=0`, ` while IFS= read -r -d '' path && IFS= read -r match; do`, ' line="${match%%:*}"', ' content="${match#*:}"', ' printf \'%s\\0%s\\0%s\\0\' "${path:0:' + MAX_LINE_CHARS + '}" "$line" "${content:0:' + MAX_LINE_CHARS + '}"', ` count=$((count + 1))`, ` if [ "$count" -ge "$PI_SSH_TAKE" ]; then return 0; fi`, ` done`, `}`, `pi_ssh_limit_git_records() {`, ` local path line content count=0`, ` while IFS= read -r -d '' path && IFS= read -r -d '' line && IFS= read -r content; do`, ' printf \'%s\\0%s\\0%s\\0\' "${path:0:' + MAX_LINE_CHARS + '}" "$line" "${content:0:' + MAX_LINE_CHARS + '}"', ` count=$((count + 1))`, ` if [ "$count" -ge "$PI_SSH_TAKE" ]; then return 0; fi`, ` done`, `}`, ] .join("\n"); function findPipeline(input: RemoteFindInput, root: string, limit: number): string { const pattern = validateField(input.pattern, "pattern") as string; const fdCase = input.caseSensitive ? "--case-sensitive" : "--ignore-case"; const fdHidden = input.includeHidden ? "--hidden" : ""; const grepCase = input.caseSensitive ? "" : "-i"; const hiddenFilter = input.includeHidden ? "cat" : "grep -Ev '(^|/)\\.'"; const take = limit + 1; return [ STATUS_HELPER, `if command -v fd >/dev/null 2>&1; then`, ` printf '${MARKER}fd\\n'`, ` fd --type f --color never --fixed-strings ${fdCase} ${fdHidden} --exclude .git --exclude node_modules -- ${shellQuote(pattern)} ${shellQuote(root)} | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`, ' statuses=("${PIPESTATUS[@]}")', ' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?', `elif command -v fdfind >/dev/null 2>&1; then`, ` printf '${MARKER}fdfind\\n'`, ` fdfind --type f --color never --fixed-strings ${fdCase} ${fdHidden} --exclude .git --exclude node_modules -- ${shellQuote(pattern)} ${shellQuote(root)} | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`, ' statuses=("${PIPESTATUS[@]}")', ' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?', `elif command -v git >/dev/null 2>&1 && git -C ${shellQuote(root)} rev-parse --is-inside-work-tree >/dev/null 2>&1; then`, ` printf '${MARKER}git-ls-files\\n'`, ` git -C ${shellQuote(root)} ls-files -co --exclude-standard | ${hiddenFilter} | grep -F ${grepCase} -- ${shellQuote(pattern)} | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`, ' statuses=("${PIPESTATUS[@]}")', ' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?', ' pi_ssh_accept_status "${statuses[1]}" 0 1 141 || exit $?', ' pi_ssh_accept_status "${statuses[2]}" 0 1 141 || exit $?', `else`, ` printf '${MARKER}find\\n'`, ` find ${shellQuote(root)} -type f ! -path '*/.git/*' ! -path '*/node_modules/*' | ${hiddenFilter} | grep -F ${grepCase} -- ${shellQuote(pattern)} | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`, ' statuses=("${PIPESTATUS[@]}")', ' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?', ' pi_ssh_accept_status "${statuses[1]}" 0 1 141 || exit $?', ' pi_ssh_accept_status "${statuses[2]}" 0 1 141 || exit $?', `fi`, ].join("\n"); } function grepPipeline(input: RemoteGrepInput, root: string, limit: number): string { const pattern = validateField(input.pattern, "pattern") as string; const include = validateField(input.include, "include", true); if (include?.includes("/")) throw new Error("include must be a basename glob without /"); const rgMode = input.literal === false ? "" : "-F"; const grepMode = input.literal === false ? "-E" : "-F"; const insensitive = input.caseSensitive ? "" : "-i"; const rgHidden = input.includeHidden ? "--hidden" : "--glob '!.*' --glob '!**/.*' --glob '!**/.*/**'"; const rgGlob = include ? `-g ${shellQuote(include)}` : ""; const gitPathspecs = [ ...(include ? [`:(glob)**/${include}`] : []), ...(input.includeHidden ? [] : [":(exclude,glob)**/.*", ":(exclude,glob)**/.*/**"]), ]; const gitPath = gitPathspecs.length > 0 ? `-- ${gitPathspecs.map(shellQuote).join(" ")}` : ""; const findName = include ? `-name ${shellQuote(include)}` : ""; const includeHidden = input.includeHidden ? 1 : 0; const take = limit + 1; return [ STATUS_HELPER, GREP_RECORD_HELPER, `PI_SSH_TAKE=${take}`, `PI_SSH_ROOT=${shellQuote(root)}`, `PI_SSH_INCLUDE_HIDDEN=${includeHidden}`, `if command -v rg >/dev/null 2>&1; then`, ` printf '${MARKER}ripgrep\\n'`, ` rg --null --line-number --no-heading --color never --with-filename --max-columns ${MAX_LINE_CHARS} --max-columns-preview ${rgMode} ${insensitive} ${rgGlob} ${rgHidden} --glob '!.git/**' --glob '!node_modules/**' -- ${shellQuote(pattern)} ${shellQuote(root)} | pi_ssh_limit_colon_records`, ' statuses=("${PIPESTATUS[@]}")', ' pi_ssh_accept_status "${statuses[0]}" 0 1 141 || exit $?', ' pi_ssh_accept_status "${statuses[1]}" 0 || exit $?', `elif command -v git >/dev/null 2>&1 && git -C ${shellQuote(root)} rev-parse --is-inside-work-tree >/dev/null 2>&1; then`, ` printf '${MARKER}git-grep\\n'`, ` git -C ${shellQuote(root)} grep --untracked --exclude-standard -z -n -I ${grepMode} ${insensitive} -e ${shellQuote(pattern)} ${gitPath} | pi_ssh_limit_git_records`, ' statuses=("${PIPESTATUS[@]}")', ' pi_ssh_accept_status "${statuses[0]}" 0 1 141 || exit $?', ' pi_ssh_accept_status "${statuses[1]}" 0 || exit $?', `else`, ` printf '${MARKER}grep\\n'`, ` find ${shellQuote(root)} -type f ! -path '*/.git/*' ! -path '*/node_modules/*' ${findName} -print0 | (`, ` while IFS= read -r -d '' file; do`, ` if [ "$PI_SSH_INCLUDE_HIDDEN" -eq 0 ]; then`, ` if [ -d "$PI_SSH_ROOT" ]; then`, ' relative=${file#"$PI_SSH_ROOT"/}', ` else`, ' relative=${file##*/}', ` fi`, ' case "$relative" in .*|*/.*) continue ;; esac', ` fi`, ` grep -n -I ${grepMode} ${insensitive} -- ${shellQuote(pattern)} "$file" | while IFS= read -r match; do`, ` printf '%s\\0%s\\n' "$file" "$match"`, ` done`, ' grep_statuses=("${PIPESTATUS[@]}")', ' pi_ssh_accept_status "${grep_statuses[0]}" 0 1 141 || exit $?', ' pi_ssh_accept_status "${grep_statuses[1]}" 0 1 141 || exit $?', ` done`, ` ) | pi_ssh_limit_colon_records`, ' statuses=("${PIPESTATUS[@]}")', ' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?', ' pi_ssh_accept_status "${statuses[1]}" 0 141 || exit $?', ' pi_ssh_accept_status "${statuses[2]}" 0 || exit $?', `fi`, ].join("\n"); } export function buildRemoteFindCommand(input: RemoteFindInput, root: string): { command: string; limit: number } { const limit = normalizeLimit(input.limit); validateField(root, "resolved path"); return { command: findPipeline(input, root, limit), limit }; } export function buildRemoteGrepCommand(input: RemoteGrepInput, root: string): { command: string; limit: number } { const limit = normalizeLimit(input.limit); validateField(root, "resolved path"); return { command: grepPipeline(input, root, limit), limit }; } function prefixGitPath(path: string, root: string): string { return path.startsWith("/") || path.startsWith("../") ? path : posixPath.join(root, path); } function visibleField(value: string): string { return value.replace(/\r/gu, "\\r").replace(/\n/gu, "\\n"); } function normalizeDiagnostic(raw: Buffer): string { return raw .toString("utf8") .replace(/\0/gu, " ") .replace(new RegExp(`${MARKER}[^\\n]*`, "gu"), "") .replace(/\s+/gu, " ") .trim() .slice(0, 300); } export function formatRemoteSearchOutput( raw: string | Buffer, root: string, limit: number, kind: "find" | "grep", ): RemoteSearchResult { const output = Buffer.isBuffer(raw) ? raw.toString("utf8") : raw; const markerIndex = output.indexOf(MARKER); const markerEnd = markerIndex < 0 ? -1 : output.indexOf("\n", markerIndex); if (markerIndex < 0 || markerEnd < 0) throw new Error(`remote ${kind} did not report a search backend`); const backend = output.slice(markerIndex + MARKER.length, markerEnd).trim() || "unknown"; const payload = output.slice(markerEnd + 1); let sourceRows: string[]; if (kind === "grep") { const fields = payload.length === 0 ? [] : payload.split("\0"); if (fields.at(-1) === "") fields.pop(); if (fields.length % 3 !== 0) throw new Error("remote grep returned a malformed or truncated NUL-delimited result"); sourceRows = []; for (let index = 0; index < fields.length; index += 3) { const path = backend.startsWith("git-") ? prefixGitPath(fields[index], root) : fields[index]; sourceRows.push(`${visibleField(path)}:${visibleField(fields[index + 1])}:${visibleField(fields[index + 2])}`); } } else { sourceRows = payload.replace(/\r\n?/gu, "\n").split("\n").filter((line) => line.length > 0); if (backend.startsWith("git-")) sourceRows = sourceRows.map((line) => prefixGitPath(line, root)); } const truncated = sourceRows.length > limit; const rows = sourceRows.slice(0, limit).map((line) => line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS - 1)}…` : line, ); const header = `Remote ${kind}: ${rows.length} result${rows.length === 1 ? "" : "s"} (backend: ${backend}, root: ${root}, truncated: ${truncated ? "yes" : "no"})`; return { text: rows.length > 0 ? `${header}\n\n${rows.join("\n")}` : `${header}\n\nNo matches found.`, backend, matchCount: rows.length, truncated, }; } async function runRemoteSearch( transport: RemoteTransport, command: string, executionCwd: string, root: string, limit: number, kind: "find" | "grep", signal?: AbortSignal, ): Promise { const chunks: Buffer[] = []; const stderrChunks: Buffer[] = []; let captured = 0; let stderrCaptured = 0; const capture = (target: Buffer[], data: Buffer, stderr = false) => { const current = stderr ? stderrCaptured : captured; const maximum = stderr ? MAX_DIAGNOSTIC_CHARS : MAX_CAPTURE_CHARS; if (current >= maximum) return; const remaining = maximum - current; const chunk = data.length > remaining ? data.subarray(0, remaining) : data; target.push(chunk); if (stderr) stderrCaptured += chunk.length; else captured += chunk.length; }; let result: { exitCode: number | null }; try { result = await transport.exec(command, executionCwd, { signal, timeout: SEARCH_TIMEOUT_SECONDS, onData(data) { capture(chunks, data); }, onStderr(data) { capture(stderrChunks, data, true); }, }); } catch (error) { if (!signal?.aborted && /timed out/iu.test(error instanceof Error ? error.message : String(error))) { throw new Error(`remote ${kind} timed out after ${SEARCH_TIMEOUT_SECONDS}s (root: ${root}); narrow the remote path with ssh_find before retrying`); } throw error; } const output = Buffer.concat(chunks); const stderr = Buffer.concat(stderrChunks); if (result.exitCode !== 0) { const detail = normalizeDiagnostic(stderr.length > 0 ? stderr : output); throw new Error(`remote ${kind} failed${result.exitCode === null ? "" : ` with exit code ${result.exitCode}`}${detail ? `: ${detail}` : ""}`); } const formatted = formatRemoteSearchOutput(output, root, limit, kind); const warning = normalizeDiagnostic(stderr); if (warning) formatted.text += `\n\nRemote warning: ${warning}`; return formatted; } export function runRemoteFind( transport: RemoteTransport, input: RemoteFindInput, remoteCwd: string, remoteHome: string, signal?: AbortSignal, ): Promise { const root = resolveRemoteSearchPath(input.path, remoteCwd, remoteHome); const built = buildRemoteFindCommand(input, root); return runRemoteSearch(transport, built.command, remoteCwd, root, built.limit, "find", signal); } export function runRemoteGrep( transport: RemoteTransport, input: RemoteGrepInput, remoteCwd: string, remoteHome: string, signal?: AbortSignal, ): Promise { const root = resolveRemoteSearchPath(input.path, remoteCwd, remoteHome); const built = buildRemoteGrepCommand(input, root); return runRemoteSearch(transport, built.command, remoteCwd, root, built.limit, "grep", signal); }