feat: add structured ssh passthrough helpers
This commit is contained in:
@@ -19,7 +19,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文
|
||||
- `bun scripts/cli.ts server status`:查询固定端口、容器状态、健康检查和访问 URL,判定标准见 `docs/reference/deployment.md`。
|
||||
- `bun scripts/cli.ts server logs`:分页返回文件日志与 Docker 日志尾部,日志规则见 `docs/reference/observability.md`。
|
||||
- `bun scripts/cli.ts server rebuild <backend-core|frontend|provider-gateway|todo-note>`:以 build-first、label-scoped replace 的异步 job 重建单个服务,避免 Docker Compose v1 recreate 问题,规则见 `docs/reference/deployment.md`。
|
||||
- `bun scripts/cli.ts ssh <providerId> [ssh-like args...]`:通过 provider-gateway 的 Host SSH / WSL SSH 维护桥打开近似原生 ssh 的交互会话或远端命令,并在远端 PATH 注入 `apply_patch`;`bun scripts/cli.ts ssh <providerId> apply-patch < patch.diff` 可直接调用该远端补丁工具,使用规则见 `docs/reference/cli.md` 和 `docs/reference/provider-gateway.md`。
|
||||
- `bun scripts/cli.ts ssh <providerId> [ssh-like args...]`:通过 provider-gateway 的 Host SSH / WSL SSH 维护桥打开近似原生 ssh 的交互会话或远端命令,并在远端 PATH 注入 `apply_patch` 与 `glob`;`apply-patch`、`py`、结构化 `find`、`glob` 和 `argv` 子命令用于避免远端补丁、Python stdin 执行与常用只读命令的嵌套转义问题,使用规则见 `docs/reference/cli.md` 和 `docs/reference/provider-gateway.md`。
|
||||
- `bun scripts/cli.ts microservice list/status/health/proxy`:管理和验证挂载在主 server 或计算节点 Docker 中的业务 microservice,Todo Note on main-server 与 FindJob/Pipeline/MET Nonlinear on D601 的规则见 `docs/reference/microservices.md`。
|
||||
- `bun scripts/cli.ts server stop`:以异步 job 停止固定 Compose 项目中的全部 UniDesk 服务,停止后用 `server status` 复核。
|
||||
- `bun scripts/cli.ts job list` / `bun scripts/cli.ts job status latest`:查询 `.state/jobs/` 中的异步任务状态,job 机制见 `docs/reference/cli.md`。
|
||||
|
||||
+26
-3
@@ -15,6 +15,7 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定
|
||||
- `server rebuild <backend-core|frontend|provider-gateway|todo-note>` 创建异步 job,先构建目标服务镜像,构建成功后只按 Compose project/service label 移除该服务旧容器,再用 `--no-deps` 启动目标服务;该命令用于替代手工删除容器的兜底流程,其中 `todo-note` 只重建主 server 承载的 Todo Note 后端,不会重建或删除 database 命名卷。
|
||||
- `ssh <providerId> [ssh-like args...]` 通过 backend-core 内网 WebSocket broker 和 provider-gateway 的 Host SSH / WSL SSH 维护桥连接目标节点;无后续参数时进入远端登录 shell,有后续参数时按 ssh 远端命令体验执行并返回远端 exit code。
|
||||
- `ssh <providerId> apply-patch [tool args...] < patch.diff` 直接调用远端注入的 `apply_patch` 工具,并把本地 stdin 中的标准 `*** Begin Patch` / `*** End Patch` patch 流透传给目标节点。
|
||||
- `ssh <providerId> py [script-args...] < script.py` 把本地 stdin 落到远端临时 `.py` 文件后再以 `python3 -u` 执行并自动清理,避免再手写 `'python3 -'`、heredoc 或多层引号;`script-args` 会按 argv 安全透传给远端脚本。
|
||||
- `microservice list/status/health/proxy` 通过 backend-core 内网 API 管理挂载在计算节点 Docker 中的 microservice;`health` 和 `proxy` 会走真实 backend-core -> provider-gateway -> 节点本机后端链路,`proxy` 对超大 body 默认输出有界预览,规则见 `docs/reference/microservices.md`。
|
||||
- `job list` 与 `job status` 查询 `.state/jobs/` 文件系统状态,是异步命令的可观测入口。
|
||||
- `debug health`、`debug dispatch` 与 `debug task` 走真实内部 core、WebSocket、数据库、provider、系统指标、Docker 状态和 Host SSH 维护桥流程,只用于开发调试,不写入 `TEST.md` 的正式验收步骤。
|
||||
@@ -46,7 +47,7 @@ core 只允许声明了 `host.ssh` capability 的 provider 使用 `ssh` 透传
|
||||
|
||||
本地 broker 默认等待 provider SSH 会话打开 60000ms,以便在目标节点同时有较多 microservice.http 任务时仍能建立维护会话;需要诊断慢连接时可用 `UNIDESK_SSH_OPEN_TIMEOUT_MS=<ms>` 临时调大,但最小有效值固定为 15000ms,避免把真实离线误判为长时间阻塞。
|
||||
|
||||
`ssh <providerId>` 会在远端会话启动时注入 `/tmp/unidesk-ssh-tools/apply_patch`,并把该目录加入远端 `PATH`。该工具接受标准 `*** Begin Patch` / `*** End Patch` patch 格式,便于通过 SSH 透传编辑远端仓库文件;目标节点需要具备 `python3` 和 `base64`。注入工具只写 `/tmp/unidesk-ssh-tools`,不修改目标仓库,交互式 shell 和远端命令都可以直接调用 `apply_patch`。
|
||||
`ssh <providerId>` 会在远端会话启动时注入 `/tmp/unidesk-ssh-tools/apply_patch` 和 `/tmp/unidesk-ssh-tools/glob`,并把该目录加入远端 `PATH`。`apply_patch` 接受标准 `*** Begin Patch` / `*** End Patch` patch 格式,便于通过 SSH 透传编辑远端仓库文件;`glob` 在远端用 Python 执行路径匹配,避免依赖 shell glob 展开。目标节点需要具备 `python3` 和 `base64`。注入工具只写 `/tmp/unidesk-ssh-tools`,不修改目标仓库,交互式 shell 和远端命令都可以直接调用这些工具。
|
||||
|
||||
如果只是远端打小补丁,不需要再手写 `ssh D601 'apply_patch' < patch.diff` 这种命令拼接;正式入口是 `bun scripts/cli.ts ssh D601 apply-patch < patch.diff`。`apply-patch` 与 `patch` 等价,附加参数会原样透传给远端 `apply_patch`,例如 `bun scripts/cli.ts ssh D601 apply-patch --help`。标准单命令用法如下,不需要先创建本地 patch 临时文件:
|
||||
|
||||
@@ -61,13 +62,35 @@ bun scripts/cli.ts ssh D601 apply-patch <<'PATCH'
|
||||
PATCH
|
||||
```
|
||||
|
||||
通过 `ssh <providerId>` 执行多行脚本时,脚本内容必须从本地 stdin 直接喂给远端解释器,例如 `bun scripts/cli.ts ssh D601 'python3 -' < script.py` 或 `printf ... | (bun scripts/cli.ts ssh D601 'bash -s')` 这种单层 stdin 传输。不要在远端命令字符串里再嵌套 heredoc、复杂引号或 `ssh 'python3 - <<EOF ...'` 形态;多层 shell 解析容易把 stdin 绑定到错误进程,结果会打开远端交互解释器并留下悬挂的 broker/SSH 会话。长脚本需要复用时,优先通过 stdin 写入目标节点的临时脚本,再在同一个远端命令中显式执行并清理。
|
||||
如果只是想远端执行 Python 脚本,不要再手写 `bun scripts/cli.ts ssh D601 'python3 -' < script.py`。正式入口是 `bun scripts/cli.ts ssh D601 py < script.py`;CLI 会先把本地 stdin 写入远端临时 `.py` 文件,再以无缓冲模式执行并自动清理,同时对额外脚本参数逐个做 shell quoting,避免字符串转义问题。典型用法:
|
||||
|
||||
```bash
|
||||
printf 'import sys\nprint(sys.argv)\n' | bun scripts/cli.ts ssh D601 py foo '--bar=baz'
|
||||
```
|
||||
|
||||
`ssh <providerId> py` 的附加参数是脚本参数,不是 Python 解释器参数;如需 `-m`、`-X` 或多条 shell 命令,仍使用原始远端命令入口。为了保证 CLI 输出及时可见且不被 `ssh -tt` 误触发交互 REPL,helper 固定采用“临时文件 + `python3 -u`”模式。
|
||||
|
||||
`ssh <providerId> find` 是常用远端搜索的结构化入口,避免在 Host SSH / WSL SSH 透传里手写 `find \( ... \)`、`*`、管道和多层引号。它会把路径、谓词和 pattern 作为 argv 安全拼接,并支持重复 `--name`、`--iname`、`--path` 或 `--ipath`,重复 pattern 默认按 OR 组合。稳定参数包括 `--max-depth`/`-maxdepth`、`--min-depth`/`-mindepth`、`--type`/`-type`、`--contains`、`--icontains`、`--name`/`-name`、`--iname`/`-iname`、`--path`/`-path`、`--ipath`/`-ipath`、`--mtime`/`-mtime`、`--mmin`/`-mmin`、`--size`/`-size`、`--sort` 和 `--limit N`。典型用法:
|
||||
|
||||
```bash
|
||||
bun scripts/cli.ts ssh D601 find /home/ubuntu --max-depth 4 --type d --icontains pika --limit 50 --sort
|
||||
```
|
||||
|
||||
`ssh <providerId> glob` 是远端 glob 匹配入口,支持 `--root DIR`、`--pattern PATTERN`、`--contains TEXT`、`--icontains TEXT`、`--type any|f|d`、`--limit N`、`--sort` 和 `--absolute`。`--contains` 与 `--icontains` 可避免在本地 shell 中输入 `*`;若显式使用 `--pattern '**/*.ts'` 这类 pattern,仍应按本地 shell 规则加引号,防止参数到达 CLI 前已被本地 shell 展开。典型用法:
|
||||
|
||||
```bash
|
||||
bun scripts/cli.ts ssh D601 glob --root /home/ubuntu/pikapython --pattern '**/*-test.cpp' --limit 20 --sort
|
||||
```
|
||||
|
||||
`ssh <providerId> argv <command> [args...]` 是通用 argv 安全拼接入口;`exec` 是同义入口。它适合不需要 shell 管道的常用命令。`find`、`glob` 和 `apply-patch` 有专用入口;`rg`、`grep`、`sed`、`nl`、`stat`、`du`、`ls`、`cat`、`head`、`tail`、`wc` 和 `pwd` 可以直接作为 `ssh` 子命令使用,CLI 会对每个 argv token 做 shell quoting。需要管道、重定向、变量展开或多条命令时仍使用旧的自由远端命令入口,并把整段远端 shell 脚本作为一个本地参数传入。
|
||||
|
||||
通过 `ssh <providerId>` 执行多行脚本时,优先使用结构化 helper,例如 `bun scripts/cli.ts ssh D601 py < script.py` 或 `printf ... | (bun scripts/cli.ts ssh D601 'bash -s')` 这种单层 stdin 传输。不要在远端命令字符串里再嵌套 heredoc、复杂引号或 `ssh 'python3 - <<EOF ...'` 形态;多层 shell 解析容易把 stdin 绑定到错误进程,结果会打开远端交互解释器并留下悬挂的 broker/SSH 会话。长脚本需要复用时,优先通过 stdin 写入目标节点的临时脚本,再在同一个远端命令中显式执行并清理。
|
||||
|
||||
## Remote Main Server Passthrough
|
||||
|
||||
`--main-server-ip` 是一个全局前缀,必须放在需要透传的命令同一次调用中,例如 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug health`。默认传输是公网 frontend:本地 CLI 读取本仓库 `config.json` 中的 frontend 登录账号密码,登录 `http://<ip>:<frontendPort>/` 获取 HttpOnly session cookie,然后通过 frontend 的 `/api/*` 同源代理访问 backend-core 内网 API;因此计算节点只需要能访问公网 frontend,不需要主 server SSH key,也不需要打开 backend-core REST API 或 PostgreSQL 端口。
|
||||
|
||||
默认 frontend 传输支持 `debug health`、`debug dispatch`、`debug task`、`microservice list/status/health/proxy` 和 `ssh <PROVIDER_ID> <remote-command>`。其中 `ssh` 的 remote frontend 传输使用 `host.ssh` dispatch 执行有界远端命令,适合 `ssh D601 hostname` 这类自测;交互式登录 shell 仍应在主 server 本机 CLI 使用,或显式切换到旧 SSH 传输后在主 server 上执行。若确实需要旧行为,可使用 `--main-server-key <key>` 或 `--main-server-transport ssh`,这时 CLI 会通过 SSH 登录主 server 的 `--main-server-root` 目录执行同一个 `bun scripts/cli.ts <command>`。
|
||||
默认 frontend 传输支持 `debug health`、`debug dispatch`、`debug task`、`microservice list/status/health/proxy` 和 `ssh <PROVIDER_ID> <remote-command>`。其中 `ssh` 的 remote frontend 传输使用 `host.ssh` dispatch 执行有界远端命令,适合 `ssh D601 hostname` 这类自测;交互式登录 shell 仍应在主 server 本机 CLI 使用,或显式切换到旧 SSH 传输后在主 server 上执行。frontend 远程透传不会流式转发本地 stdin,因此 `ssh py < script.py`、`ssh apply-patch < patch.diff` 这类 stdin-backed helper 必须在主 server 本机运行,或显式切换到 `--main-server-transport ssh`。若确实需要旧行为,可使用 `--main-server-key <key>` 或 `--main-server-transport ssh`,这时 CLI 会通过 SSH 登录主 server 的 `--main-server-root` 目录执行同一个 `bun scripts/cli.ts <command>`。
|
||||
|
||||
计算节点可以用该入口测试自身的远程升级闭环,而不需要在计算节点公开 core REST API 或 database。标准顺序是:先运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug health` 确认主 server 看到当前 Provider 在线,且该 Provider labels 中 `unideskCapabilities` 包含 `host.ssh`、`hostSshConfigured=true`、`hostSshKeyPresent=true`;再运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch <PROVIDER_ID> provider.upgrade --mode schedule --wait-ms 15000` 触发真实 `provider.upgrade`;随后再次运行 `debug health` 确认节点重新上线;最后运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch <PROVIDER_ID> host.ssh --wait-ms 15000` 和 `bun scripts/cli.ts --main-server-ip 74.48.78.17 ssh <PROVIDER_ID> hostname` 验证 SSH 透传能力。provider-gateway 新部署或升级后没有完成这组 remote CLI 自测,不能视为交付完成。
|
||||
|
||||
|
||||
+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