diff --git a/.agents/skills/unidesk-trans/SKILL.md b/.agents/skills/unidesk-trans/SKILL.md index 15857670..0c13e790 100644 --- a/.agents/skills/unidesk-trans/SKILL.md +++ b/.agents/skills/unidesk-trans/SKILL.md @@ -7,7 +7,7 @@ description: UniDesk SSH 透传与 apply-patch 语法 — `trans ` 在远端 host/k3s/Windows 上执行命令或文本 patch。 -**固定入口**: `trans ...`(wrapper 位于 `/root/.local/bin/trans`,等价 `bun scripts/cli.ts ssh "$@"`) +**固定入口**: `trans ...`(wrapper 位于 `/root/.local/bin/trans`,委托 repo 内 ssh-only 启动入口 `bun scripts/ssh-cli.ts ssh "$@"`,避免被无关 CLI 子命令模块解析失败拖垮) --- diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 94584e26..fe7c1fc8 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1,8 +1,8 @@ # UniDesk CLI Reference -UniDesk 的统一 CLI 实现入口是根目录 `scripts/cli.ts`,运行方式固定为 `bun scripts/cli.ts `;普通根 CLI 子命令仍使用该入口。`trans ...` 是 `bun scripts/cli.ts ssh ...` 的短 alias,只用于 SSH/WSL/k3s 透传,用于避免远端操作里反复输出过长前缀;长期参考文档、AGENTS 索引、CLI help 和人工远端操作示例都必须优先写 `trans ...`,不得再把 `bun scripts/cli.ts ssh ...` 作为默认透传入口。CLI 默认输出 JSON,所有成功和失败路径都必须向 stdout 写出结构化对象,避免无输出造成状态不可观测。 +UniDesk 的统一 CLI 实现入口是根目录 `scripts/cli.ts`,运行方式固定为 `bun scripts/cli.ts `;普通根 CLI 子命令仍使用该入口。`trans ...` 是 SSH/WSL/k3s 透传专用入口,wrapper 委托轻量 `scripts/ssh-cli.ts` 启动链路,避免被无关根 CLI 子命令模块的解析或语法错误拖垮;长期参考文档、AGENTS 索引、CLI help 和人工远端操作示例都必须优先写 `trans ...`,不得再把 `bun scripts/cli.ts ssh ...` 作为默认透传入口。CLI 默认输出 JSON,所有成功和失败路径都必须向 stdout 写出结构化对象,避免无输出造成状态不可观测。 -主 server 必须在 PATH 上提供 `/root/.local/bin/trans` 可执行 wrapper,内容委托 repo 内版本化 `scripts/trans` 并执行 `bun scripts/cli.ts ssh "$@"`;交互 shell 可额外提供 alias,但非交互 Codex `exec` 和脚本不能依赖 alias 展开。 +主 server 必须在 PATH 上提供 `/root/.local/bin/trans` 可执行 wrapper,内容委托 repo 内版本化 `scripts/trans` 并执行 `bun scripts/ssh-cli.ts ssh "$@"`;交互 shell 可额外提供 alias,但非交互 Codex `exec` 和脚本不能依赖 alias 展开。 `trans` wrapper 是 SSH/WSL/k3s 透传的唯一默认入口:人工/Codex 远端操作、长期参考文档、AGENTS 索引、CLI help、非交互脚本和非交互 `exec` 都必须直接调主 server PATH 上的 `/root/.local/bin/trans`;禁止把 `bun scripts/cli.ts ssh ...`、`bun scripts/cli.ts trans ...` 或任何带 `bun scripts/cli.ts` 前缀的透传写法作为默认入口。`bun scripts/cli.ts help`、`config`、`server`、`provider`、`microservice` 等普通根 CLI 子命令不受这条限制,仍使用 `bun scripts/cli.ts `,避免透传命令和根子命令在调用前缀上互相混淆。 @@ -233,11 +233,11 @@ GitHub issue/PR 正文局部修补必须优先使用 `trans gh:/owner/repo/issue ## SSH Command -`trans [ssh-like args...]` 是面向人的终端透传入口,不包装 JSON 输出,底层等价于 `bun scripts/cli.ts ssh ...`。CLI 会在宿主机启动 `docker exec -i unidesk-backend-core backend-core --ssh-broker ...`,broker 只连接 backend-core 的 Docker 内网 `/ws/ssh`;core 使用 provider WebSocket 下发 open/dispatch 控制消息,但 stdin/stdout/stderr 数据面必须走 provider 主动连接 main server 的 `host.ssh.tcp-pool` TCP warm pool,provider-gateway 最终执行维护用 SSH 连接宿主或 WSL sshd。TTY 策略固定为交互登录 shell 使用 `ssh -tt`,带远端命令的会话使用 `ssh -T`;`apply-patch`、脚本 stdin、`py` 和旧 `apply-patch-v1` fallback 这类命令模式不得被伪终端回显或注入控制字符。该入口不暴露 database,也不改变 frontend/dev frontend/provider ingress 之外的业务边界;provider data TCP port 是 provider 主动连入的数据面端口,不是计算节点入站要求。 +`trans [ssh-like args...]` 是面向人的终端透传入口,不包装 JSON 输出,默认由 `scripts/ssh-cli.ts` 只加载 SSH/route/远程前端转发相关模块。主 server 本地执行时会在宿主机启动 `docker exec -i unidesk-backend-core backend-core --ssh-broker ...`,broker 只连接 backend-core 的 Docker 内网 `/ws/ssh`;core 使用 provider WebSocket 下发 open/dispatch 控制消息,但 stdin/stdout/stderr 数据面必须走 provider 主动连接 main server 的 `host.ssh.tcp-pool` TCP warm pool,provider-gateway 最终执行维护用 SSH 连接宿主或 WSL sshd。TTY 策略固定为交互登录 shell 使用 `ssh -tt`,带远端命令的会话使用 `ssh -T`;`apply-patch`、脚本 stdin、`py` 和旧 `apply-patch-v1` fallback 这类命令模式不得被伪终端回显或注入控制字符。该入口不暴露 database,也不改变 frontend/dev frontend/provider ingress 之外的业务边界;provider data TCP port 是 provider 主动连入的数据面端口,不是计算节点入站要求。 `trans --help` 和 `trans --help` 是本地 JSON 帮助命令,必须快速返回;不能把 `--help` 解析成 Provider ID,不能打开交互 shell,也不能等待 provider 会话。 -主 server 固定提供 `trans` 缩写,等价于 `bun scripts/cli.ts ssh "$@"` 的受控 UniDesk SSH 透传入口。这里必须同时保留两层入口:交互式 shell 可额外配置 alias;Codex `exec`、脚本和其他非交互 shell 不会自动展开 alias,所以还必须有 `/root/.local/bin/trans` 可执行 wrapper,内容固定为委托 repo 内版本化脚本: +主 server 固定提供 `trans` 缩写,等价于 `bun scripts/ssh-cli.ts ssh "$@"` 的受控 UniDesk SSH 透传入口。这里必须同时保留两层入口:交互式 shell 可额外配置 alias;Codex `exec`、脚本和其他非交互 shell 不会自动展开 alias,所以还必须有 `/root/.local/bin/trans` 可执行 wrapper,内容固定为委托 repo 内版本化脚本: ```sh #!/bin/sh diff --git a/scripts/src/remote-options.ts b/scripts/src/remote-options.ts new file mode 100644 index 00000000..483c179c --- /dev/null +++ b/scripts/src/remote-options.ts @@ -0,0 +1,91 @@ +export interface RemoteCliOptions { + host: string | null; + user: string; + port: number; + projectRoot: string; + identityFile: string | null; + transport: "auto" | "frontend" | "ssh"; + args: string[]; +} + +const hostOptions = new Set(["--main-server-ip", "--main-server", "--server"]); +const userOptions = new Set(["--main-server-user", "--server-user"]); +const portOptions = new Set(["--main-server-port", "--server-port"]); +const rootOptions = new Set(["--main-server-root", "--server-root"]); +const keyOptions = new Set(["--main-server-key", "--server-key"]); +const transportOptions = new Set(["--main-server-transport", "--server-transport"]); + +function positivePort(raw: string, option: string): number { + const value = Number(raw); + if (!Number.isInteger(value) || value <= 0 || value > 65535) throw new Error(`${option} must be a TCP port from 1 to 65535`); + return value; +} + +function requiredValue(argv: string[], index: number, option: string): string { + const value = argv[index + 1]; + if (value === undefined || value.length === 0) throw new Error(`${option} requires a non-empty value`); + return value; +} + +function transportValue(raw: string, option: string): RemoteCliOptions["transport"] { + if (raw === "auto" || raw === "frontend" || raw === "ssh") return raw; + throw new Error(`${option} must be one of: auto, frontend, ssh`); +} + +export function extractRemoteCliOptions(argv: string[]): RemoteCliOptions { + const rest: string[] = []; + const options: RemoteCliOptions = { + host: null, + user: "root", + port: 22, + projectRoot: "/root/unidesk", + identityFile: null, + transport: "auto", + args: rest, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] ?? ""; + if (arg === "--") { + if (rest.length === 0) { + rest.push(...argv.slice(index + 1)); + break; + } + rest.push(arg); + continue; + } + if (hostOptions.has(arg)) { + options.host = requiredValue(argv, index, arg); + index += 1; + continue; + } + if (userOptions.has(arg)) { + options.user = requiredValue(argv, index, arg); + index += 1; + continue; + } + if (portOptions.has(arg)) { + options.port = positivePort(requiredValue(argv, index, arg), arg); + index += 1; + continue; + } + if (rootOptions.has(arg)) { + options.projectRoot = requiredValue(argv, index, arg); + index += 1; + continue; + } + if (keyOptions.has(arg)) { + options.identityFile = requiredValue(argv, index, arg); + index += 1; + continue; + } + if (transportOptions.has(arg)) { + options.transport = transportValue(requiredValue(argv, index, arg), arg); + index += 1; + continue; + } + rest.push(arg); + } + + return options; +} diff --git a/scripts/src/remote-ssh.ts b/scripts/src/remote-ssh.ts new file mode 100644 index 00000000..92ed2f99 --- /dev/null +++ b/scripts/src/remote-ssh.ts @@ -0,0 +1,1001 @@ +import { spawn } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { runApplyPatchV2, type ApplyPatchV2Executor } from "./apply-patch-v2"; +import { type UniDeskConfig } from "./config"; +import { type RemoteCliOptions } from "./remote-options"; +import { + buildWindowsPowerShellInvocation, + createSshStdoutForwarder, + formatSshFailureHint, + formatSshRuntimeTimeoutHint, + formatSshRuntimeTimingHint, + normalizeSshOperationArgs, + parseSshInvocation, + remoteCommandForRoute, + sshFailureHint, + sshRoutePayloadCwd, + sshRouteSeparatorCompatibilityHint, + sshRuntimeTimeoutHint, + sshRuntimeTimeoutMs, + sshRuntimeTimingHint, + wrapSshRemoteCommand, + type SshCaptureResult, +} from "./ssh"; +import { + isSshFileTransferOperation, + runSshFileTransferOperation, + type SshRemoteCommandExecutor, + type SshRemoteCommandStreamHandlers, +} from "./ssh-file-transfer"; + +interface FrontendSession { + baseUrl: string; + cookie: string; + sshClientToken: string | null; +} + +interface FetchJsonResult { + ok: boolean; + status?: number; + body?: unknown; + error?: string; + responseHeaders?: Record; + responseTruncated?: boolean; + responseBytesRead?: number; + responseContentLength?: string | null; +} + +const remoteSshInputChunkBytes = 32 * 1024; + +function normalizeSshCommandArgs(args: string[]): string[] { + if (args[0] === "ssh") return args; + return ["ssh", ...args]; +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function frontendBaseUrl(host: string, config: UniDeskConfig): string { + if (host.startsWith("http://") || host.startsWith("https://")) return host.replace(/\/+$/u, ""); + if (/:\d+$/u.test(host)) return `http://${host}`; + return `http://${host}:${config.network.frontend.port}`; +} + +function remoteHttpClientMode(env: NodeJS.ProcessEnv = process.env): "curl" | "fetch" { + const explicit = env.UNIDESK_REMOTE_HTTP_CLIENT?.trim().toLowerCase(); + if (explicit === "fetch") return "fetch"; + if (explicit === "curl") return "curl"; + return isRunnerEnvironment(env) ? "curl" : "fetch"; +} + +function isRunnerEnvironment(env: NodeJS.ProcessEnv): boolean { + return Boolean( + env.AGENTRUN_BOOT_MODE + || env.AGENTRUN_RUN_ID + || env.AGENTRUN_K8S_JOB_NAME + || env.CODE_QUEUE_SERVICE_ROLE + || env.CODE_QUEUE_INSTANCE_ID + || env.KUBERNETES_SERVICE_HOST, + ); +} + +async function readJson(url: string, init?: RequestInit, timeoutMs = 8000, maxResponseBytes = 5_000_000): Promise { + if (remoteHttpClientMode() === "curl") return readJsonWithCurl(url, init, timeoutMs, maxResponseBytes); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { ...init, signal: controller.signal }); + const reader = res.body?.getReader(); + const chunks: Uint8Array[] = []; + let bytes = 0; + let responseTruncated = false; + if (reader !== undefined) { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (bytes + value.byteLength > maxResponseBytes) { + const keep = Math.max(0, maxResponseBytes - bytes); + if (keep > 0) { + chunks.push(value.slice(0, keep)); + bytes += keep; + } + responseTruncated = true; + try { + await reader.cancel(); + } catch { + // Ignore cancel failures after the bounded preview has been collected. + } + break; + } + chunks.push(value); + bytes += value.byteLength; + } + } + const buffer = new Uint8Array(bytes); + let offset = 0; + for (const chunk of chunks) { + buffer.set(chunk, offset); + offset += chunk.byteLength; + } + const text = new TextDecoder().decode(buffer); + let body: unknown = null; + try { + body = text.length > 0 && !responseTruncated ? JSON.parse(text) : null; + } catch { + body = { text }; + } + if (responseTruncated) { + body = { _unideskResponseTruncated: true, maxResponseBytes, bytesRead: bytes, contentLength: res.headers.get("content-length"), textPreview: text }; + } + return { ok: res.ok, status: res.status, body, responseHeaders: responseHeadersRecord(res.headers), responseTruncated, responseBytesRead: bytes, responseContentLength: res.headers.get("content-length") }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) }; + } finally { + clearTimeout(timer); + } +} + +function responseHeadersRecord(headers: Headers): Record { + const record: Record = {}; + headers.forEach((value, key) => { + record[key.toLowerCase()] = value; + }); + return record; +} + +function requestHeaders(init?: RequestInit): Array<[string, string]> { + const headers = new Headers(init?.headers); + const output: Array<[string, string]> = []; + headers.forEach((value, key) => output.push([key, value])); + return output; +} + +async function runCurl(args: string[], timeoutMs: number): Promise<{ status: number | null; stdout: string; stderr: string; timedOut: boolean; error?: string }> { + const child = spawn("curl", args, { stdio: ["ignore", "pipe", "pipe"] }); + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + }, timeoutMs + 1000); + child.stdout?.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk))); + child.stderr?.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk))); + return await new Promise((resolve) => { + child.on("error", (error) => { + clearTimeout(timer); + resolve({ status: null, stdout: "", stderr: "", timedOut, error: error.message }); + }); + child.on("close", (status) => { + clearTimeout(timer); + resolve({ + status, + stdout: Buffer.concat(stdoutChunks).toString("utf8"), + stderr: Buffer.concat(stderrChunks).toString("utf8"), + timedOut, + }); + }); + }); +} + +function parseCurlResponseHeaders(raw: string): Record { + const blocks = raw.split(/\r?\n\r?\n/u).map((block) => block.trim()).filter(Boolean); + const latest = blocks.at(-1) ?? ""; + const headers: Record = {}; + for (const line of latest.split(/\r?\n/u).slice(1)) { + const splitAt = line.indexOf(":"); + if (splitAt <= 0) continue; + headers[line.slice(0, splitAt).trim().toLowerCase()] = line.slice(splitAt + 1).trim(); + } + return headers; +} + +function decodeBoundedBody(buffer: Buffer, maxResponseBytes: number): { text: string; truncated: boolean; bytesRead: number } { + if (buffer.byteLength <= maxResponseBytes) return { text: buffer.toString("utf8"), truncated: false, bytesRead: buffer.byteLength }; + return { text: buffer.subarray(0, maxResponseBytes).toString("utf8"), truncated: true, bytesRead: maxResponseBytes }; +} + +async function readJsonWithCurl(url: string, init?: RequestInit, timeoutMs = 8000, maxResponseBytes = 5_000_000): Promise { + const dir = await mkdtemp(path.join(tmpdir(), "unidesk-remote-http-")); + const headersFile = path.join(dir, "headers.txt"); + const bodyFile = path.join(dir, "body.bin"); + const requestBodyFile = path.join(dir, "request-body.bin"); + try { + const method = init?.method ?? (init?.body === undefined ? "GET" : "POST"); + const args = [ + "-sS", + "--max-time", String(Math.max(1, Math.ceil(timeoutMs / 1000))), + "-D", headersFile, + "-o", bodyFile, + "-w", "%{http_code}", + "-X", method, + ]; + for (const [key, value] of requestHeaders(init)) args.push("-H", `${key}: ${value}`); + if (init?.body !== undefined) { + await writeFile(requestBodyFile, typeof init.body === "string" ? init.body : String(init.body)); + args.push("--data-binary", `@${requestBodyFile}`); + } + args.push(url); + const curl = await runCurl(args, timeoutMs); + if (curl.status !== 0) { + const curlError = curl.error ?? curl.stderr.trim(); + return { + ok: false, + status: curl.stdout.trim().match(/^\d{3}$/u) ? Number(curl.stdout.trim()) : undefined, + error: curl.timedOut ? `curl timed out after ${timeoutMs}ms` : (curlError.length > 0 ? curlError : `curl exited with ${curl.status}`), + }; + } + const status = Number(curl.stdout.trim()); + const headersText = await readFile(headersFile, "utf8").catch(() => ""); + const responseHeaders = parseCurlResponseHeaders(headersText); + const bodyBuffer = await readFile(bodyFile).catch(() => Buffer.alloc(0)); + const decoded = decodeBoundedBody(bodyBuffer, maxResponseBytes); + let body: unknown = null; + try { + body = decoded.text.length > 0 && !decoded.truncated ? JSON.parse(decoded.text) : null; + } catch { + body = { text: decoded.text }; + } + if (decoded.truncated) { + body = { + _unideskResponseTruncated: true, + maxResponseBytes, + bytesRead: decoded.bytesRead, + contentLength: responseHeaders["content-length"] ?? null, + textPreview: decoded.text, + }; + } + return { + ok: status >= 200 && status < 300, + status, + body, + responseHeaders, + responseTruncated: decoded.truncated, + responseBytesRead: decoded.bytesRead, + responseContentLength: responseHeaders["content-length"] ?? null, + }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) }; + } finally { + await rm(dir, { recursive: true, force: true }).catch(() => undefined); + } +} + +async function loginFrontend(host: string, config: UniDeskConfig): Promise { + const baseUrl = frontendBaseUrl(host, config); + const res = await readJson(`${baseUrl}/login`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ username: config.auth.username, password: config.auth.password }), + }, 8_000, 120_000); + if (!res.ok) throw new Error(`frontend login failed via ${baseUrl}: status=${res.status ?? "unknown"} body=${JSON.stringify(res.body ?? res.error).slice(0, 300)}`); + const cookie = res.responseHeaders?.["set-cookie"]?.split(";")[0] ?? ""; + if (cookie.length === 0) throw new Error(`frontend login via ${baseUrl} did not return a session cookie`); + return { baseUrl, cookie, sshClientToken: null }; +} + +function sshClientTokenFromEnv(env: NodeJS.ProcessEnv = process.env): string | null { + const token = env.UNIDESK_SSH_CLIENT_TOKEN?.trim() ?? ""; + return token.length > 0 ? token : null; +} + +function scopedSshFrontendSession(host: string, config: UniDeskConfig, token: string): FrontendSession { + return { baseUrl: frontendBaseUrl(host, config), cookie: "", sshClientToken: token }; +} + +function frontendSshWebSocketUrl(session: FrontendSession): string { + const url = new URL("/ws/ssh", session.baseUrl); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.toString(); +} + +function webSocketDataText(data: unknown): string { + if (typeof data === "string") return data; + if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8"); + if (ArrayBuffer.isView(data)) return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8"); + return String(data); +} + +function openFrontendSshWebSocket(session: FrontendSession): WebSocket { + const WebSocketWithHeaders = WebSocket as unknown as new ( + url: string, + options?: { headers?: Record }, + ) => WebSocket; + const headers = session.sshClientToken === null + ? { cookie: session.cookie } + : { authorization: `Bearer ${session.sshClientToken}` }; + return new WebSocketWithHeaders(frontendSshWebSocketUrl(session), { headers }); +} + +async function runRemoteSshWebSocket( + session: FrontendSession, + invocation: ReturnType, +): Promise { + const parsed = invocation.parsed; + const startedAtMs = Date.now(); + const size = { + cols: Number(process.stdout.columns) > 0 ? Number(process.stdout.columns) : 100, + rows: Number(process.stdout.rows) > 0 ? Number(process.stdout.rows) : 30, + }; + const openTimeoutMs = Math.max(15000, Number(process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || 60000)); + const runtimeTimeoutMs = sshRuntimeTimeoutMs(); + const payload = { + providerId: invocation.providerId, + command: wrapSshRemoteCommand(parsed.remoteCommand, parsed.requiredHelpers), + cwd: sshRoutePayloadCwd(invocation.route), + tty: parsed.remoteCommand === null, + stdinEotOnEnd: parsed.remoteCommand !== null, + openTimeoutMs, + runtimeTimeoutMs, + cols: size.cols, + rows: size.rows, + }; + const ws = openFrontendSshWebSocket(session); + let exitCode = 255; + let settled = false; + let canSend = false; + let sessionReady = false; + const pending: string[] = []; + const pendingSessionMessages: string[] = []; + + const send = (value: unknown): void => { + const text = JSON.stringify(value); + if (!canSend || ws.readyState !== WebSocket.OPEN) { + pending.push(text); + return; + } + ws.send(text); + }; + const flush = (): void => { + while (pending.length > 0 && ws.readyState === WebSocket.OPEN) ws.send(pending.shift()!); + }; + const sendInput = (value: Buffer | string): void => { + sendWhenSessionReady({ type: "ssh.input", data: Buffer.from(value).toString("base64"), encoding: "base64" }); + }; + const sendInputChunked = (value: string): void => { + const buffer = Buffer.from(value, "utf8"); + for (let offset = 0; offset < buffer.length; offset += remoteSshInputChunkBytes) { + sendInput(buffer.subarray(offset, Math.min(buffer.length, offset + remoteSshInputChunkBytes))); + } + }; + const sendWhenSessionReady = (value: unknown): void => { + const text = JSON.stringify(value); + if (!sessionReady || ws.readyState !== WebSocket.OPEN) { + pendingSessionMessages.push(text); + return; + } + ws.send(text); + }; + const flushSessionMessages = (): void => { + if (!sessionReady || ws.readyState !== WebSocket.OPEN) return; + while (pendingSessionMessages.length > 0) ws.send(pendingSessionMessages.shift()!); + }; + + return await new Promise((resolve) => { + const rawMode = parsed.remoteCommand === null && process.stdin.isTTY && typeof process.stdin.setRawMode === "function"; + const stdoutForwarder = parsed.remoteCommand === null ? null : createSshStdoutForwarder({ + invocation, + transport: "frontend-websocket", + }); + let timedOut = false; + const openTimer = setTimeout(() => { + if (sessionReady || settled) return; + process.stderr.write("unidesk remote frontend ssh bridge timed out waiting for provider session\n"); + exitCode = 255; + try { + ws.close(); + } catch { + // Ignore close failures while resolving the timeout path. + } + }, openTimeoutMs); + const runtimeTimer = setTimeout(() => { + if (settled) return; + timedOut = true; + exitCode = 124; + process.stderr.write(formatSshRuntimeTimeoutHint(sshRuntimeTimeoutHint({ + invocation, + transport: "frontend-websocket", + timeoutMs: runtimeTimeoutMs, + }))); + try { + ws.close(); + } catch { + // Ignore close failures while resolving the timeout path. + } + finish(124); + }, runtimeTimeoutMs); + + const restore = (): void => { + clearTimeout(openTimer); + clearTimeout(runtimeTimer); + process.stdin.off("data", onStdinData); + process.stdin.off("end", onStdinEnd); + if (rawMode) process.stdin.setRawMode(false); + }; + const finish = (code: number): void => { + if (settled) return; + settled = true; + restore(); + const hint = timedOut ? null : sshFailureHint(invocation.providerId, parsed, code, ""); + if (hint !== null) process.stderr.write(formatSshFailureHint(hint)); + const timingHint = formatSshRuntimeTimingHint(sshRuntimeTimingHint({ + invocation, + transport: "frontend-websocket", + exitCode: code, + startedAtMs, + })); + if (timingHint) process.stderr.write(timingHint); + resolve(code); + }; + const onStdinData = (chunk: Buffer): void => { + sendInput(chunk); + }; + const onStdinEnd = (): void => { + if (parsed.stdinSuffix) sendInput(parsed.stdinSuffix); + if (payload.stdinEotOnEnd === true) sendInput(Buffer.from([4])); + sendWhenSessionReady({ type: "ssh.eof" }); + }; + + ws.addEventListener("open", () => { + canSend = true; + send({ type: "ssh.open", ...payload }); + flush(); + }); + ws.addEventListener("message", (event) => { + const text = webSocketDataText(event.data); + let message: Record; + try { + message = JSON.parse(text) as Record; + } catch { + process.stderr.write(`${text}\n`); + return; + } + if (message.type === "ssh.dispatched") return; + if (message.type === "ssh.opened") { + sessionReady = true; + clearTimeout(openTimer); + flushSessionMessages(); + return; + } + if (message.type === "ssh.data") { + const chunk = Buffer.from(String(message.data ?? ""), message.encoding === "base64" ? "base64" : "utf8"); + if (message.stream === "stderr") process.stderr.write(chunk); + else if (stdoutForwarder === null) { + process.stdout.write(chunk); + } else { + const hint = stdoutForwarder.write(chunk); + if (hint !== null) process.stderr.write(hint); + } + return; + } + if (message.type === "ssh.error") { + process.stderr.write(`${String(message.message || "ssh bridge error")}\n`); + exitCode = 255; + ws.close(); + return; + } + if (message.type === "ssh.exit") { + exitCode = Number.isInteger(message.exitCode) ? Number(message.exitCode) : 255; + ws.close(); + } + }); + ws.addEventListener("close", () => finish(exitCode)); + ws.addEventListener("error", () => { + process.stderr.write("unidesk remote frontend ssh bridge websocket error\n"); + finish(255); + }); + + if (rawMode) process.stdin.setRawMode(true); + process.stdin.resume(); + if (parsed.stdinPrefix) sendInput(parsed.stdinPrefix); + process.stdin.on("data", onStdinData); + process.stdin.on("end", onStdinEnd); + }); +} + +async function runRemoteSshWebSocketCapture( + session: FrontendSession, + invocation: ReturnType, + command: string[], + input?: string, +): Promise { + const remoteCommand = remoteCommandForRoute(invocation.route, command, { stdin: input !== undefined }); + return await runRemoteSshWebSocketCaptureRemoteCommand(session, invocation, remoteCommand, input); +} + +async function runRemoteSshWebSocketCaptureRemoteCommand( + session: FrontendSession, + invocation: ReturnType, + remoteCommand: string, + input?: string, +): Promise { + const captureInvocation = { + ...invocation, + parsed: { ...invocation.parsed, remoteCommand, requiresStdin: input !== undefined, invocationKind: "helper" as const }, + }; + const startedAtMs = Date.now(); + const size = { + cols: Number(process.stdout.columns) > 0 ? Number(process.stdout.columns) : 100, + rows: Number(process.stdout.rows) > 0 ? Number(process.stdout.rows) : 30, + }; + const runtimeTimeoutMs = sshRuntimeTimeoutMs(); + const payload = { + providerId: invocation.providerId, + command: wrapSshRemoteCommand(remoteCommand), + cwd: sshRoutePayloadCwd(invocation.route), + tty: false, + stdinEotOnEnd: false, + openTimeoutMs: Math.max(15000, Number(process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || 60000)), + runtimeTimeoutMs, + cols: size.cols, + rows: size.rows, + }; + const ws = openFrontendSshWebSocket(session); + let exitCode = 255; + let settled = false; + let canSend = false; + let sessionReady = false; + let stdout = ""; + let stderr = ""; + const pending: string[] = []; + const pendingSessionMessages: string[] = []; + + const send = (value: unknown): void => { + const text = JSON.stringify(value); + if (!canSend || ws.readyState !== WebSocket.OPEN) { + pending.push(text); + return; + } + ws.send(text); + }; + const sendWhenSessionReady = (value: unknown): void => { + const text = JSON.stringify(value); + if (!sessionReady || ws.readyState !== WebSocket.OPEN) { + pendingSessionMessages.push(text); + return; + } + ws.send(text); + }; + const sendInput = (value: Buffer | string): void => { + sendWhenSessionReady({ type: "ssh.input", data: Buffer.from(value).toString("base64"), encoding: "base64" }); + }; + const sendInputChunked = (value: string): void => { + const buffer = Buffer.from(value, "utf8"); + for (let offset = 0; offset < buffer.length; offset += remoteSshInputChunkBytes) { + sendInput(buffer.subarray(offset, Math.min(buffer.length, offset + remoteSshInputChunkBytes))); + } + }; + const flush = (): void => { + while (pending.length > 0 && ws.readyState === WebSocket.OPEN) ws.send(pending.shift()!); + }; + const flushSessionMessages = (): void => { + if (!sessionReady || ws.readyState !== WebSocket.OPEN) return; + while (pendingSessionMessages.length > 0) ws.send(pendingSessionMessages.shift()!); + }; + + return await new Promise((resolve) => { + let killTimer: ReturnType | null = null; + const finish = (code: number): void => { + if (settled) return; + settled = true; + clearTimeout(openTimer); + clearTimeout(runtimeTimer); + if (killTimer !== null) clearTimeout(killTimer); + const timingHint = formatSshRuntimeTimingHint(sshRuntimeTimingHint({ + invocation: captureInvocation, + transport: "frontend-websocket", + exitCode: code, + startedAtMs, + })); + if (timingHint) stderr += timingHint; + resolve({ exitCode: code, stdout, stderr }); + }; + const openTimer = setTimeout(() => { + if (sessionReady || settled) return; + stderr += "unidesk remote frontend ssh bridge timed out waiting for provider session\n"; + exitCode = 255; + try { + ws.close(); + } catch { + // Ignore. + } + }, payload.openTimeoutMs); + const runtimeTimer = setTimeout(() => { + if (settled) return; + exitCode = 124; + stderr += formatSshRuntimeTimeoutHint(sshRuntimeTimeoutHint({ + invocation: captureInvocation, + transport: "frontend-websocket", + timeoutMs: runtimeTimeoutMs, + })); + try { + ws.close(); + } catch { + // Ignore. + } + killTimer = setTimeout(() => finish(124), 2000); + finish(124); + }, runtimeTimeoutMs); + + ws.addEventListener("open", () => { + canSend = true; + send({ type: "ssh.open", ...payload }); + flush(); + }); + ws.addEventListener("message", (event) => { + const text = webSocketDataText(event.data); + let message: Record; + try { + message = JSON.parse(text) as Record; + } catch { + stderr += `${text}\n`; + return; + } + if (message.type === "ssh.dispatched") return; + if (message.type === "ssh.opened") { + sessionReady = true; + clearTimeout(openTimer); + if (input !== undefined) sendInputChunked(input); + sendWhenSessionReady({ type: "ssh.eof" }); + flushSessionMessages(); + return; + } + if (message.type === "ssh.data") { + const chunk = Buffer.from(String(message.data ?? ""), message.encoding === "base64" ? "base64" : "utf8").toString("utf8"); + if (message.stream === "stderr") stderr += chunk; + else stdout += chunk; + return; + } + if (message.type === "ssh.error") { + stderr += `${String(message.message || "ssh bridge error")}\n`; + exitCode = 255; + ws.close(); + return; + } + if (message.type === "ssh.exit") { + exitCode = Number.isInteger(message.exitCode) ? Number(message.exitCode) : 255; + ws.close(); + } + }); + ws.addEventListener("close", () => finish(exitCode)); + ws.addEventListener("error", () => { + stderr += "unidesk remote frontend ssh bridge websocket error\n"; + finish(255); + }); + }); +} + +async function runRemoteSshWebSocketStreamRemoteCommand( + session: FrontendSession, + invocation: ReturnType, + remoteCommand: string, + handlers: SshRemoteCommandStreamHandlers, + input?: string, + options: { inactivityTimeoutMs?: number } = {}, +): Promise { + const streamInvocation = { + ...invocation, + parsed: { ...invocation.parsed, remoteCommand, requiresStdin: input !== undefined, invocationKind: "helper" as const }, + }; + const startedAtMs = Date.now(); + const size = { + cols: Number(process.stdout.columns) > 0 ? Number(process.stdout.columns) : 100, + rows: Number(process.stdout.rows) > 0 ? Number(process.stdout.rows) : 30, + }; + const inactivityTimeoutMs = options.inactivityTimeoutMs ?? sshRuntimeTimeoutMs(); + const runtimeTimeoutMs = options.inactivityTimeoutMs === undefined ? inactivityTimeoutMs : Math.max(inactivityTimeoutMs * 4, 60 * 60_000); + const payload = { + providerId: invocation.providerId, + command: wrapSshRemoteCommand(remoteCommand), + cwd: sshRoutePayloadCwd(invocation.route), + tty: false, + stdinEotOnEnd: false, + openTimeoutMs: Math.max(15000, Number(process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || 60000)), + runtimeTimeoutMs, + runtimeTimeoutMode: options.inactivityTimeoutMs === undefined ? "wall-clock" : "inactivity", + cols: size.cols, + rows: size.rows, + }; + const ws = openFrontendSshWebSocket(session); + let exitCode = 255; + let settled = false; + let canSend = false; + let sessionReady = false; + let stdout = ""; + let stderr = ""; + let streamError: unknown = null; + let streamWrites = Promise.resolve(); + const pending: string[] = []; + const pendingSessionMessages: string[] = []; + + const send = (value: unknown): void => { + const text = JSON.stringify(value); + if (!canSend || ws.readyState !== WebSocket.OPEN) { + pending.push(text); + return; + } + ws.send(text); + }; + const sendWhenSessionReady = (value: unknown): void => { + const text = JSON.stringify(value); + if (!sessionReady || ws.readyState !== WebSocket.OPEN) { + pendingSessionMessages.push(text); + return; + } + ws.send(text); + }; + const sendInput = (value: Buffer | string): void => { + sendWhenSessionReady({ type: "ssh.input", data: Buffer.from(value).toString("base64"), encoding: "base64" }); + }; + const sendInputChunked = (value: string): void => { + const buffer = Buffer.from(value, "utf8"); + for (let offset = 0; offset < buffer.length; offset += remoteSshInputChunkBytes) { + sendInput(buffer.subarray(offset, Math.min(buffer.length, offset + remoteSshInputChunkBytes))); + } + }; + const flush = (): void => { + while (pending.length > 0 && ws.readyState === WebSocket.OPEN) ws.send(pending.shift()!); + }; + const flushSessionMessages = (): void => { + if (!sessionReady || ws.readyState !== WebSocket.OPEN) return; + while (pendingSessionMessages.length > 0) ws.send(pendingSessionMessages.shift()!); + }; + const queueStreamWrite = (chunk: Buffer, stream: "stdout" | "stderr"): void => { + streamWrites = streamWrites.then(async () => { + if (stream === "stdout") await handlers.onStdout(chunk); + else if (handlers.onStderr !== undefined) await handlers.onStderr(chunk); + }).catch((error) => { + streamError = error; + stderr += `unidesk remote frontend ssh stream sink failed: ${error instanceof Error ? error.message : String(error)}\n`; + exitCode = 255; + try { + ws.close(); + } catch { + // Ignore close failures after the local stream sink has failed. + } + }); + }; + + return await new Promise((resolve) => { + let killTimer: ReturnType | null = null; + let inactivityTimer: ReturnType | null = null; + const refreshActivityTimer = (): void => { + if (settled || options.inactivityTimeoutMs === undefined) return; + if (inactivityTimer !== null) clearTimeout(inactivityTimer); + inactivityTimer = setTimeout(() => { + exitCode = 124; + stderr += formatSshRuntimeTimeoutHint(sshRuntimeTimeoutHint({ + invocation: streamInvocation, + transport: "frontend-websocket", + timeoutMs: inactivityTimeoutMs, + })); + try { + ws.close(); + } catch { + // Ignore. + } + killTimer = setTimeout(() => finish(124), 2000); + finish(124); + }, inactivityTimeoutMs); + }; + const finish = (code: number): void => { + if (settled) return; + settled = true; + clearTimeout(openTimer); + clearTimeout(runtimeTimer); + if (inactivityTimer !== null) clearTimeout(inactivityTimer); + if (killTimer !== null) clearTimeout(killTimer); + void streamWrites.then(() => { + const finalCode = streamError === null ? code : 255; + const timingHint = formatSshRuntimeTimingHint(sshRuntimeTimingHint({ + invocation: streamInvocation, + transport: "frontend-websocket", + exitCode: finalCode, + startedAtMs, + })); + if (timingHint) stderr += timingHint; + resolve({ exitCode: finalCode, stdout, stderr }); + }); + }; + const openTimer = setTimeout(() => { + if (sessionReady || settled) return; + stderr += "unidesk remote frontend ssh bridge timed out waiting for provider session\n"; + exitCode = 255; + try { + ws.close(); + } catch { + // Ignore. + } + }, payload.openTimeoutMs); + const runtimeTimer = setTimeout(() => { + if (settled) return; + exitCode = 124; + stderr += formatSshRuntimeTimeoutHint(sshRuntimeTimeoutHint({ + invocation: streamInvocation, + transport: "frontend-websocket", + timeoutMs: runtimeTimeoutMs, + })); + try { + ws.close(); + } catch { + // Ignore. + } + killTimer = setTimeout(() => finish(124), 2000); + finish(124); + }, runtimeTimeoutMs); + refreshActivityTimer(); + + ws.addEventListener("open", () => { + canSend = true; + send({ type: "ssh.open", ...payload }); + flush(); + }); + ws.addEventListener("message", (event) => { + const text = webSocketDataText(event.data); + let message: Record; + try { + message = JSON.parse(text) as Record; + } catch { + stderr += `${text}\n`; + return; + } + if (message.type === "ssh.dispatched") { + refreshActivityTimer(); + return; + } + if (message.type === "ssh.opened") { + sessionReady = true; + clearTimeout(openTimer); + refreshActivityTimer(); + if (input !== undefined) sendInputChunked(input); + sendWhenSessionReady({ type: "ssh.eof" }); + flushSessionMessages(); + return; + } + if (message.type === "ssh.data") { + refreshActivityTimer(); + const chunk = Buffer.from(String(message.data ?? ""), message.encoding === "base64" ? "base64" : "utf8"); + if (message.stream === "stderr") { + stderr += chunk.toString("utf8"); + queueStreamWrite(chunk, "stderr"); + } else { + queueStreamWrite(chunk, "stdout"); + } + return; + } + if (message.type === "ssh.error") { + stderr += `${String(message.message || "ssh bridge error")}\n`; + exitCode = 255; + ws.close(); + return; + } + if (message.type === "ssh.exit") { + exitCode = Number.isInteger(message.exitCode) ? Number(message.exitCode) : 255; + ws.close(); + } + }); + ws.addEventListener("close", () => finish(exitCode)); + ws.addEventListener("error", () => { + stderr += "unidesk remote frontend ssh bridge websocket error\n"; + finish(255); + }); + }); +} + +async function runRemoteSshOverFrontend(session: FrontendSession, target: string | undefined, args: string[]): Promise { + if (!target) throw new Error("remote ssh requires a route, for example: bun scripts/ssh-cli.ts --main-server-ip 74.48.78.17 ssh D601 hostname"); + const normalizedArgs = normalizeSshOperationArgs(args); + process.stderr.write(sshRouteSeparatorCompatibilityHint(args, normalizedArgs)); + const invocation = parseSshInvocation(target, normalizedArgs); + if (isSshFileTransferOperation(normalizedArgs)) { + const executor: SshRemoteCommandExecutor = { + runRemoteCommand: (remoteCommand, input) => runRemoteSshWebSocketCaptureRemoteCommand(session, invocation, remoteCommand, input), + streamRemoteCommand: (remoteCommand, handlers, input, options) => runRemoteSshWebSocketStreamRemoteCommand(session, invocation, remoteCommand, handlers, input, options), + }; + return await runSshFileTransferOperation(invocation, normalizedArgs, executor, { + buildRouteCommand: remoteCommandForRoute, + buildWindowsPowerShellCommand: buildWindowsPowerShellInvocation, + }); + } + if ((normalizedArgs[0] ?? "") === "apply-patch") { + const executor: ApplyPatchV2Executor = { + run: (command, input) => runRemoteSshWebSocketCapture(session, invocation, command, input), + }; + return await runApplyPatchV2({ + executor, + stdin: process.stdin, + stdout: process.stdout, + stderr: process.stderr, + argv: normalizedArgs.slice(1), + timing: { + providerId: invocation.providerId, + route: invocation.route.raw, + transport: "frontend-websocket", + }, + }); + } + return runRemoteSshWebSocket(session, invocation); +} + +async function runRemoteSshCliOverSsh(options: RemoteCliOptions): Promise { + if (options.host === null) throw new Error("runRemoteSshCli requires --main-server-ip or --server"); + const remoteArgs = normalizeSshCommandArgs(options.args); + const remoteCommand = [ + "cd", + shellQuote(options.projectRoot), + "&&", + "exec", + "bun", + "scripts/ssh-cli.ts", + ...remoteArgs.map(shellQuote), + ].join(" "); + const sshArgs = [ + "-p", + String(options.port), + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=accept-new", + "-o", + "ServerAliveInterval=20", + "-o", + "ServerAliveCountMax=3", + ]; + if (options.identityFile !== null) sshArgs.push("-i", options.identityFile); + sshArgs.push(`${options.user}@${options.host}`, remoteCommand); + + const child = spawn("ssh", sshArgs, { + stdio: ["inherit", "inherit", "inherit"], + }); + + return await new Promise((resolve) => { + child.on("error", (error) => { + process.stderr.write(`unidesk remote ssh cli failed to start ssh: ${error.message}\n`); + resolve(127); + }); + child.on("close", (code) => resolve(code ?? 255)); + }); +} + +async function runRemoteSshCliOverFrontend(options: RemoteCliOptions, config: UniDeskConfig): Promise { + if (options.host === null) throw new Error("runRemoteSshCli requires --main-server-ip or --server"); + const args = normalizeSshCommandArgs(options.args); + const [top, sub] = args; + if (top !== "ssh") throw new Error(`remote ssh cli supports only ssh commands, got: ${top || ""}`); + const scopedSshToken = sshClientTokenFromEnv(); + const session = scopedSshToken === null ? await loginFrontend(options.host, config) : scopedSshFrontendSession(options.host, config, scopedSshToken); + return await runRemoteSshOverFrontend(session, sub, args.slice(2)); +} + +export async function runRemoteSshCli(options: RemoteCliOptions, config: UniDeskConfig): Promise { + if (options.host === null) throw new Error("runRemoteSshCli requires --main-server-ip or --server"); + const args = normalizeSshCommandArgs(options.args); + const useSsh = options.transport === "ssh" || (options.transport === "auto" && options.identityFile !== null); + if (useSsh) return runRemoteSshCliOverSsh({ ...options, args }); + return runRemoteSshCliOverFrontend({ ...options, args }, config); +} + +export async function runRemoteSshCommandCapture( + config: UniDeskConfig, + host: string, + target: string, + args: string[], + input?: string, + env: NodeJS.ProcessEnv = process.env, +): Promise { + const token = sshClientTokenFromEnv(env); + const session = token === null + ? await loginFrontend(host, config) + : scopedSshFrontendSession(host, config, token); + const normalizedArgs = normalizeSshOperationArgs(args); + const invocation = parseSshInvocation(target, normalizedArgs); + const parsed = invocation.parsed; + if (parsed.remoteCommand === null) throw new Error(`remote ssh ${target} capture requires a non-interactive operation`); + const stdin = parsed.stdinPrefix !== undefined || parsed.stdinSuffix !== undefined + ? `${parsed.stdinPrefix ?? ""}${input ?? ""}${parsed.stdinSuffix ?? ""}` + : input; + return await runRemoteSshWebSocketCaptureRemoteCommand(session, invocation, parsed.remoteCommand, stdin); +} diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index 5225f830..2d0a5e34 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -3180,8 +3180,8 @@ async function runSshStreamRemoteCommand( } async function runRemoteSsh(config: UniDeskConfig, host: string, providerId: string, args: string[]): Promise { - const { runRemoteCli } = await import("./remote"); - return await runRemoteCli({ + const { runRemoteSshCli } = await import("./remote-ssh"); + return await runRemoteSshCli({ host, user: "root", port: 22, @@ -3212,7 +3212,7 @@ export async function runSshCommandCapture(config: UniDeskConfig, target: string } async function runRemoteSshCapture(config: UniDeskConfig, host: string, target: string, args: string[], input?: string): Promise { - const { runRemoteSshCommandCapture } = await import("./remote"); + const { runRemoteSshCommandCapture } = await import("./remote-ssh"); return await runRemoteSshCommandCapture(config, host, target, args, input); } diff --git a/scripts/ssh-cli.ts b/scripts/ssh-cli.ts new file mode 100644 index 00000000..975b35ce --- /dev/null +++ b/scripts/ssh-cli.ts @@ -0,0 +1,47 @@ +import { readConfig } from "./src/config"; +import { isHelpToken, sshHelp } from "./src/help"; +import { emitError, emitJson } from "./src/output"; +import { extractRemoteCliOptions } from "./src/remote-options"; +import { runRemoteSshCli } from "./src/remote-ssh"; +import { runSsh } from "./src/ssh"; + +const remoteOptions = extractRemoteCliOptions(process.argv.slice(2)); +const args = normalizeSshCommandArgs(remoteOptions.args); +const commandName = args.join(" ") || "ssh"; + +function normalizeSshCommandArgs(rawArgs: string[]): string[] { + if (rawArgs[0] === "ssh") return rawArgs; + return ["ssh", ...rawArgs]; +} + +function isGhContentRouteTarget(target: string | undefined): boolean { + return typeof target === "string" && target.startsWith("gh:"); +} + +async function main(): Promise { + const [top, sub, third] = args; + if (top !== "ssh") throw new Error(`ssh-cli supports only the ssh command, got: ${top || ""}`); + + if (sub === undefined || isHelpToken(sub) || (isHelpToken(third) && args.length === 3)) { + emitJson(commandName, sshHelp()); + return; + } + + if (remoteOptions.host !== null) { + process.exitCode = await runRemoteSshCli({ ...remoteOptions, args }, readConfig()); + return; + } + + if (isGhContentRouteTarget(sub)) { + const { runGhContentRoute } = await import("./src/gh-route"); + process.exitCode = await runGhContentRoute(sub, args.slice(2)); + return; + } + + process.exitCode = await runSsh(readConfig(), sub, args.slice(2)); +} + +main().catch((error) => { + emitError(commandName, error); + process.exitCode = 1; +}); diff --git a/scripts/tran b/scripts/tran index 67c18135..e3d732ff 100755 --- a/scripts/tran +++ b/scripts/tran @@ -2,7 +2,7 @@ set -eu repo=${UNIDESK_TRAN_REPO_ROOT:-/root/unidesk} -if [ ! -f "$repo/scripts/cli.ts" ]; then +if [ ! -f "$repo/scripts/ssh-cli.ts" ]; then self_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) repo=$(CDPATH= cd -- "$self_dir/.." && pwd) fi @@ -49,8 +49,8 @@ if [ -n "${CODE_QUEUE_SERVICE_ROLE:-}" ] || [ -n "${CODE_QUEUE_INSTANCE_ID:-}" ] fi if [ "$runner_env" = 1 ] && [ -n "$host" ] && [ "${UNIDESK_TRAN_LOCAL:-}" != "1" ]; then - bun "$repo/scripts/cli.ts" --main-server-ip "$host" ssh "$@" + bun "$repo/scripts/ssh-cli.ts" --main-server-ip "$host" ssh "$@" exit $? fi -bun "$repo/scripts/cli.ts" ssh "$@" +bun "$repo/scripts/ssh-cli.ts" ssh "$@" diff --git a/scripts/trans b/scripts/trans index e0e68a83..ffdc38d3 100755 --- a/scripts/trans +++ b/scripts/trans @@ -2,9 +2,9 @@ set -eu repo=${UNIDESK_TRANS_REPO_ROOT:-/root/unidesk} -if [ ! -f "$repo/scripts/cli.ts" ]; then +if [ ! -f "$repo/scripts/ssh-cli.ts" ]; then self_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) repo=$(CDPATH= cd -- "$self_dir/.." && pwd) fi -exec bun "$repo/scripts/cli.ts" ssh "$@" +exec bun "$repo/scripts/ssh-cli.ts" ssh "$@"