feat: add remote apply-patch passthrough and pipeline timeline polish
This commit is contained in:
+202
-6
@@ -5,11 +5,159 @@ export interface ParsedSshArgs {
|
||||
remoteCommand: string | null;
|
||||
}
|
||||
|
||||
const remoteApplyPatchSource = String.raw`#!/usr/bin/env python3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def die(message):
|
||||
print(f"apply_patch: {message}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def write_file(path, text):
|
||||
target = Path(path)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(text)
|
||||
|
||||
|
||||
def move_file(source, destination):
|
||||
source_path = Path(source)
|
||||
destination_path = Path(destination)
|
||||
if not source_path.exists():
|
||||
die(f"file not found: {source}")
|
||||
if destination_path.exists():
|
||||
die(f"target file already exists: {destination}")
|
||||
destination_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
source_path.rename(destination_path)
|
||||
|
||||
|
||||
def read_file(path):
|
||||
try:
|
||||
return Path(path).read_text()
|
||||
except FileNotFoundError:
|
||||
die(f"file not found: {path}")
|
||||
|
||||
|
||||
def apply_update(path, body):
|
||||
old = read_file(path)
|
||||
output = []
|
||||
search_from = 0
|
||||
index = 0
|
||||
while index < len(body):
|
||||
if body[index].startswith("*** End of File"):
|
||||
index += 1
|
||||
continue
|
||||
if not body[index].startswith("@@"):
|
||||
die(f"expected hunk header in {path}")
|
||||
index += 1
|
||||
sequence = []
|
||||
while index < len(body) and not body[index].startswith("@@"):
|
||||
line = body[index]
|
||||
index += 1
|
||||
if line.startswith("*** End of File"):
|
||||
continue
|
||||
if line.startswith(" "):
|
||||
sequence.append((" ", line[1:]))
|
||||
elif line.startswith("-"):
|
||||
sequence.append(("-", line[1:]))
|
||||
elif line.startswith("+"):
|
||||
sequence.append(("+", line[1:]))
|
||||
elif line.rstrip("\n") == r"\ No newline at end of file":
|
||||
continue
|
||||
else:
|
||||
die(f"bad hunk line in {path}: {line[:80].rstrip()}")
|
||||
search = "".join(text for kind, text in sequence if kind in (" ", "-"))
|
||||
replacement = "".join(text for kind, text in sequence if kind in (" ", "+"))
|
||||
offset = old.find(search, search_from)
|
||||
if offset < 0:
|
||||
offset = old.find(search)
|
||||
if offset < 0:
|
||||
die(f"hunk context not found in {path}")
|
||||
output.append(old[search_from:offset])
|
||||
output.append(replacement)
|
||||
search_from = offset + len(search)
|
||||
output.append(old[search_from:])
|
||||
write_file(path, "".join(output))
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help"):
|
||||
print("apply_patch: read *** Begin Patch format from stdin; supports add/update/delete/move")
|
||||
return
|
||||
lines = sys.stdin.read().splitlines(keepends=True)
|
||||
if not lines or lines[0].strip() != "*** Begin Patch":
|
||||
die("patch must start with *** Begin Patch")
|
||||
index = 1
|
||||
while index < len(lines):
|
||||
line = lines[index].rstrip("\n")
|
||||
if line == "*** End Patch":
|
||||
print("Done!")
|
||||
return
|
||||
if line.startswith("*** Add File: "):
|
||||
path = line[len("*** Add File: "):].strip()
|
||||
index += 1
|
||||
content = []
|
||||
while index < len(lines) and not lines[index].startswith("*** "):
|
||||
if not lines[index].startswith("+"):
|
||||
die(f"add file lines must start with + for {path}")
|
||||
content.append(lines[index][1:])
|
||||
index += 1
|
||||
if Path(path).exists():
|
||||
die(f"file already exists: {path}")
|
||||
write_file(path, "".join(content))
|
||||
continue
|
||||
if line.startswith("*** Delete File: "):
|
||||
path = line[len("*** Delete File: "):].strip()
|
||||
try:
|
||||
Path(path).unlink()
|
||||
except FileNotFoundError:
|
||||
die(f"file not found: {path}")
|
||||
index += 1
|
||||
continue
|
||||
if line.startswith("*** Update File: "):
|
||||
path = line[len("*** Update File: "):].strip()
|
||||
index += 1
|
||||
move_to = None
|
||||
if index < len(lines) and lines[index].startswith("*** Move to: "):
|
||||
move_to = lines[index][len("*** Move to: "):].strip()
|
||||
index += 1
|
||||
body = []
|
||||
while index < len(lines) and not (
|
||||
lines[index].startswith("*** Add File: ")
|
||||
or lines[index].startswith("*** Update File: ")
|
||||
or lines[index].startswith("*** Delete File: ")
|
||||
or lines[index].startswith("*** End Patch")
|
||||
):
|
||||
if lines[index].startswith("*** Move to: "):
|
||||
die("move marker must appear before update hunks")
|
||||
body.append(lines[index])
|
||||
index += 1
|
||||
if body:
|
||||
apply_update(path, body)
|
||||
elif move_to is None and not Path(path).exists():
|
||||
die(f"file not found: {path}")
|
||||
if move_to is not None:
|
||||
move_file(path, move_to)
|
||||
continue
|
||||
die(f"unexpected patch line: {line}")
|
||||
die("missing *** End Patch")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
`;
|
||||
|
||||
const sshOptionsWithValue = new Set([
|
||||
"-B", "-b", "-c", "-D", "-E", "-e", "-F", "-I", "-i", "-J", "-L", "-l", "-m", "-O", "-o", "-p", "-Q", "-R", "-S", "-W", "-w",
|
||||
]);
|
||||
|
||||
export function parseSshArgs(args: string[]): ParsedSshArgs {
|
||||
const subcommand = args[0] ?? "";
|
||||
if (subcommand === "apply-patch" || subcommand === "patch") {
|
||||
const toolArgs = ["apply_patch", ...args.slice(1)];
|
||||
return { remoteCommand: toolArgs.map(shellQuote).join(" ") };
|
||||
}
|
||||
const remote: string[] = [];
|
||||
let remoteStarted = false;
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
@@ -32,6 +180,27 @@ export function parseSshArgs(args: string[]): ParsedSshArgs {
|
||||
return { remoteCommand: remote.length === 0 ? null : remote.join(" ") };
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function remoteToolBootstrapCommand(): string {
|
||||
const encoded = Buffer.from(remoteApplyPatchSource, "utf8").toString("base64");
|
||||
return [
|
||||
"UNIDESK_SSH_TOOL_DIR=/tmp/unidesk-ssh-tools",
|
||||
'mkdir -p "$UNIDESK_SSH_TOOL_DIR"',
|
||||
`printf %s ${shellQuote(encoded)} | base64 -d > "$UNIDESK_SSH_TOOL_DIR/apply_patch"`,
|
||||
'chmod 700 "$UNIDESK_SSH_TOOL_DIR/apply_patch"',
|
||||
'export PATH="$UNIDESK_SSH_TOOL_DIR:$PATH"',
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
function wrapRemoteCommand(command: string | null): string {
|
||||
const bootstrap = remoteToolBootstrapCommand();
|
||||
if (command === null) return `${bootstrap}; exec "\${SHELL:-/bin/bash}" -l`;
|
||||
return `${bootstrap}; stty -echo 2>/dev/null || true; ${command}`;
|
||||
}
|
||||
|
||||
function brokerSource(): string {
|
||||
return String.raw`
|
||||
const open = JSON.parse(process.argv[2] || process.argv[1] || "{}");
|
||||
@@ -40,8 +209,10 @@ const url = "ws://127.0.0.1:8080/ws/ssh?token=" + encodeURIComponent(token);
|
||||
const ws = new WebSocket(url);
|
||||
let exitCode = 255;
|
||||
let canSend = false;
|
||||
let sessionReady = false;
|
||||
let opened = false;
|
||||
const pending = [];
|
||||
const pendingInput = [];
|
||||
const openTimer = setTimeout(() => {
|
||||
if (opened) return;
|
||||
process.stderr.write("unidesk ssh bridge timed out waiting for provider session\n");
|
||||
@@ -64,6 +235,22 @@ function flush() {
|
||||
}
|
||||
}
|
||||
|
||||
function sendInput(value) {
|
||||
const text = JSON.stringify(value);
|
||||
if (!sessionReady || ws.readyState !== WebSocket.OPEN) {
|
||||
pendingInput.push(text);
|
||||
return;
|
||||
}
|
||||
ws.send(text);
|
||||
}
|
||||
|
||||
function flushInput() {
|
||||
if (!sessionReady || ws.readyState !== WebSocket.OPEN) return;
|
||||
while (pendingInput.length > 0) {
|
||||
ws.send(pendingInput.shift());
|
||||
}
|
||||
}
|
||||
|
||||
function decodeData(data) {
|
||||
return typeof data === "string" ? data : Buffer.from(data).toString("utf8");
|
||||
}
|
||||
@@ -92,10 +279,15 @@ ws.addEventListener("message", (event) => {
|
||||
}
|
||||
if (message.type === "ssh.opened") {
|
||||
opened = true;
|
||||
sessionReady = true;
|
||||
clearTimeout(openTimer);
|
||||
if (open.stdinEotOnEnd === true) setTimeout(flushInput, 200);
|
||||
else flushInput();
|
||||
return;
|
||||
}
|
||||
if (message.type === "ssh.dispatched") {
|
||||
return;
|
||||
}
|
||||
if (message.type === "ssh.dispatched") return;
|
||||
if (message.type === "ssh.error") {
|
||||
clearTimeout(openTimer);
|
||||
process.stderr.write(String(message.message || "ssh bridge error") + "\n");
|
||||
@@ -120,12 +312,13 @@ ws.addEventListener("error", () => {
|
||||
});
|
||||
|
||||
process.stdin.on("data", (chunk) => {
|
||||
send({ type: "ssh.input", data: Buffer.from(chunk).toString("base64"), encoding: "base64" });
|
||||
flush();
|
||||
sendInput({ type: "ssh.input", data: Buffer.from(chunk).toString("base64"), encoding: "base64" });
|
||||
});
|
||||
process.stdin.on("end", () => {
|
||||
send({ type: "ssh.eof" });
|
||||
flush();
|
||||
if (open.stdinEotOnEnd === true) {
|
||||
sendInput({ type: "ssh.input", data: Buffer.from([4]).toString("base64"), encoding: "base64" });
|
||||
}
|
||||
sendInput({ type: "ssh.eof" });
|
||||
});
|
||||
`;
|
||||
}
|
||||
@@ -141,9 +334,12 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st
|
||||
if (!providerId) throw new Error("ssh requires provider id, for example: bun scripts/cli.ts ssh D518");
|
||||
const parsed = parseSshArgs(args);
|
||||
const size = terminalSize();
|
||||
const openTimeoutMs = Math.max(15000, Number(process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || 60000));
|
||||
const payload = {
|
||||
providerId,
|
||||
command: parsed.remoteCommand,
|
||||
command: wrapRemoteCommand(parsed.remoteCommand),
|
||||
stdinEotOnEnd: parsed.remoteCommand !== null,
|
||||
openTimeoutMs,
|
||||
cols: size.cols,
|
||||
rows: size.rows,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user