mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 18:55:22 +00:00
feat: add pure ssh2 remote operations
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
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;
|
||||
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;
|
||||
|
||||
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("/")) 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");
|
||||
|
||||
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)} 2>&1 | 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)} 2>&1 | 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 2>&1 | ${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/*' 2>&1 | ${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);
|
||||
const fixed = input.literal === false ? "" : "-F";
|
||||
const insensitive = input.caseSensitive ? "" : "-i";
|
||||
const rgGlob = include ? `-g ${shellQuote(include)}` : "";
|
||||
const gitPath = include ? `-- ${shellQuote(include)}` : "";
|
||||
const findName = include ? `-name ${shellQuote(include)}` : "";
|
||||
const take = limit + 1;
|
||||
return [
|
||||
STATUS_HELPER,
|
||||
`if command -v rg >/dev/null 2>&1; then`,
|
||||
` printf '${MARKER}ripgrep\\n'`,
|
||||
` rg --line-number --no-heading --color never --with-filename --max-columns 500 --max-columns-preview ${fixed} ${insensitive} ${rgGlob} --glob '!.git/**' --glob '!node_modules/**' -- ${shellQuote(pattern)} ${shellQuote(root)} 2>&1 | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
|
||||
' statuses=("${PIPESTATUS[@]}")',
|
||||
' pi_ssh_accept_status "${statuses[0]}" 0 1 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-grep\\n'`,
|
||||
` git -C ${shellQuote(root)} grep --untracked --exclude-standard -n -I ${fixed} ${insensitive} -e ${shellQuote(pattern)} ${gitPath} 2>&1 | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
|
||||
' statuses=("${PIPESTATUS[@]}")',
|
||||
' pi_ssh_accept_status "${statuses[0]}" 0 1 141 || exit $?',
|
||||
`else`,
|
||||
` printf '${MARKER}grep\\n'`,
|
||||
` find ${shellQuote(root)} -type f ! -path '*/.git/*' ! -path '*/node_modules/*' ${findName} -exec grep -nH -I ${fixed} ${insensitive} -- ${shellQuote(pattern)} {} + 2>&1 | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
|
||||
' statuses=("${PIPESTATUS[@]}")',
|
||||
' pi_ssh_accept_status "${statuses[0]}" 0 141 || 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(line: string, root: string, grep: boolean): string {
|
||||
if (line.startsWith("/") || line.startsWith("../")) return line;
|
||||
if (!grep) return posixPath.join(root, line);
|
||||
const separator = line.indexOf(":");
|
||||
if (separator < 1) return line;
|
||||
return `${posixPath.join(root, line.slice(0, separator))}${line.slice(separator)}`;
|
||||
}
|
||||
|
||||
export function formatRemoteSearchOutput(
|
||||
raw: string,
|
||||
root: string,
|
||||
limit: number,
|
||||
kind: "find" | "grep",
|
||||
): RemoteSearchResult {
|
||||
const lines = raw.replace(/\r\n?/gu, "\n").split("\n");
|
||||
const markerIndex = lines.findIndex((line) => line.startsWith(MARKER));
|
||||
if (markerIndex < 0) throw new Error(`remote ${kind} did not report a search backend`);
|
||||
const backend = lines[markerIndex].slice(MARKER.length).trim() || "unknown";
|
||||
const sourceRows = lines.slice(markerIndex + 1).filter((line) => line.length > 0);
|
||||
const truncated = sourceRows.length > limit;
|
||||
const rows = sourceRows.slice(0, limit).map((line) => {
|
||||
const normalized = backend.startsWith("git-") ? prefixGitPath(line, root, kind === "grep") : line;
|
||||
return normalized.length > MAX_LINE_CHARS ? `${normalized.slice(0, MAX_LINE_CHARS - 1)}…` : normalized;
|
||||
});
|
||||
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,
|
||||
root: string,
|
||||
limit: number,
|
||||
kind: "find" | "grep",
|
||||
signal?: AbortSignal,
|
||||
): Promise<RemoteSearchResult> {
|
||||
const chunks: Buffer[] = [];
|
||||
let captured = 0;
|
||||
const result = await transport.exec(command, root, {
|
||||
signal,
|
||||
timeout: 30,
|
||||
onData(data) {
|
||||
if (captured >= MAX_CAPTURE_CHARS) return;
|
||||
const remaining = MAX_CAPTURE_CHARS - captured;
|
||||
const chunk = data.length > remaining ? data.subarray(0, remaining) : data;
|
||||
chunks.push(chunk);
|
||||
captured += chunk.length;
|
||||
},
|
||||
});
|
||||
const output = Buffer.concat(chunks).toString("utf8");
|
||||
if (result.exitCode !== 0) {
|
||||
const detail = output
|
||||
.replace(/\r\n?/gu, "\n")
|
||||
.split("\n")
|
||||
.filter((line) => !line.startsWith(MARKER))
|
||||
.join(" ")
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim()
|
||||
.slice(0, 300);
|
||||
throw new Error(`remote ${kind} failed${result.exitCode === null ? "" : ` with exit code ${result.exitCode}`}${detail ? `: ${detail}` : ""}`);
|
||||
}
|
||||
return formatRemoteSearchOutput(output, root, limit, kind);
|
||||
}
|
||||
|
||||
export function runRemoteFind(
|
||||
transport: RemoteTransport,
|
||||
input: RemoteFindInput,
|
||||
remoteCwd: string,
|
||||
remoteHome: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RemoteSearchResult> {
|
||||
const root = resolveRemoteSearchPath(input.path, remoteCwd, remoteHome);
|
||||
const built = buildRemoteFindCommand(input, root);
|
||||
return runRemoteSearch(transport, built.command, root, built.limit, "find", signal);
|
||||
}
|
||||
|
||||
export function runRemoteGrep(
|
||||
transport: RemoteTransport,
|
||||
input: RemoteGrepInput,
|
||||
remoteCwd: string,
|
||||
remoteHome: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RemoteSearchResult> {
|
||||
const root = resolveRemoteSearchPath(input.path, remoteCwd, remoteHome);
|
||||
const built = buildRemoteGrepCommand(input, root);
|
||||
return runRemoteSearch(transport, built.command, root, built.limit, "grep", signal);
|
||||
}
|
||||
Reference in New Issue
Block a user