import path from "node:path"; import { createHash } from "node:crypto"; import type { Readable, Writable } from "node:stream"; export type PatchHunk = | { kind: "add"; path: string; content: string } | { kind: "delete"; path: string } | { kind: "update"; path: string; movePath: string | null; chunks: UpdateChunk[] }; export interface UpdateChunk { changeContext: string | null; oldLines: string[]; newLines: string[]; isEndOfFile: boolean; } export interface PatchParseResult { patch: string; hunks: PatchHunk[]; } export interface PatchUpdateResult { oldContent: string; newContent: string; } export interface ApplyPatchV2Options { executor: ApplyPatchV2Executor; stdin: Readable; stdout: Writable; argv?: string[]; } export interface ApplyPatchV2Executor { run(command: string[], input?: string): Promise; } export interface ApplyPatchV2RemoteResult { exitCode: number; stdout: string; stderr: string; } export class ApplyPatchV2Error extends Error { constructor(message: string, public readonly details: Record = {}) { super(message); this.name = "ApplyPatchV2Error"; } } type PlannedFileState = { exists: true; content: string } | { exists: false; content: "" }; type PlannedOperation = { kind: "write"; path: string; content: string } | { kind: "delete"; path: string }; type Replacement = [start: number, oldLength: number, newLines: string[]]; interface ApplyPatchV2Plan { operations: PlannedOperation[]; changed: string[]; } const beginMarker = "*** Begin Patch"; const endMarker = "*** End Patch"; const addFileMarker = "*** Add File: "; const deleteFileMarker = "*** Delete File: "; const updateFileMarker = "*** Update File: "; const moveToMarker = "*** Move to: "; const emptyChangeContextMarker = "@@"; const changeContextMarker = "@@ "; const eofMarker = "*** End of File"; export function isApplyPatchV2HelpArgs(args: string[] = []): boolean { const first = args[0] ?? ""; return first === "help" || first === "--help" || first === "-h"; } export function applyPatchV2HelpPayload() { return { ok: true, command: "ssh apply-patch < patch.diff", summary: "Apply a standard apply_patch patch to a remote route with the local v2 line-based engine.", usage: [ "tran G14:/root/hwlab/.worktree/task apply-patch < patch.diff", "bun scripts/cli.ts ssh D601:/tmp apply-patch < patch.diff" ], input: { required: true, firstLine: beginMarker, lastLine: endMarker }, note: "apply-patch reads patch text from stdin and uses the v2 engine by default. Use `apply-patch-v1` only for the legacy helper." }; } export function parseApplyPatchV2(patchText: string): PatchParseResult { const patch = stripLenientHeredoc(patchText).trim(); const lines = patch.length === 0 ? [] : patch.split(/\r?\n/u); const first = lines[0]?.trim(); const last = lines[lines.length - 1]?.trim(); if (first !== beginMarker) throw new ApplyPatchV2Error(`invalid patch: first line must be '${beginMarker}'`); if (last !== endMarker) throw new ApplyPatchV2Error(`invalid patch: last line must be '${endMarker}'`); const hunks: PatchHunk[] = []; let index = 1; while (index < lines.length - 1) { const line = lines[index]?.trim() ?? ""; if (line.length === 0) { index += 1; continue; } if (line.startsWith(addFileMarker)) { const filePath = validatePatchPath(line.slice(addFileMarker.length), index + 1); index += 1; const added: string[] = []; while (index < lines.length - 1 && !isFileHeader(lines[index] ?? "")) { const addLine = lines[index] ?? ""; if (!addLine.startsWith("+")) { throw new ApplyPatchV2Error("invalid add file line; every added line must start with +", { line: index + 1, path: filePath }); } added.push(addLine.slice(1)); index += 1; } hunks.push({ kind: "add", path: filePath, content: joinLinesWithFinalNewline(added) }); continue; } if (line.startsWith(deleteFileMarker)) { hunks.push({ kind: "delete", path: validatePatchPath(line.slice(deleteFileMarker.length), index + 1) }); index += 1; continue; } if (line.startsWith(updateFileMarker)) { const filePath = validatePatchPath(line.slice(updateFileMarker.length), index + 1); index += 1; let movePath: string | null = null; if ((lines[index] ?? "").startsWith(moveToMarker)) { movePath = validatePatchPath((lines[index] ?? "").slice(moveToMarker.length), index + 1); index += 1; } const chunks: UpdateChunk[] = []; while (index < lines.length - 1 && !isFileHeader(lines[index] ?? "")) { if ((lines[index] ?? "").trim().length === 0) { index += 1; continue; } const parsed = parseUpdateChunk(lines, index, chunks.length === 0); chunks.push(parsed.chunk); index = parsed.nextIndex; } if (chunks.length === 0) throw new ApplyPatchV2Error("update file hunk is empty", { line: index + 1, path: filePath }); hunks.push({ kind: "update", path: filePath, movePath, chunks }); continue; } throw new ApplyPatchV2Error("invalid hunk header", { line: index + 1, text: line }); } return { patch, hunks }; } export function deriveUpdatedContent(filePath: string, originalContent: string, chunks: UpdateChunk[]): PatchUpdateResult { const originalLines = splitContentLines(originalContent); const replacements = computeReplacements(filePath, originalLines, chunks); const newLines = applyReplacements(originalLines, replacements); const newContent = joinLinesWithFinalNewline(newLines); return { oldContent: originalContent, newContent }; } export async function runApplyPatchV2(options: ApplyPatchV2Options): Promise { if (isApplyPatchV2HelpArgs(options.argv)) { options.stdout.write(`${JSON.stringify(applyPatchV2HelpPayload(), null, 2)}\n`); return 0; } if ((options.argv?.length ?? 0) > 0) { options.stdout.write(`${JSON.stringify({ ok: false, error: { code: "apply_patch_unsupported_args", message: "ssh apply-patch uses the v2 engine and accepts no helper flags. Use apply-patch-v1 for legacy helper options." }, unsupportedArgs: options.argv, help: applyPatchV2HelpPayload() }, null, 2)}\n`); return 2; } const patchText = await readStreamText(options.stdin); if (!patchText.trim()) { options.stdout.write(`${JSON.stringify({ ok: false, error: { code: "apply_patch_stdin_required", message: "ssh apply-patch requires patch text on stdin." }, help: applyPatchV2HelpPayload() }, null, 2)}\n`); return 2; } const parsed = parseApplyPatchV2(patchText); const plan = await planApplyPatchV2(options.executor, parsed.hunks); for (const operation of plan.operations) { await executePlannedOperation(options.executor, operation); } options.stdout.write("Success. Updated the following files:\n"); for (const item of plan.changed) options.stdout.write(`${item}\n`); return 0; } async function planApplyPatchV2(executor: ApplyPatchV2Executor, hunks: PatchHunk[]): Promise { const states = new Map(); const operations: PlannedOperation[] = []; const changed: string[] = []; async function readPlannedText(filePath: string): Promise { const state = states.get(filePath); if (state !== undefined) { if (!state.exists) throw new ApplyPatchV2Error("cannot update a file deleted earlier in this patch", { path: filePath }); return state.content; } const content = await readRemoteText(executor, filePath); states.set(filePath, { exists: true, content }); return content; } function planWrite(filePath: string, content: string): void { states.set(filePath, { exists: true, content }); operations.push({ kind: "write", path: filePath, content }); } function planDelete(filePath: string): void { states.set(filePath, { exists: false, content: "" }); operations.push({ kind: "delete", path: filePath }); } for (const hunk of hunks) { if (hunk.kind === "add") { planWrite(hunk.path, hunk.content); changed.push(hunk.path); continue; } if (hunk.kind === "delete") { planDelete(hunk.path); changed.push(hunk.path); continue; } const originalContent = await readPlannedText(hunk.path); const update = deriveUpdatedContent(hunk.path, originalContent, hunk.chunks); if (hunk.movePath !== null && hunk.movePath !== hunk.path) { planWrite(hunk.movePath, update.newContent); planDelete(hunk.path); changed.push(`${hunk.path} -> ${hunk.movePath}`); continue; } planWrite(hunk.path, update.newContent); changed.push(hunk.path); } return { operations, changed }; } async function executePlannedOperation(executor: ApplyPatchV2Executor, operation: PlannedOperation): Promise { if (operation.kind === "write") { await writeRemoteText(executor, operation.path, operation.content); return; } await checkedRemoteV2(executor, "delete", [operation.path]); } async function readRemoteText(executor: ApplyPatchV2Executor, target: string): Promise { const read = await checkedRemoteV2(executor, "read", [target]); return read.stdout; } async function writeRemoteText(executor: ApplyPatchV2Executor, target: string, content: string): Promise { const contentBuffer = Buffer.from(content, "utf8"); const encoded = contentBuffer.toString("base64"); const expectedBytes = String(contentBuffer.length); const expectedSha256 = sha256Hex(contentBuffer); if (encoded.length <= 48_000) { await checkedRemoteV2(executor, "write-b64-argv", [target, expectedBytes, expectedSha256, ...chunkString(encoded, 12_000)]); return; } await checkedRemoteV2(executor, "write-b64-stdin", [target, expectedBytes, expectedSha256], encoded); } async function checkedRemoteV2(executor: ApplyPatchV2Executor, operation: "read" | "write-b64-argv" | "write-b64-stdin" | "delete" | "move", args: string[], input?: string): Promise<{ stdout: string }> { const result = await executor.run(remoteV2Script(operation, args), input); if (result.exitCode === 0) return result; throw new ApplyPatchV2Error("remote apply-patch v2 operation failed", { operation, args, exitCode: result.exitCode, stdout: result.stdout.slice(-2000), stderr: result.stderr.slice(-4000), }); } function remoteV2Script(operation: "read" | "write-b64-argv" | "write-b64-stdin" | "delete" | "move", args: string[]): string[] { const script = [ "set -eu", "sha256_file() {", " if command -v sha256sum >/dev/null 2>&1; then sha256sum -- \"$1\" | awk '{print $1}'; return; fi", " if command -v shasum >/dev/null 2>&1; then shasum -a 256 -- \"$1\" | awk '{print $1}'; return; fi", " if command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 -- \"$1\" | awk '{print $NF}'; return; fi", " printf 'missing sha256 tool\\n' >&2", " return 127", "}", "verify_tmp() {", " target=$1", " tmp=$2", " expected_bytes=$3", " expected_sha256=$4", " actual_bytes=$(wc -c < \"$tmp\" | tr -d '[:space:]')", " if [ \"$actual_bytes\" != \"$expected_bytes\" ]; then", " rm -f -- \"$tmp\"", " printf 'v2 decoded byte count mismatch for %s: expected=%s actual=%s\\n' \"$target\" \"$expected_bytes\" \"$actual_bytes\" >&2", " exit 23", " fi", " actual_sha256=$(sha256_file \"$tmp\")", " if [ \"$actual_sha256\" != \"$expected_sha256\" ]; then", " rm -f -- \"$tmp\"", " printf 'v2 decoded sha256 mismatch for %s: expected=%s actual=%s\\n' \"$target\" \"$expected_sha256\" \"$actual_sha256\" >&2", " exit 24", " fi", "}", "op=$1", "shift", "case \"$op\" in", " read)", " cat -- \"$1\"", " ;;", " write-b64-argv)", " target=$1", " expected_bytes=$2", " expected_sha256=$3", " shift 3", " case \"$target\" in */*) parent=${target%/*}; mkdir -p -- \"$parent\";; esac", " base=${target##*/}", " dir=.", " case \"$target\" in */*) dir=${target%/*};; esac", " tmp=\"$dir/.${base}.unidesk-v2-$$.tmp\"", " : > \"$tmp.b64\"", " for chunk in \"$@\"; do printf '%s' \"$chunk\" >> \"$tmp.b64\"; done", " base64 -d < \"$tmp.b64\" > \"$tmp\"", " rm -f -- \"$tmp.b64\"", " verify_tmp \"$target\" \"$tmp\" \"$expected_bytes\" \"$expected_sha256\"", " mv -f -- \"$tmp\" \"$target\"", " actual_sha256=$(sha256_file \"$target\")", " if [ \"$actual_sha256\" != \"$expected_sha256\" ]; then printf 'v2 final sha256 mismatch for %s\\n' \"$target\" >&2; exit 25; fi", " ;;", " write-b64-stdin)", " target=$1", " expected_bytes=$2", " expected_sha256=$3", " case \"$target\" in */*) parent=${target%/*}; mkdir -p -- \"$parent\";; esac", " base=${target##*/}", " dir=.", " case \"$target\" in */*) dir=${target%/*};; esac", " tmp=\"$dir/.${base}.unidesk-v2-$$.tmp\"", " base64 -d > \"$tmp\"", " verify_tmp \"$target\" \"$tmp\" \"$expected_bytes\" \"$expected_sha256\"", " mv -f -- \"$tmp\" \"$target\"", " actual_sha256=$(sha256_file \"$target\")", " if [ \"$actual_sha256\" != \"$expected_sha256\" ]; then printf 'v2 final sha256 mismatch for %s\\n' \"$target\" >&2; exit 25; fi", " ;;", " delete)", " rm -f -- \"$1\"", " ;;", " move)", " target=$2", " case \"$target\" in */*) parent=${target%/*}; mkdir -p -- \"$parent\";; esac", " mv -f -- \"$1\" \"$2\"", " ;;", " *)", " printf 'unsupported op: %s\\n' \"$op\" >&2", " exit 2", " ;;", "esac", ].join("\n"); return ["sh", "-c", script, "unidesk-apply-patch-v2", operation, ...args]; } function sha256Hex(value: Buffer): string { return createHash("sha256").update(value).digest("hex"); } function chunkString(value: string, chunkSize: number): string[] { const chunks: string[] = []; for (let index = 0; index < value.length; index += chunkSize) { chunks.push(value.slice(index, index + chunkSize)); } return chunks.length > 0 ? chunks : [""]; } function readStreamText(stream: Readable): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; stream.on("data", (chunk: Buffer | string) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); stream.on("error", reject); stream.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); stream.resume(); }); } function stripLenientHeredoc(text: string): string { const trimmed = text.trim(); const lines = trimmed.length === 0 ? [] : trimmed.split(/\r?\n/u); const first = lines[0] ?? ""; const last = lines[lines.length - 1] ?? ""; if ((first === "<= 4) { return lines.slice(1, -1).join("\n"); } return text; } function validatePatchPath(value: string, line: number): string { const filePath = value.trim(); if (filePath.length === 0) throw new ApplyPatchV2Error("patch path cannot be empty", { line }); if (path.isAbsolute(filePath)) throw new ApplyPatchV2Error("patch paths must be relative", { line, path: filePath }); if (filePath.split(/[\\/]+/u).includes("..")) throw new ApplyPatchV2Error("patch paths cannot contain ..", { line, path: filePath }); return filePath; } function isFileHeader(line: string): boolean { const trimmed = line.trim(); return trimmed.startsWith(addFileMarker) || trimmed.startsWith(deleteFileMarker) || trimmed.startsWith(updateFileMarker) || trimmed === endMarker; } function parseUpdateChunk(lines: string[], startIndex: number, allowMissingContext: boolean): { chunk: UpdateChunk; nextIndex: number } { let index = startIndex; let changeContext: string | null = null; const first = lines[index] ?? ""; if (first === emptyChangeContextMarker) { index += 1; } else if (first.startsWith(changeContextMarker)) { changeContext = first.slice(changeContextMarker.length); index += 1; } else if (!allowMissingContext) { throw new ApplyPatchV2Error("expected update chunk to start with @@ context marker", { line: startIndex + 1, text: first }); } const oldLines: string[] = []; const newLines: string[] = []; let parsed = 0; let isEndOfFile = false; while (index < lines.length - 1) { const line = lines[index] ?? ""; if (isFileHeader(line)) break; if (line === eofMarker) { if (parsed === 0) throw new ApplyPatchV2Error("update chunk does not contain any lines", { line: index + 1 }); isEndOfFile = true; index += 1; break; } const marker = line[0] ?? ""; if (marker === " ") { oldLines.push(line.slice(1)); newLines.push(line.slice(1)); } else if (marker === "+") { newLines.push(line.slice(1)); } else if (marker === "-") { oldLines.push(line.slice(1)); } else if (line.length === 0) { oldLines.push(""); newLines.push(""); } else if (parsed > 0) { break; } else { throw new ApplyPatchV2Error("unexpected line in update chunk", { line: index + 1, text: line }); } parsed += 1; index += 1; } if (parsed === 0) throw new ApplyPatchV2Error("update chunk does not contain any lines", { line: startIndex + 1 }); return { chunk: { changeContext, oldLines, newLines, isEndOfFile }, nextIndex: index }; } function splitContentLines(content: string): string[] { const lines = content.split("\n"); if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); return lines; } function joinLinesWithFinalNewline(lines: string[]): string { if (lines.length === 0) return ""; return `${lines.join("\n")}\n`; } function computeReplacements(filePath: string, originalLines: string[], chunks: UpdateChunk[]): Replacement[] { const replacements: Replacement[] = []; let lineIndex = 0; for (const [chunkIndex, chunk] of chunks.entries()) { if (chunk.changeContext !== null) { const foundContext = seekSequence(originalLines, [chunk.changeContext], lineIndex, false); if (foundContext === null) { throw new ApplyPatchV2Error("failed to find update context", { path: filePath, chunk: chunkIndex + 1, context: chunk.changeContext }); } lineIndex = foundContext + 1; } if (chunk.oldLines.length === 0) { const insertAt = originalLines.length > 0 && originalLines[originalLines.length - 1] === "" ? originalLines.length - 1 : originalLines.length; replacements.push([insertAt, 0, chunk.newLines]); continue; } let pattern = chunk.oldLines; let found = seekSequence(originalLines, pattern, lineIndex, chunk.isEndOfFile); let newLines = chunk.newLines; if (found === null && pattern[pattern.length - 1] === "") { pattern = pattern.slice(0, -1); newLines = newLines[newLines.length - 1] === "" ? newLines.slice(0, -1) : newLines; found = seekSequence(originalLines, pattern, lineIndex, chunk.isEndOfFile); } if (found === null) { throw new ApplyPatchV2Error("failed to find expected lines", { path: filePath, chunk: chunkIndex + 1, expected: chunk.oldLines.join("\n"), }); } replacements.push([found, pattern.length, newLines]); lineIndex = found + pattern.length; } assertNonOverlappingReplacements(filePath, replacements, originalLines.length); return replacements; } function applyReplacements(lines: string[], replacements: Replacement[]): string[] { const result = [...lines]; for (const [start, oldLen, newSegment] of [...replacements].reverse()) { result.splice(start, oldLen, ...newSegment); } return result; } function assertNonOverlappingReplacements(filePath: string, replacements: Replacement[], lineCount: number): void { const sorted = [...replacements].sort((left, right) => left[0] - right[0]); let previousEnd = 0; for (const [start, oldLen] of sorted) { if (start < 0 || oldLen < 0 || start + oldLen > lineCount) { throw new ApplyPatchV2Error("computed replacement is outside file bounds", { path: filePath, start, oldLen, lineCount, }); } if (start < previousEnd) { throw new ApplyPatchV2Error("computed replacements overlap", { path: filePath, start, previousEnd, }); } previousEnd = Math.max(previousEnd, start + oldLen); } } function seekSequence(lines: string[], pattern: string[], start: number, eof: boolean): number | null { if (pattern.length === 0) return start; if (pattern.length > lines.length) return null; const searchStart = eof && lines.length >= pattern.length ? lines.length - pattern.length : start; const attempts: Array<(value: string) => string> = [ (value) => value, (value) => value.trimEnd(), (value) => value.trim(), normalizeLine, ]; for (const normalize of attempts) { for (let index = searchStart; index <= lines.length - pattern.length; index += 1) { let ok = true; for (let offset = 0; offset < pattern.length; offset += 1) { if (normalize(lines[index + offset] ?? "") !== normalize(pattern[offset] ?? "")) { ok = false; break; } } if (ok) return index; } } return null; } function normalizeLine(value: string): string { return value.trim().replace(/[\u2010-\u2015\u2212]/gu, "-") .replace(/[\u2018\u2019\u201A\u201B]/gu, "'") .replace(/[\u201C\u201D\u201E\u201F]/gu, "\"") .replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/gu, " "); }