feat: add structured ssh passthrough helpers
This commit is contained in:
+5
-1
@@ -27,8 +27,12 @@ function help(): unknown {
|
||||
{ command: "server status", description: "Show fixed ports, containers, service health, and public URLs." },
|
||||
{ command: "server logs [--tail-bytes N]", description: "Return bounded tails from file logs and docker logs." },
|
||||
{ command: "server rebuild <backend-core|frontend|provider-gateway|todo-note>", description: "Build first, then label-replace one service without Docker Compose v1 recreate fallback." },
|
||||
{ command: "ssh <providerId> [ssh-like args...]", description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge with built-in apply_patch in PATH." },
|
||||
{ command: "ssh <providerId> [ssh-like args...]", description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge with built-in remote helper tools in PATH." },
|
||||
{ command: "ssh <providerId> apply-patch [tool args...] < patch.diff", description: "Invoke the injected remote apply_patch helper directly over SSH passthrough and stream the patch from local stdin." },
|
||||
{ command: "ssh <providerId> py [script-args...] < script.py", description: "Run remote Python from local stdin through SSH passthrough without nested shell quoting; extra args become script argv." },
|
||||
{ command: "ssh <providerId> find <path...> [--max-depth N] [--type d|f|l] [--contains TEXT] [--iname PATTERN] [--limit N] [--sort]", description: "Run a structured remote find command without nested shell quoting or parentheses." },
|
||||
{ command: "ssh <providerId> glob [--root DIR] [--pattern PATTERN] [--contains TEXT] [--type any|f|d] [--limit N] [--sort]", description: "Run remote glob matching through the injected helper without shell glob expansion." },
|
||||
{ command: "ssh <providerId> argv <command> [args...]", description: "Run a remote command with each argv token shell-quoted by UniDesk before SSH passthrough." },
|
||||
{ command: "microservice list", description: "List UniDesk-managed microservices and their provider/runtime mapping." },
|
||||
{ command: "microservice status <id>", description: "Show one microservice config, repository reference, backend mapping, and runtime status." },
|
||||
{ command: "microservice health <id>", description: "Probe one microservice through backend-core -> provider-gateway HTTP proxy." },
|
||||
|
||||
@@ -381,6 +381,10 @@ async function remoteMicroservice(session: FrontendSession, args: string[]): Pro
|
||||
async function runRemoteSshOverFrontend(session: FrontendSession, providerId: string | undefined, args: string[]): Promise<number> {
|
||||
if (!providerId) throw new Error("remote ssh requires provider id, for example: bun scripts/cli.ts --main-server-ip 74.48.78.17 ssh D601 hostname");
|
||||
const parsed = parseSshArgs(args);
|
||||
if (parsed.requiresStdin) {
|
||||
process.stderr.write("remote frontend transport does not stream stdin for ssh helper subcommands such as apply-patch or py; run the command on the main server or use --main-server-transport ssh\n");
|
||||
return 255;
|
||||
}
|
||||
if (parsed.remoteCommand === null) {
|
||||
process.stderr.write("remote frontend transport supports ssh remote commands only; pass a command such as: ssh D601 hostname\n");
|
||||
return 255;
|
||||
|
||||
+199
-4
@@ -3,8 +3,11 @@ import { type UniDeskConfig, repoRoot } from "./config";
|
||||
|
||||
export interface ParsedSshArgs {
|
||||
remoteCommand: string | null;
|
||||
requiresStdin: boolean;
|
||||
}
|
||||
|
||||
const argvQuotedSshSubcommands = new Set(["rg", "grep", "sed", "nl", "stat", "du", "ls", "cat", "head", "tail", "wc", "pwd"]);
|
||||
|
||||
const remoteApplyPatchSource = String.raw`#!/usr/bin/env python3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -148,6 +151,71 @@ if __name__ == "__main__":
|
||||
main()
|
||||
`;
|
||||
|
||||
const remoteGlobSource = String.raw`#!/usr/bin/env python3
|
||||
import argparse
|
||||
import glob
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="remote glob helper for UniDesk ssh passthrough")
|
||||
parser.add_argument("patterns", nargs="*", help="glob patterns relative to --root unless absolute")
|
||||
parser.add_argument("--root", default=".", help="base directory for relative patterns")
|
||||
parser.add_argument("--pattern", action="append", default=[], help="additional glob pattern")
|
||||
parser.add_argument("--contains", action="append", default=[], help="match path names containing text")
|
||||
parser.add_argument("--icontains", action="append", default=[], help="case-insensitive contains match")
|
||||
parser.add_argument("--type", choices=["any", "f", "d"], default="any", help="filter by any/file/dir")
|
||||
parser.add_argument("--limit", type=int, default=0, help="maximum number of rows to print")
|
||||
parser.add_argument("--sort", action="store_true", help="sort output")
|
||||
parser.add_argument("--absolute", action="store_true", help="print absolute paths")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.limit < 0:
|
||||
print("glob: --limit must be >= 0", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
root = os.path.abspath(args.root)
|
||||
patterns = list(args.patterns) + list(args.pattern)
|
||||
for text in args.contains:
|
||||
patterns.append(f"**/*{text}*")
|
||||
for text in args.icontains:
|
||||
# Python glob is case-sensitive on Linux, so filter from a broad recursive scan.
|
||||
patterns.append("**/*")
|
||||
if not patterns:
|
||||
patterns = ["*"]
|
||||
|
||||
seen = set()
|
||||
rows = []
|
||||
lowered_contains = [text.lower() for text in args.icontains]
|
||||
for pattern in patterns:
|
||||
effective = pattern if os.path.isabs(pattern) else os.path.join(root, pattern)
|
||||
for path in glob.iglob(effective, recursive=True):
|
||||
full = os.path.abspath(path)
|
||||
if full in seen:
|
||||
continue
|
||||
if lowered_contains and not any(text in os.path.basename(full).lower() or text in full.lower() for text in lowered_contains):
|
||||
continue
|
||||
if args.type == "f" and not os.path.isfile(full):
|
||||
continue
|
||||
if args.type == "d" and not os.path.isdir(full):
|
||||
continue
|
||||
seen.add(full)
|
||||
rows.append(full if args.absolute else os.path.relpath(full, root))
|
||||
|
||||
if args.sort:
|
||||
rows.sort()
|
||||
if args.limit > 0:
|
||||
rows = rows[:args.limit]
|
||||
for row in rows:
|
||||
print(row)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(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",
|
||||
]);
|
||||
@@ -156,7 +224,24 @@ 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(" ") };
|
||||
return { remoteCommand: shellArgv(toolArgs), requiresStdin: true };
|
||||
}
|
||||
if (subcommand === "py") {
|
||||
return { remoteCommand: buildPythonStdinCommand(args.slice(1)), requiresStdin: true };
|
||||
}
|
||||
if (subcommand === "argv" || subcommand === "exec") {
|
||||
const toolArgs = args.slice(1);
|
||||
if (toolArgs.length === 0) throw new Error(`ssh ${subcommand} requires a command`);
|
||||
return { remoteCommand: shellArgv(toolArgs), requiresStdin: false };
|
||||
}
|
||||
if (subcommand === "find") {
|
||||
return { remoteCommand: buildFindCommand(args.slice(1)), requiresStdin: false };
|
||||
}
|
||||
if (subcommand === "glob") {
|
||||
return { remoteCommand: shellArgv(["glob", ...args.slice(1)]), requiresStdin: false };
|
||||
}
|
||||
if (argvQuotedSshSubcommands.has(subcommand)) {
|
||||
return { remoteCommand: shellArgv(args), requiresStdin: false };
|
||||
}
|
||||
const remote: string[] = [];
|
||||
let remoteStarted = false;
|
||||
@@ -177,20 +262,130 @@ export function parseSshArgs(args: string[]): ParsedSshArgs {
|
||||
remoteStarted = true;
|
||||
remote.push(arg);
|
||||
}
|
||||
return { remoteCommand: remote.length === 0 ? null : remote.join(" ") };
|
||||
return { remoteCommand: remote.length === 0 ? null : remote.join(" "), requiresStdin: false };
|
||||
}
|
||||
|
||||
function shellArgv(args: string[]): string {
|
||||
return args.map(shellQuote).join(" ");
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function positiveInt(value: string, option: string): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${option} must be a positive integer`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function findOptionValue(args: string[], index: number, option: string): string {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.length === 0) throw new Error(`ssh find ${option} requires a value`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function buildFindCommand(args: string[]): string {
|
||||
const paths: string[] = [];
|
||||
const predicates: string[] = [];
|
||||
const patternPredicates: Array<[string, string]> = [];
|
||||
let limit: number | null = null;
|
||||
let sortOutput = false;
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index] ?? "";
|
||||
if (arg === "--limit") {
|
||||
const value = findOptionValue(args, index, arg);
|
||||
limit = positiveInt(value, "ssh find --limit");
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--sort") {
|
||||
sortOutput = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--max-depth" || arg === "-maxdepth" || arg === "--min-depth" || arg === "-mindepth") {
|
||||
const value = findOptionValue(args, index, arg);
|
||||
const findArg = arg === "--max-depth" ? "-maxdepth" : arg === "--min-depth" ? "-mindepth" : arg;
|
||||
predicates.push(findArg, String(positiveInt(value, `ssh find ${arg}`)));
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--type" || arg === "-type") {
|
||||
const value = findOptionValue(args, index, arg);
|
||||
if (!/^[bcdpfls]$/u.test(value)) throw new Error("ssh find --type must be one of: b c d p f l s");
|
||||
predicates.push("-type", value);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--name" || arg === "-name" || arg === "--iname" || arg === "-iname" || arg === "--path" || arg === "-path" || arg === "--ipath" || arg === "-ipath") {
|
||||
const value = findOptionValue(args, index, arg);
|
||||
const findArg = arg.startsWith("--") ? `-${arg.slice(2)}` : arg;
|
||||
patternPredicates.push([findArg, value]);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--contains" || arg === "--icontains") {
|
||||
const value = findOptionValue(args, index, arg);
|
||||
const findArg = arg === "--contains" ? "-name" : "-iname";
|
||||
patternPredicates.push([findArg, `*${value}*`]);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--mtime" || arg === "-mtime" || arg === "--mmin" || arg === "-mmin" || arg === "--size" || arg === "-size") {
|
||||
const value = findOptionValue(args, index, arg);
|
||||
const findArg = arg.startsWith("--") ? `-${arg.slice(2)}` : arg;
|
||||
predicates.push(findArg, value);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("-")) {
|
||||
throw new Error(`unsupported ssh find option: ${arg}`);
|
||||
}
|
||||
paths.push(arg);
|
||||
}
|
||||
|
||||
const findArgs = ["find", ...(paths.length > 0 ? paths : ["."]), ...predicates];
|
||||
if (patternPredicates.length === 1) {
|
||||
const [kind, pattern] = patternPredicates[0]!;
|
||||
findArgs.push(kind, pattern);
|
||||
} else if (patternPredicates.length > 1) {
|
||||
findArgs.push("(");
|
||||
patternPredicates.forEach(([kind, pattern], index) => {
|
||||
if (index > 0) findArgs.push("-o");
|
||||
findArgs.push(kind, pattern);
|
||||
});
|
||||
findArgs.push(")");
|
||||
}
|
||||
findArgs.push("-print");
|
||||
|
||||
let command = shellArgv(findArgs);
|
||||
if (sortOutput) command = `${command} | sort`;
|
||||
if (limit !== null) command = `${command} | head -n ${limit}`;
|
||||
return command;
|
||||
}
|
||||
|
||||
function buildPythonStdinCommand(args: string[]): string {
|
||||
const pythonArgs = args.map(shellQuote).join(" ");
|
||||
const execArgs = pythonArgs.length > 0 ? ` "$UNIDESK_SSH_PY_FILE" ${pythonArgs}` : ' "$UNIDESK_SSH_PY_FILE"';
|
||||
return [
|
||||
'UNIDESK_SSH_PY_FILE="$(mktemp /tmp/unidesk-ssh-py.XXXXXX.py)" || exit 1',
|
||||
`trap 'rm -f "$UNIDESK_SSH_PY_FILE"' EXIT`,
|
||||
'cat > "$UNIDESK_SSH_PY_FILE"',
|
||||
`python3 -u${execArgs}`,
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
function remoteToolBootstrapCommand(): string {
|
||||
const encoded = Buffer.from(remoteApplyPatchSource, "utf8").toString("base64");
|
||||
const encodedApplyPatch = Buffer.from(remoteApplyPatchSource, "utf8").toString("base64");
|
||||
const encodedGlob = Buffer.from(remoteGlobSource, "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"`,
|
||||
`printf %s ${shellQuote(encodedApplyPatch)} | base64 -d > "$UNIDESK_SSH_TOOL_DIR/apply_patch"`,
|
||||
`printf %s ${shellQuote(encodedGlob)} | base64 -d > "$UNIDESK_SSH_TOOL_DIR/glob"`,
|
||||
'chmod 700 "$UNIDESK_SSH_TOOL_DIR/apply_patch"',
|
||||
'chmod 700 "$UNIDESK_SSH_TOOL_DIR/glob"',
|
||||
'export PATH="$UNIDESK_SSH_TOOL_DIR:$PATH"',
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user