import { spawn } from "node:child_process"; import { type UniDeskConfig } from "./config"; import { type DebugDispatchCommand, isDebugDispatchCommand } from "./debug"; import { summarizeMicroserviceProxyResponse } from "./microservices"; import { parseSshArgs } from "./ssh"; export interface RemoteCliOptions { host: string | null; user: string; port: number; projectRoot: string; identityFile: string | null; transport: "auto" | "frontend" | "ssh"; args: string[]; } interface FrontendSession { baseUrl: string; cookie: string; } interface FetchJsonResult { ok: boolean; status?: number; body?: unknown; error?: 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 === "--") { rest.push(...argv.slice(index + 1)); break; } 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; } function shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; } async function runRemoteCliOverSsh(options: RemoteCliOptions): Promise { if (options.host === null) throw new Error("runRemoteCli requires --main-server-ip or --server"); const remoteArgs = options.args.length === 0 ? ["help"] : options.args; const remoteCommand = [ "cd", shellQuote(options.projectRoot), "&&", "exec", "bun", "scripts/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 cli failed to start ssh: ${error.message}\n`); resolve(127); }); child.on("close", (code) => resolve(code ?? 255)); }); } function emitRemoteJson(command: string, data: unknown, ok = true): void { process.stdout.write(`${JSON.stringify({ ok, command, data }, null, 2)}\n`); } function emitRemoteError(command: string, error: unknown): void { const payload = error instanceof Error ? { name: error.name, message: error.message, stack: error.stack ?? null } : { message: String(error) }; process.stdout.write(`${JSON.stringify({ ok: false, command, error: payload }, null, 2)}\n`); } function commandName(args: string[]): string { return args.join(" ") || "help"; } function frontendBaseUrl(host: string, config: UniDeskConfig): string { if (host.startsWith("http://") || host.startsWith("https://")) return host.replace(/\/+$/, ""); if (/:\d+$/.test(host)) return `http://${host}`; return `http://${host}:${config.network.frontend.port}`; } async function readJson(url: string, init?: RequestInit, timeoutMs = 8000): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const res = await fetch(url, { ...init, signal: controller.signal }); const text = await res.text(); let body: unknown = null; try { body = text.length > 0 ? JSON.parse(text) : null; } catch { body = { text }; } return { ok: res.ok, status: res.status, body }; } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) }; } finally { clearTimeout(timer); } } async function loginFrontend(host: string, config: UniDeskConfig): Promise { const baseUrl = frontendBaseUrl(host, config); const res = await fetch(`${baseUrl}/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ username: config.auth.username, password: config.auth.password }), }); const body = await res.text(); if (!res.ok) { throw new Error(`frontend login failed via ${baseUrl}: status=${res.status} body=${body.slice(0, 300)}`); } const cookie = res.headers.get("set-cookie")?.split(";")[0] ?? ""; if (cookie.length === 0) throw new Error(`frontend login via ${baseUrl} did not return a session cookie`); return { baseUrl, cookie }; } async function frontendJson(session: FrontendSession, path: string, init?: RequestInit, timeoutMs = 8000): Promise { const headers = new Headers(init?.headers); headers.set("cookie", session.cookie); if (init?.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json"); return readJson(`${session.baseUrl}${path}`, { ...init, headers }, timeoutMs); } function stringOption(args: string[], name: string): string | undefined { const index = args.indexOf(name); if (index === -1) return undefined; const raw = args[index + 1]; if (raw === undefined || raw.length === 0) throw new Error(`${name} requires a non-empty value`); return raw; } function numberOption(args: string[], name: string, defaultValue: number): number { const raw = stringOption(args, name); if (raw === undefined) return defaultValue; const value = Number(raw); if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`); return value; } function jsonOption(args: string[], name: string): Record | undefined { const raw = stringOption(args, name); if (raw === undefined) return undefined; const parsed = JSON.parse(raw) as unknown; if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(`${name} must be a JSON object`); return parsed as Record; } function dispatchPayload(args: string[], command: DebugDispatchCommand): Record { const explicit = jsonOption(args, "--payload-json") ?? {}; if (command === "provider.upgrade") { return { source: "cli-remote", mode: stringOption(args, "--mode") ?? stringOption(args, "--upgrade-mode") ?? "plan", ...explicit }; } if (command === "host.ssh") { const sshCommand = stringOption(args, "--ssh-command"); return { source: "cli-remote", mode: sshCommand === undefined ? "probe" : "exec", ...(sshCommand === undefined ? {} : { command: sshCommand }), ...(stringOption(args, "--cwd") === undefined ? {} : { cwd: stringOption(args, "--cwd") }), ...(args.includes("--timeout-ms") ? { timeoutMs: numberOption(args, "--timeout-ms", 8000) } : {}), ...explicit, }; } return { source: "cli-remote", ...explicit }; } function summarizeSystemStatus(response: FetchJsonResult): FetchJsonResult { const body = response.body as { systemStatuses?: Array>; ok?: boolean } | null; const systemStatuses = (body?.systemStatuses ?? []).map((item) => { const current = (item.current ?? {}) as Record; const history = Array.isArray(item.history) ? item.history : []; return { providerId: item.providerId, name: item.name, nodeStatus: item.nodeStatus, updatedAt: item.updatedAt, current: item.current === null || item.current === undefined ? null : { ok: current.ok, collectedAt: current.collectedAt, cpu: current.cpu, memory: current.memory, disk: current.disk, }, historyPreview: history.slice(-8), historyCount: history.length, }; }); return { ...response, body: { ok: body?.ok === true, systemStatuses } }; } function summarizeDockerStatus(response: FetchJsonResult): FetchJsonResult { const body = response.body as { dockerStatuses?: Array>; ok?: boolean } | null; const dockerStatuses = (body?.dockerStatuses ?? []).map((item) => { const status = (item.dockerStatus ?? {}) as Record; const containers = Array.isArray(status.containers) ? status.containers : []; return { providerId: item.providerId, name: item.name, nodeStatus: item.nodeStatus, updatedAt: item.updatedAt, dockerStatus: { ok: status.ok, socketPresent: status.socketPresent, collectedAt: status.collectedAt, counts: status.counts, daemon: status.daemon, containersPreview: containers.slice(0, 8), }, }; }); return { ...response, body: { ok: body?.ok === true, dockerStatuses } }; } async function waitForFrontendTask(session: FrontendSession, taskId: string, timeoutMs: number): Promise { const started = Date.now(); let latest: unknown = null; while (Date.now() - started < timeoutMs) { latest = await frontendJson(session, "/api/tasks?limit=100"); const tasks = (latest as { body?: { tasks?: Array<{ id?: string; status?: string; result?: unknown }> } }).body?.tasks ?? []; const task = tasks.find((item) => item.id === taskId); if (task?.status === "succeeded" || task?.status === "failed") return { ok: true, task }; await Bun.sleep(500); } return { ok: false, timeoutMs, latest }; } async function remoteHealth(session: FrontendSession, config: UniDeskConfig): Promise { return { transport: "frontend", frontendPublic: await readJson(`${session.baseUrl}/health`), providerIngressPublic: await readJson(`http://${new URL(session.baseUrl).hostname}:${config.network.providerIngress.port}/health`), overviewInternal: await frontendJson(session, "/api/overview"), nodesInternal: await frontendJson(session, "/api/nodes"), systemStatusInternal: summarizeSystemStatus(await frontendJson(session, "/api/nodes/system-status?limit=24")), dockerStatusInternal: summarizeDockerStatus(await frontendJson(session, "/api/nodes/docker-status")), publicExposureBoundary: { coreHostPort: { port: config.network.core.port, expected: "not-exposed" }, databaseHostPort: { port: config.network.database.port, expected: "not-exposed" }, }, }; } async function remoteDebugDispatch(session: FrontendSession, config: UniDeskConfig, args: string[]): Promise { const third = args[2]; const fourth = args[3]; const providerId = isDebugDispatchCommand(third) ? config.providerGateway.id : third ?? config.providerGateway.id; const commandArg = isDebugDispatchCommand(third) ? third : fourth; const dispatchCommand = isDebugDispatchCommand(commandArg) ? commandArg : "docker.ps"; const dispatch = await frontendJson(session, "/api/dispatch", { method: "POST", body: JSON.stringify({ providerId, command: dispatchCommand, payload: dispatchPayload(args, dispatchCommand) }), }); const taskId = (dispatch as { body?: { taskId?: string } }).body?.taskId ?? ""; const waitMs = numberOption(args, "--wait-ms", 0); const wait = waitMs > 0 && taskId.length > 0 ? await waitForFrontendTask(session, taskId, waitMs) : null; return { transport: "frontend", dispatch, wait }; } async function remoteDebugTask(session: FrontendSession, args: string[]): Promise { const taskId = args[2] ?? "latest"; const tasksResponse = await frontendJson(session, "/api/tasks?limit=100"); const tasks = (tasksResponse as { body?: { tasks?: Array<{ id?: string }> } }).body?.tasks ?? []; const task = taskId === "latest" ? tasks[0] : tasks.find((item) => item.id === taskId); return { transport: "frontend", tasksResponse, taskId, task: task ?? null }; } async function remoteMicroservice(session: FrontendSession, args: string[]): Promise { const action = args[1] ?? "list"; const id = args[2]; const path = args[3]; if (action === "list") { return { transport: "frontend", response: await frontendJson(session, "/api/microservices", undefined, 12_000) }; } if ((action === "status" || action === "health") && id !== undefined) { return { transport: "frontend", response: await frontendJson(session, `/api/microservices/${encodeURIComponent(id)}/${action}`, undefined, 18_000), }; } if (action === "proxy" && id !== undefined && path !== undefined && path.startsWith("/")) { const response = await frontendJson(session, `/api/microservices/${encodeURIComponent(id)}/proxy${path}`, undefined, 24_000); return { transport: "frontend", response: summarizeMicroserviceProxyResponse(response, args), }; } throw new Error("remote microservice command must be: microservice list | status | health | proxy "); } async function runRemoteSshOverFrontend(session: FrontendSession, providerId: string | undefined, args: string[]): Promise { 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.remoteCommand === null) { process.stderr.write("remote frontend transport supports ssh remote commands only; pass a command such as: ssh D601 hostname\n"); return 255; } const dispatch = await frontendJson(session, "/api/dispatch", { method: "POST", body: JSON.stringify({ providerId, command: "host.ssh", payload: { source: "cli-remote-ssh", mode: "exec", command: parsed.remoteCommand, timeoutMs: 15000 }, }), }); const taskId = (dispatch as { body?: { taskId?: string } }).body?.taskId ?? ""; if (!dispatch.ok || taskId.length === 0) { process.stderr.write(`${JSON.stringify(dispatch, null, 2)}\n`); return 255; } const wait = await waitForFrontendTask(session, taskId, 20_000); const task = (wait as { task?: { status?: string; result?: Record; message?: string } }).task; const result = task?.result ?? {}; const stdout = typeof result.stdout === "string" ? result.stdout : ""; const stderr = typeof result.stderr === "string" ? result.stderr : ""; if (stdout.length > 0) process.stdout.write(stdout); if (stderr.length > 0) process.stderr.write(stderr); if (task?.status !== "succeeded") { if (stdout.length === 0 && stderr.length === 0) process.stderr.write(`${JSON.stringify({ taskId, task }, null, 2)}\n`); return typeof result.exitCode === "number" ? result.exitCode : 255; } return typeof result.exitCode === "number" ? result.exitCode : 0; } async function runRemoteCliOverFrontend(options: RemoteCliOptions, config: UniDeskConfig): Promise { if (options.host === null) throw new Error("runRemoteCli requires --main-server-ip or --server"); const args = options.args.length === 0 ? ["help"] : options.args; const name = commandName(args); try { const session = await loginFrontend(options.host, config); const [top, sub] = args; if (top === "help" || top === "--help" || top === "-h") { emitRemoteJson(name, { transport: "frontend", baseUrl: session.baseUrl, commands: ["debug health", "debug dispatch", "debug task", "ssh ", "microservice list", "microservice status ", "microservice health ", "microservice proxy "], }); return 0; } if (top === "debug" && sub === "health") { emitRemoteJson(name, await remoteHealth(session, config)); return 0; } if (top === "debug" && sub === "dispatch") { emitRemoteJson(name, await remoteDebugDispatch(session, config, args)); return 0; } if (top === "debug" && sub === "task") { emitRemoteJson(name, await remoteDebugTask(session, args)); return 0; } if (top === "microservice") { emitRemoteJson(name, await remoteMicroservice(session, args)); return 0; } if (top === "ssh") { return await runRemoteSshOverFrontend(session, sub, args.slice(2)); } throw new Error(`remote frontend transport does not support command: ${name}`); } catch (error) { emitRemoteError(name, error); return 1; } } export async function runRemoteCli(options: RemoteCliOptions, config: UniDeskConfig): Promise { if (options.host === null) throw new Error("runRemoteCli requires --main-server-ip or --server"); const useSsh = options.transport === "ssh" || (options.transport === "auto" && options.identityFile !== null); if (useSsh) return runRemoteCliOverSsh(options); return runRemoteCliOverFrontend(options, config); }