Initial commit

This commit is contained in:
Zoe
2026-09-19 14:53:40 -05:00
commit 75b3a01aee
10 changed files with 538 additions and 0 deletions
+65
View File
@@ -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
View File
@@ -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);
});
}