Initial commit
This commit is contained in:
+65
@@ -0,0 +1,65 @@
|
||||
import { readFile, mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { quoteShell, remote } from "./ssh.js";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const [operation, destination, argument, extra] = process.argv.slice(2);
|
||||
if (!operation || !destination) {
|
||||
throw new Error("Usage: home <install|capture|act|shell> <ssh-alias> [output.png|actions.json|command] [seconds]");
|
||||
}
|
||||
const helper = '"$HOME/.local/lib/desktop-harness/desktop.py"';
|
||||
if (operation === "install") {
|
||||
const source = await readFile(new URL("../../vm/desktop.py", import.meta.url), "utf8");
|
||||
const result = await remote(destination, `mkdir -p "$HOME/.local/lib/desktop-harness" && cat > ${helper} && python3 ${helper} --install-autostart`, source);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || "Installation failed.");
|
||||
}
|
||||
console.log("Installed desktop helper and XFCE autostart. Future logins register automatically; for the current session, run registration once in an XFCE terminal (see README.md).");
|
||||
return;
|
||||
}
|
||||
if (operation === "shell") {
|
||||
if (!argument) {
|
||||
throw new Error("Supply a shell command as one quoted argument.");
|
||||
}
|
||||
const seconds = Number(extra ?? 30);
|
||||
if (!Number.isInteger(seconds) || seconds < 1 || seconds > 300) {
|
||||
throw new Error("Timeout must be 1–300 seconds.");
|
||||
}
|
||||
const result = await remote(destination,
|
||||
`timeout --signal=TERM --kill-after=2s ${seconds}s bash -lc ${quoteShell(argument)}`,
|
||||
"", (seconds + 20) * 1000);
|
||||
process.stdout.write(result.stdout);
|
||||
process.stderr.write(result.stderr);
|
||||
if (result.code === 255 || result.code === null) {
|
||||
throw new Error("SSH disconnected; command outcome is unknown.");
|
||||
}
|
||||
console.error(`\nExit status: ${result.code}${result.code === 124 || result.code === 137 ? " (timeout/termination)" : ""}`);
|
||||
process.exitCode = result.code;
|
||||
return;
|
||||
}
|
||||
if (operation !== "capture" && operation !== "act") {
|
||||
throw new Error(`Unknown operation: ${operation}`);
|
||||
}
|
||||
const request = operation === "capture"
|
||||
? { actions: [] }
|
||||
: JSON.parse(await readFile(argument ?? "actions.json", "utf8")) as unknown;
|
||||
const result = await remote(destination, `python3 ${helper}`, JSON.stringify(request));
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || "Desktop request failed; some actions may already have executed.");
|
||||
}
|
||||
const response: unknown = JSON.parse(result.stdout.toString("utf8"));
|
||||
if (typeof response !== "object" || response === null || !("image" in response)
|
||||
|| typeof response.image !== "string") {
|
||||
throw new Error("Invalid desktop response.");
|
||||
}
|
||||
const output = resolve(operation === "capture" ? argument ?? "artifacts/desktop.png" : extra ?? "artifacts/desktop.png");
|
||||
await mkdir(dirname(output), { recursive: true });
|
||||
await writeFile(output, Buffer.from(response.image, "base64"));
|
||||
const { image, ...metadata } = response;
|
||||
console.log(JSON.stringify({ ...metadata, screenshot: output }, null, 2));
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
export function quoteShell(value: string): string {
|
||||
return "'" + value.replaceAll("'", "'\\''") + "'";
|
||||
}
|
||||
|
||||
export interface RemoteResult {
|
||||
code: number | null;
|
||||
stdout: Buffer;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export function remote(destination: string, command: string, input = "", timeoutMs = 30_000): Promise<RemoteResult> {
|
||||
if (!destination || destination.startsWith("-") || /\s/.test(destination)) {
|
||||
throw new Error("Use an SSH config alias or user@hostname as the destination.");
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("ssh", [
|
||||
"-T", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes",
|
||||
"-o", "ConnectTimeout=10", "-o", "ServerAliveInterval=10",
|
||||
"-o", "ServerAliveCountMax=2", destination, command,
|
||||
], { stdio: "pipe" });
|
||||
const stdout: Buffer[] = [];
|
||||
const stderr: Buffer[] = [];
|
||||
let size = 0;
|
||||
let failure: Error | undefined;
|
||||
const timer = setTimeout(() => {
|
||||
failure = new Error("SSH deadline exceeded; remote outcome is unknown.");
|
||||
child.kill("SIGKILL");
|
||||
}, timeoutMs);
|
||||
const collect = (chunks: Buffer[], chunk: Buffer): void => {
|
||||
size += chunk.length;
|
||||
if (size > 32 * 1024 * 1024) {
|
||||
failure = new Error("SSH output exceeded 32 MiB; remote outcome is unknown.");
|
||||
child.kill("SIGKILL");
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
};
|
||||
child.stdout.on("data", (chunk: Buffer) => collect(stdout, chunk));
|
||||
child.stderr.on("data", (chunk: Buffer) => collect(stderr, chunk));
|
||||
child.stdin.on("error", () => {
|
||||
// SSH's exit status and stderr explain a closed input pipe.
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (failure) {
|
||||
reject(failure);
|
||||
} else {
|
||||
resolve({ code, stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr).toString("utf8") });
|
||||
}
|
||||
});
|
||||
child.stdin.end(input);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user