946 lines
42 KiB
TypeScript
946 lines
42 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import { type UniDeskConfig } from "./config";
|
|
import { type DebugDispatchCommand, isDebugDispatchCommand } from "./debug";
|
|
import { summarizeMicroserviceHealthResponse, summarizeMicroserviceObservation, summarizeMicroserviceProxyResponse } from "./microservices";
|
|
import { parseNetworkPerfOptions, runNetworkPerf } from "./network-perf";
|
|
import { isSshSkillDiscoveryArgs, parseSshArgs } from "./ssh";
|
|
import { codexJudgeQueryAsync, codexOutputQueryAsync, codexPrPreflightQueryAsync, codexQueuesQueryAsync, codexTaskQueryAsync, codexTasksQueryAsync } from "./code-queue";
|
|
import { runDecisionCenterCommandAsync } from "./decision-center";
|
|
import {
|
|
artifactRegistryReadonlyResultFromCommand,
|
|
buildArtifactRegistryReadonlyProbe,
|
|
parseArtifactRegistryOptions,
|
|
} from "./artifact-registry";
|
|
import { runCiPublishBackendCoreDryRunPreflight, runCiPublishUserServiceDryRunPreflight } from "./ci";
|
|
|
|
export interface RemoteCliOptions {
|
|
host: string | null;
|
|
user: string;
|
|
port: number;
|
|
projectRoot: string;
|
|
identityFile: string | null;
|
|
transport: "auto" | "frontend" | "ssh";
|
|
args: string[];
|
|
}
|
|
|
|
export type RemoteFailureClassification = "auth-missing" | "remote-proxy-missing" | "provider-unreachable" | "local-docker-required";
|
|
|
|
export interface AutoRemoteCiPublishPlan {
|
|
enabled: boolean;
|
|
host: string | null;
|
|
reason: string;
|
|
failureClassification: RemoteFailureClassification | null;
|
|
transport: "frontend";
|
|
command: string | null;
|
|
}
|
|
|
|
interface FrontendSession {
|
|
baseUrl: string;
|
|
cookie: string;
|
|
}
|
|
|
|
interface FetchJsonResult {
|
|
ok: boolean;
|
|
status?: number;
|
|
body?: unknown;
|
|
error?: string;
|
|
responseTruncated?: boolean;
|
|
responseBytesRead?: number;
|
|
responseContentLength?: string | null;
|
|
}
|
|
|
|
class RemoteCliFailure extends Error {
|
|
readonly failureClassification: RemoteFailureClassification;
|
|
readonly runnerDisposition = "infra-blocked";
|
|
readonly detail: unknown;
|
|
|
|
constructor(failureClassification: RemoteFailureClassification, message: string, detail?: unknown) {
|
|
super(message);
|
|
this.name = "RemoteCliFailure";
|
|
this.failureClassification = failureClassification;
|
|
this.detail = detail ?? null;
|
|
}
|
|
}
|
|
|
|
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`);
|
|
}
|
|
|
|
function truthyDisabled(raw: string | undefined): boolean {
|
|
if (raw === undefined) return false;
|
|
const normalized = raw.trim().toLowerCase();
|
|
return normalized === "0" || normalized === "false" || normalized === "off" || normalized === "disabled";
|
|
}
|
|
|
|
function normalizeRemoteHostHint(raw: string | undefined): string | null {
|
|
const value = raw?.trim() ?? "";
|
|
if (value.length === 0) return null;
|
|
if (value === "localhost" || value === "127.0.0.1" || value === "::1") return null;
|
|
return value.replace(/\/+$/u, "");
|
|
}
|
|
|
|
function isCodeQueueRunnerEnv(env: NodeJS.ProcessEnv): boolean {
|
|
return Boolean(env.CODE_QUEUE_SERVICE_ROLE || env.CODE_QUEUE_INSTANCE_ID || env.CODE_QUEUE_DEV_CONTAINER_MASTER_HOST || env.KUBERNETES_SERVICE_HOST);
|
|
}
|
|
|
|
export function autoRemoteCiPublishUserServiceDryRunPlan(
|
|
config: UniDeskConfig,
|
|
args: string[],
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): AutoRemoteCiPublishPlan {
|
|
const [top, sub] = args;
|
|
const isPublishDryRun = top === "ci" && (sub === "publish-user-service" || sub === "publish-backend-core") && args.includes("--dry-run");
|
|
if (!isPublishDryRun) {
|
|
return { enabled: false, host: null, reason: "not ci publish-user-service/publish-backend-core --dry-run", failureClassification: null, transport: "frontend", command: null };
|
|
}
|
|
if (truthyDisabled(env.UNIDESK_CI_PUBLISH_AUTO_REMOTE)) {
|
|
return { enabled: false, host: null, reason: "UNIDESK_CI_PUBLISH_AUTO_REMOTE disables automatic remote preflight", failureClassification: "local-docker-required", transport: "frontend", command: null };
|
|
}
|
|
const explicitHost = normalizeRemoteHostHint(env.CODE_QUEUE_DEV_CONTAINER_MASTER_HOST)
|
|
?? normalizeRemoteHostHint(env.UNIDESK_MAIN_SERVER_IP)
|
|
?? normalizeRemoteHostHint(env.UNIDESK_MAIN_SERVER_HOST);
|
|
const host = explicitHost ?? (isCodeQueueRunnerEnv(env) ? normalizeRemoteHostHint(config.network.publicHost) : null);
|
|
if (host === null) {
|
|
return { enabled: false, host: null, reason: "no remote main-server frontend host was detected for this runner", failureClassification: "local-docker-required", transport: "frontend", command: null };
|
|
}
|
|
return {
|
|
enabled: true,
|
|
host,
|
|
reason: explicitHost === null
|
|
? "Code Queue runner environment detected; using config.network.publicHost as the frontend control-plane"
|
|
: "runner remote main-server frontend host detected",
|
|
failureClassification: null,
|
|
transport: "frontend",
|
|
command: ["bun", "scripts/cli.ts", "--main-server-ip", host, ...args].join(" "),
|
|
};
|
|
}
|
|
|
|
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<number> {
|
|
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<number>((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) };
|
|
const classification = error instanceof RemoteCliFailure
|
|
? {
|
|
failureClassification: error.failureClassification,
|
|
runnerDisposition: error.runnerDisposition,
|
|
retryable: error.failureClassification !== "auth-missing",
|
|
detail: error.detail,
|
|
recoveryHints: remoteFailureRecoveryHints(error.failureClassification),
|
|
}
|
|
: null;
|
|
process.stdout.write(`${JSON.stringify({
|
|
ok: false,
|
|
command,
|
|
...(classification === null ? {} : classification),
|
|
error: payload,
|
|
}, null, 2)}\n`);
|
|
}
|
|
|
|
function remoteFailureRecoveryHints(classification: RemoteFailureClassification): string[] {
|
|
if (classification === "auth-missing") {
|
|
return [
|
|
"verify config.json frontend auth.username/auth.password against the remote main-server frontend login",
|
|
"retry the same command through --main-server-ip after credentials are restored",
|
|
];
|
|
}
|
|
if (classification === "remote-proxy-missing") {
|
|
return [
|
|
"verify the remote frontend is reachable on the configured public frontend port",
|
|
"verify frontend can proxy /api to backend-core before retrying artifact publish preflight",
|
|
];
|
|
}
|
|
if (classification === "provider-unreachable") {
|
|
return [
|
|
"verify backend-core /api/dispatch can create D601 host.ssh tasks",
|
|
"verify the D601 provider-gateway host.ssh capability and artifact registry health",
|
|
];
|
|
}
|
|
return [
|
|
"run this read-only preflight with --main-server-ip <host> from runner environments without local backend-core/database containers",
|
|
];
|
|
}
|
|
|
|
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, maxResponseBytes = 5_000_000): Promise<FetchJsonResult> {
|
|
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, 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);
|
|
}
|
|
}
|
|
|
|
async function loginFrontend(host: string, config: UniDeskConfig): Promise<FrontendSession> {
|
|
const baseUrl = frontendBaseUrl(host, config);
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), 8_000);
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(`${baseUrl}/login`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ username: config.auth.username, password: config.auth.password }),
|
|
signal: controller.signal,
|
|
});
|
|
} catch (error) {
|
|
throw new RemoteCliFailure("remote-proxy-missing", `frontend login request failed via ${baseUrl}: ${error instanceof Error ? error.message : String(error)}`, { baseUrl });
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
const body = await res.text();
|
|
if (!res.ok) {
|
|
const failureClassification: RemoteFailureClassification = res.status === 401 || res.status === 403 ? "auth-missing" : "remote-proxy-missing";
|
|
throw new RemoteCliFailure(failureClassification, `frontend login failed via ${baseUrl}: status=${res.status} body=${body.slice(0, 300)}`, { baseUrl, status: res.status });
|
|
}
|
|
const cookie = res.headers.get("set-cookie")?.split(";")[0] ?? "";
|
|
if (cookie.length === 0) throw new RemoteCliFailure("auth-missing", `frontend login via ${baseUrl} did not return a session cookie`, { baseUrl, status: res.status });
|
|
return { baseUrl, cookie };
|
|
}
|
|
|
|
async function frontendJson(session: FrontendSession, path: string, init?: RequestInit, timeoutMs = 8000, maxResponseBytes = 5_000_000): Promise<FetchJsonResult> {
|
|
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, maxResponseBytes);
|
|
}
|
|
|
|
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 cappedNumberOption(args: string[], name: string, defaultValue: number, maxValue: number): number {
|
|
return Math.min(numberOption(args, name, defaultValue), maxValue);
|
|
}
|
|
|
|
function jsonOption(args: string[], name: string): Record<string, unknown> | 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<string, unknown>;
|
|
}
|
|
|
|
const compactSkillDiscoverPython = String.raw`import os,json,socket,platform,getpass
|
|
from pathlib import Path as P
|
|
S=os.getenv('S','all');L=int(os.getenv('L','0'));D=int(os.getenv('D','4'));skip={'node_modules','.git','.state','logs','references','__pycache__'}
|
|
def isw():
|
|
try:r=P('/proc/sys/kernel/osrelease').read_text(errors='ignore').lower()
|
|
except Exception:r=''
|
|
return 'microsoft' in r or 'wsl' in r or 'WSL_INTEROP' in os.environ
|
|
def wp(p):
|
|
s=str(p)
|
|
return s[5].upper()+':\\'+s[7:].replace('/','\\') if s.startswith('/mnt/') and len(s)>6 and s[5].isalpha() and s[6]=='/' else None
|
|
def md(f):
|
|
n=f.parent.name;d=''
|
|
try:ls=f.read_text(errors='replace')[:8192].splitlines()
|
|
except Exception:ls=[]
|
|
if ls and ls[0].strip()=='---':
|
|
for l in ls[1:]:
|
|
if l.strip()=='---':break
|
|
if ':' in l:
|
|
k,v=l.split(':',1);k=k.strip().lower();v=v.strip().strip('"\' ')
|
|
if k=='name' and v:n=v
|
|
if k=='description' and v:d=v
|
|
if not d:
|
|
for l in ls:
|
|
x=l.strip()
|
|
if x and not x.startswith('---') and not x.startswith('#'):
|
|
d=x;break
|
|
return n,d
|
|
def scan(sc,root):
|
|
rec={'scope':sc,'path':str(root),'windowsPath':wp(root),'exists':False,'skillCount':0,'error':None};out=[]
|
|
try:rec['exists']=root.exists()
|
|
except Exception as e:rec['error']=str(e)
|
|
if not rec['exists']:return rec,out
|
|
try:
|
|
for f in root.rglob('SKILL.md'):
|
|
rel=f.relative_to(root).parts[:-1]
|
|
if not rel or len(rel)>D or any(x in skip for x in rel):continue
|
|
n,d=md(f);out.append({'scope':sc,'name':n,'description':d,'path':str(f.parent),'skillMd':str(f),'windowsPath':wp(f.parent),'root':str(root)})
|
|
except Exception as e:rec['error']=str(e)
|
|
rec['skillCount']=len(out);return rec,out
|
|
roots=[];h=P.home()
|
|
if S!='windows':roots += [('wsl',h/'.agents/skills'),('wsl',h/'.codex/skills'),('wsl',P('/root/.agents/skills')),('wsl',P('/root/.codex/skills'))]
|
|
if S!='wsl' and isw():
|
|
try:users=list(P('/mnt/c/Users').iterdir())
|
|
except Exception:users=[]
|
|
for u in users:
|
|
if u.is_dir() and u.name.lower() not in {'all users','default','default user','public'}:roots += [('windows',u/'.agents/skills'),('windows',u/'.codex/skills')]
|
|
seen=set();rr=[];ss=[]
|
|
for sc,r in roots:
|
|
k=(sc,str(r))
|
|
if k in seen:continue
|
|
seen.add(k);a,b=scan(sc,r);rr.append(a);ss+=b
|
|
ss.sort(key=lambda x:(0 if x['scope']=='wsl' else 1,x['name'].lower(),x['path']));tb=len(ss)
|
|
if L>0:ss=ss[:L]
|
|
c={'total':len(ss),'totalBeforeLimit':tb,'wsl':sum(1 for x in ss if x['scope']=='wsl'),'windows':sum(1 for x in ss if x['scope']=='windows')}
|
|
print(json.dumps({'ok':True,'command':'unidesk ssh skills','node':{'hostname':socket.gethostname(),'user':getpass.getuser(),'home':str(P.home()),'platform':platform.platform(),'isWsl':isw()},'counts':c,'roots':rr,'skills':ss},ensure_ascii=False,indent=2))`;
|
|
|
|
function remoteFrontendSkillDiscoverCommand(args: string[]): string {
|
|
let scope = "all";
|
|
let limit = 0;
|
|
let maxDepth = 4;
|
|
const start = args[0] === "skill" ? 2 : 1;
|
|
for (let index = start; index < args.length; index += 1) {
|
|
const arg = args[index] ?? "";
|
|
const next = args[index + 1];
|
|
if (arg === "--scope") {
|
|
if (next !== "all" && next !== "wsl" && next !== "windows") throw new Error("ssh skills --scope must be one of: all, wsl, windows");
|
|
scope = next;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--limit") {
|
|
const value = Number(next);
|
|
if (!Number.isInteger(value) || value < 0) throw new Error("ssh skills --limit must be >= 0");
|
|
limit = value;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--max-depth") {
|
|
const value = Number(next);
|
|
if (!Number.isInteger(value) || value <= 0) throw new Error("ssh skills --max-depth must be positive");
|
|
maxDepth = value;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
throw new Error(`remote frontend ssh skills does not support option: ${arg}`);
|
|
}
|
|
return `S=${shellQuote(scope)} L=${shellQuote(String(limit))} D=${shellQuote(String(maxDepth))} python3 -c ${shellQuote(compactSkillDiscoverPython)}`;
|
|
}
|
|
|
|
function dispatchPayload(args: string[], command: DebugDispatchCommand): Record<string, unknown> {
|
|
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<Record<string, unknown>>; ok?: boolean } | null;
|
|
const systemStatuses = (body?.systemStatuses ?? []).map((item) => {
|
|
const current = (item.current ?? {}) as Record<string, unknown>;
|
|
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<Record<string, unknown>>; ok?: boolean } | null;
|
|
const dockerStatuses = (body?.dockerStatuses ?? []).map((item) => {
|
|
const status = (item.dockerStatus ?? {}) as Record<string, unknown>;
|
|
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<unknown> {
|
|
const started = Date.now();
|
|
let latest: unknown = null;
|
|
while (Date.now() - started < timeoutMs) {
|
|
latest = await frontendJson(session, `/api/tasks/${encodeURIComponent(taskId)}`, undefined, 8000, 5_000_000);
|
|
const body = latest as { body?: { task?: { id?: string; status?: string; result?: unknown } } };
|
|
const task = body.body?.task;
|
|
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<unknown> {
|
|
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: "restricted-to-code-queue-provider" },
|
|
oaEventFlowHostPort: { port: 4255, expected: "restricted-to-code-queue-provider" },
|
|
},
|
|
};
|
|
}
|
|
|
|
async function remoteDebugDispatch(session: FrontendSession, config: UniDeskConfig, args: string[]): Promise<unknown> {
|
|
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<unknown> {
|
|
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 };
|
|
}
|
|
|
|
export function summarizeRemoteMicroserviceResponse(action: string, id: string, response: unknown, args: string[]): unknown {
|
|
const optionArgs = args.slice(3);
|
|
if (action === "health") return summarizeMicroserviceHealthResponse(response, optionArgs, id);
|
|
if (action === "status" || action === "diagnostics") return summarizeMicroserviceObservation(action, id, response, optionArgs);
|
|
return response;
|
|
}
|
|
|
|
async function remoteMicroservice(session: FrontendSession, args: string[]): Promise<unknown> {
|
|
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" || action === "diagnostics" || action === "tunnel-self-test") && id !== undefined) {
|
|
const response = await frontendJson(session, `/api/microservices/${encodeURIComponent(id)}/${action}`, undefined, 18_000);
|
|
return {
|
|
transport: "frontend",
|
|
response: summarizeRemoteMicroserviceResponse(action, id, response, args),
|
|
};
|
|
}
|
|
if (action === "proxy" && id !== undefined && path !== undefined && path.startsWith("/")) {
|
|
const full = args.includes("--full");
|
|
const raw = args.includes("--raw");
|
|
const maxBodyBytes = full ? numberOption(args, "--max-body-bytes", 5_000_000) : cappedNumberOption(args, "--max-body-bytes", raw ? 120_000 : 60_000, 500_000);
|
|
const maxResponseBytes = full ? Math.min(Math.max(maxBodyBytes, 120_000), 5_000_000) : Math.min(Math.max(maxBodyBytes * 3, 240_000), 1_500_000);
|
|
const response = await frontendJson(session, `/api/microservices/${encodeURIComponent(id)}/proxy${path}`, undefined, 24_000, maxResponseBytes);
|
|
return {
|
|
transport: "frontend",
|
|
response: summarizeMicroserviceProxyResponse(response, args),
|
|
};
|
|
}
|
|
throw new Error("remote microservice command must be: microservice list | status <id> | health <id> | diagnostics <id> | tunnel-self-test <id> | proxy <id> <path>");
|
|
}
|
|
|
|
function commandResultFromFrontendTask(command: string[], task: { status?: string; result?: Record<string, unknown> } | undefined) {
|
|
const result = task?.result ?? {};
|
|
const stdout = typeof result.stdout === "string" ? result.stdout : "";
|
|
const stderr = typeof result.stderr === "string" ? result.stderr : "";
|
|
return {
|
|
command,
|
|
cwd: ".",
|
|
exitCode: typeof result.exitCode === "number" ? result.exitCode : null,
|
|
stdout,
|
|
stderr,
|
|
signal: null,
|
|
timedOut: result.timedOut === true || (task?.status !== "succeeded" && /timeout|timed out/iu.test(stderr)),
|
|
};
|
|
}
|
|
|
|
async function dispatchHostSshJson(
|
|
session: FrontendSession,
|
|
providerId: string,
|
|
command: string,
|
|
timeoutMs: number,
|
|
waitMs = Math.max(timeoutMs + 5000, 20_000),
|
|
): Promise<{ dispatch: FetchJsonResult; wait: unknown; task?: { status?: string; result?: Record<string, unknown> }; taskId: string | null }> {
|
|
const dispatch = await frontendJson(session, "/api/dispatch", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
providerId,
|
|
command: "host.ssh",
|
|
payload: { source: "cli-remote-artifact-registry", mode: "exec", command, timeoutMs },
|
|
}),
|
|
});
|
|
const taskId = (dispatch as { body?: { taskId?: string } }).body?.taskId ?? "";
|
|
const wait = taskId.length > 0 ? await waitForFrontendTask(session, taskId, waitMs) : null;
|
|
const task = (wait as { task?: { status?: string; result?: Record<string, unknown> } } | null)?.task;
|
|
return { dispatch, wait, task, taskId: taskId.length > 0 ? taskId : null };
|
|
}
|
|
|
|
async function remoteArtifactRegistry(session: FrontendSession, args: string[]): Promise<unknown> {
|
|
const action = args[1] ?? "status";
|
|
if (action !== "status" && action !== "health") {
|
|
throw new Error("remote frontend transport supports artifact-registry status|health only; use main-server SSH for mutating install/deploy commands");
|
|
}
|
|
const options = parseArtifactRegistryOptions(args.slice(2));
|
|
const probe = buildArtifactRegistryReadonlyProbe(action, options);
|
|
const dispatched = await dispatchHostSshJson(session, probe.providerId, probe.script, probe.timeoutMs);
|
|
const command = ["frontend", "/api/dispatch", probe.providerId, "host.ssh", action];
|
|
const result = commandResultFromFrontendTask(command, dispatched.task);
|
|
const registryResult = artifactRegistryReadonlyResultFromCommand(probe, result);
|
|
const dispatchBody = dispatched.dispatch.body as Record<string, unknown> | null | undefined;
|
|
const dispatchError = typeof dispatchBody?.error === "string" ? dispatchBody.error : null;
|
|
return {
|
|
transport: "frontend",
|
|
readonly: true,
|
|
dispatch: dispatched.dispatch,
|
|
wait: dispatched.wait,
|
|
result: dispatched.taskId === null
|
|
? {
|
|
ok: false,
|
|
readonly: true,
|
|
installed: false,
|
|
healthy: false,
|
|
decision: "infra-blocked",
|
|
retryable: true,
|
|
runnerDisposition: "infra-blocked",
|
|
failureClassification: "control-plane-missing",
|
|
recommendedAction: "restore the remote frontend/backend-core dispatch control plane, then rerun artifact-registry status|health",
|
|
healthyScopes: [],
|
|
failedScopes: ["control-plane-missing", "backend-core-api"],
|
|
runtimeApiHealthy: false,
|
|
channels: [
|
|
{ channel: "backend-core-api", ok: false, requiredFor: "frontend /api/dispatch backend-core session creation", detail: dispatched.dispatch },
|
|
{ channel: "provider-dispatch", ok: false, requiredFor: "host.ssh task creation", detail: dispatched.dispatch },
|
|
],
|
|
controlPlane: {
|
|
transport: "remote-frontend",
|
|
failureClassification: "control-plane-missing",
|
|
dispatchStatus: dispatched.dispatch.status ?? null,
|
|
dispatchError,
|
|
},
|
|
registry: registryResult,
|
|
}
|
|
: registryResult,
|
|
};
|
|
}
|
|
|
|
async function remoteCi(session: FrontendSession, config: UniDeskConfig, args: string[]): Promise<unknown> {
|
|
const action = args[1] ?? "status";
|
|
if ((action !== "publish-user-service" && action !== "publish-backend-core") || !args.includes("--dry-run")) {
|
|
throw new Error("remote frontend transport supports only ci publish-user-service --dry-run and ci publish-backend-core --dry-run preflight; real CI publication must run from the controlled main-server CLI after preflight is ready");
|
|
}
|
|
const transport = {
|
|
kind: "remote-frontend" as const,
|
|
remoteHost: session.baseUrl,
|
|
coreFetch: (path: string, init?: { method?: string; body?: unknown; maxResponseBytes?: number }) => frontendJson(session, path, init === undefined ? undefined : {
|
|
method: init.method,
|
|
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
|
}, 12_000, init?.maxResponseBytes ?? 500_000),
|
|
dispatchHostSsh: async (command: string, waitMs: number, remoteTimeoutMs: number) => {
|
|
const dispatched = await dispatchHostSshJson(session, "D601", command, remoteTimeoutMs, waitMs);
|
|
const task = dispatched.task;
|
|
const result = task?.result ?? {};
|
|
return {
|
|
ok: task?.status === "succeeded" && (typeof result.exitCode !== "number" || result.exitCode === 0) && dispatched.taskId !== null,
|
|
taskId: dispatched.taskId,
|
|
status: task?.status ?? null,
|
|
stdout: typeof result.stdout === "string" ? result.stdout : "",
|
|
stderr: typeof result.stderr === "string" ? result.stderr : "",
|
|
exitCode: typeof result.exitCode === "number" ? result.exitCode : null,
|
|
raw: task ?? dispatched.wait ?? dispatched.dispatch,
|
|
};
|
|
},
|
|
commandCwd: ".",
|
|
artifactRegistryCommand: (probe: ReturnType<typeof buildArtifactRegistryReadonlyProbe>) => ["frontend", "/api/dispatch", probe.providerId, "host.ssh", probe.action, dispatchedTaskShape(probe.remoteCommandShape)],
|
|
};
|
|
return {
|
|
transport: "frontend",
|
|
readonly: true,
|
|
result: action === "publish-backend-core"
|
|
? await runCiPublishBackendCoreDryRunPreflight(config, args.slice(1), transport)
|
|
: await runCiPublishUserServiceDryRunPreflight(config, args.slice(1), transport),
|
|
};
|
|
}
|
|
|
|
function dispatchedTaskShape(remoteCommandShape: string): string {
|
|
return remoteCommandShape.length > 0 ? remoteCommandShape : "host.ssh artifact-registry readonly probe";
|
|
}
|
|
|
|
async function remoteCodeQueue(session: FrontendSession, args: string[]): Promise<unknown> {
|
|
const action = args[1] ?? "task";
|
|
if (action !== "task" && action !== "summary" && action !== "show" && action !== "tasks" && action !== "overview" && action !== "queues" && action !== "queue-list" && action !== "output" && action !== "judge" && action !== "pr-preflight" && action !== "runtime-preflight") {
|
|
throw new Error("remote codex command must be: codex task <taskId>, codex tasks, codex queues, codex output <taskId>, codex judge <taskId> --attempt N, or codex pr-preflight [--remote]");
|
|
}
|
|
const taskId = args[2];
|
|
if ((action === "task" || action === "summary" || action === "show" || action === "output" || action === "judge") && (taskId === undefined || taskId.length === 0)) {
|
|
throw new Error(`codex ${action} requires task id`);
|
|
}
|
|
const requiredTaskId = taskId ?? "";
|
|
const fetcher = (path: string, init?: { method?: string; body?: unknown }): Promise<FetchJsonResult> => {
|
|
const requestInit = init === undefined
|
|
? undefined
|
|
: {
|
|
method: init.method,
|
|
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
|
};
|
|
return frontendJson(session, path, requestInit, action === "judge" ? 130_000 : 24_000);
|
|
};
|
|
return {
|
|
transport: "frontend",
|
|
result: action === "tasks" || action === "overview"
|
|
? await codexTasksQueryAsync(args.slice(1), fetcher)
|
|
: action === "queues" || action === "queue-list"
|
|
? await codexQueuesQueryAsync(args.slice(2), fetcher)
|
|
: action === "output"
|
|
? await codexOutputQueryAsync(requiredTaskId, args.slice(3), fetcher)
|
|
: action === "pr-preflight" || action === "runtime-preflight"
|
|
? await codexPrPreflightQueryAsync(args.slice(1), fetcher)
|
|
: action === "judge"
|
|
? await codexJudgeQueryAsync(requiredTaskId, args.slice(3), fetcher)
|
|
: await codexTaskQueryAsync(requiredTaskId, args.slice(3), fetcher),
|
|
};
|
|
}
|
|
|
|
async function remoteNetworkPerf(options: RemoteCliOptions, config: UniDeskConfig, args: string[]): Promise<unknown> {
|
|
if (options.host === null) throw new Error("network perf requires --main-server-ip when using remote frontend transport");
|
|
return {
|
|
transport: "frontend",
|
|
result: await runNetworkPerf(parseNetworkPerfOptions(config, args.slice(2), options.host)),
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
if (args[0] === "glob") {
|
|
process.stderr.write("remote frontend transport does not support the ssh glob helper because host.ssh exec has a short command-length limit; run it on the main server CLI instead\n");
|
|
return 255;
|
|
}
|
|
const remoteCommand = isSshSkillDiscoveryArgs(args) ? remoteFrontendSkillDiscoverCommand(args) : parsed.remoteCommand;
|
|
const dispatch = await frontendJson(session, "/api/dispatch", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
providerId,
|
|
command: "host.ssh",
|
|
payload: { source: "cli-remote-ssh", mode: "exec", command: remoteCommand, timeoutMs: isSshSkillDiscoveryArgs(args) ? 30000 : 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<string, unknown>; 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<number> {
|
|
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 <providerId> <command>", "ssh <providerId> skills", "artifact-registry status|health", "ci publish-user-service --dry-run", "ci publish-backend-core --dry-run", "microservice list", "microservice status <id>", "microservice health <id>", "microservice diagnostics <id>", "microservice tunnel-self-test <id>", "microservice proxy <id> <path>", "decision upload <markdown-file>", "decision list", "decision show <id>", "codex task <taskId>", "codex tasks", "codex queues", "codex judge <taskId> --attempt N", "codex pr-preflight [--remote]", "network perf"],
|
|
});
|
|
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 === "artifact-registry") {
|
|
emitRemoteJson(name, await remoteArtifactRegistry(session, args));
|
|
return 0;
|
|
}
|
|
if (top === "ci") {
|
|
emitRemoteJson(name, await remoteCi(session, config, args));
|
|
return 0;
|
|
}
|
|
if (top === "decision" || top === "decision-center") {
|
|
const fetcher = (path: string, init?: { method?: string; body?: unknown }): Promise<FetchJsonResult> => {
|
|
const requestInit = init === undefined
|
|
? undefined
|
|
: {
|
|
method: init.method,
|
|
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
|
};
|
|
return frontendJson(session, path, requestInit, 30_000);
|
|
};
|
|
const result = await runDecisionCenterCommandAsync(config, args.slice(1), fetcher);
|
|
const ok = typeof result !== "object" || result === null || !("ok" in result) || (result as { ok?: unknown }).ok !== false;
|
|
emitRemoteJson(name, result, ok);
|
|
return ok ? 0 : 1;
|
|
}
|
|
if (top === "codex") {
|
|
emitRemoteJson(name, await remoteCodeQueue(session, args));
|
|
return 0;
|
|
}
|
|
if (top === "network" && sub === "perf") {
|
|
emitRemoteJson(name, await remoteNetworkPerf(options, config, 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<number> {
|
|
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);
|
|
}
|