// Commander registration for gateway status, health, diagnostics, discovery, and run commands. import { formatByteSize } from "@openclaw/normalization-core"; import { parseStrictPositiveInteger } from "@openclaw/normalization-core/number-coercion"; import type { Command } from "commander"; import { formatDocsLink } from "../../../packages/terminal-core/src/links.js"; import { colorize, isRich, theme } from "../../../packages/terminal-core/src/theme.js"; import type { HealthSummary } from "../../commands/health.js"; import { formatErrorMessage } from "../../infra/errors.js"; import type { CostUsageSummary } from "../../infra/session-cost-usage.js"; import type { DiagnosticStabilityBundle, ReadDiagnosticStabilityBundleResult, } from "../../logging/diagnostic-stability-bundle.js"; import type { DiagnosticStabilityEventRecord, DiagnosticStabilitySnapshot, } from "../../logging/diagnostic-stability.js"; import type { WriteDiagnosticSupportExportResult } from "../../logging/diagnostic-support-export.js"; import { defaultRuntime } from "../../runtime.js"; import { createLazyPromise } from "../../shared/lazy-promise.js"; import { inheritOptionFromParent } from "../command-options.js"; import { addGatewayServiceCommands } from "../daemon-cli/register-service-commands.js"; import { formatCliJsonFailure, rethrowExpectedCliError } from "../failure-output.js"; import { addGatewayClientOptions, callGatewayFromCliWithTransport, resolveGatewayRpcOptions, resolveGatewayRpcOptionsWithLocalPort, } from "../gateway-rpc.js"; import { formatHelpExamples } from "../help-format.js"; import { parseTimeoutMsWithFallback } from "../parse-timeout.js"; import { setCommandJsonMode } from "../program/json-mode.js"; import type { GatewayDiscoverOpts } from "./discover.js"; import { isGatewayMachineOutput } from "./output-mode.js"; import { addGatewayRestartHandoffCommands } from "./register-restart-handoff.js"; import { addGatewayRunCommand } from "./run-command.js"; import { runGatewayResume, runGatewaySuspend } from "./suspend-cli.js"; type GatewayRpcOpts = Parameters[1]; const loadConfigModule = createLazyPromise( () => import("../../config/read-best-effort-config.runtime.js"), ); const loadGatewayStatusModule = createLazyPromise(() => import("../../commands/gateway-status.js")); const loadGatewayHealthModule = createLazyPromise(() => import("../../commands/health.js")); const loadBonjourDiscoveryModule = createLazyPromise( () => import("../../infra/bonjour-discovery.js"), ); const loadWideAreaDnsModule = createLazyPromise(() => import("../../infra/widearea-dns.js")); const loadHealthStyleModule = createLazyPromise( () => import("../../../packages/terminal-core/src/health-style.js"), ); const loadUsageFormatModule = createLazyPromise(() => import("../../utils/usage-format.js")); const loadStabilityBundleModule = createLazyPromise( () => import("../../logging/diagnostic-stability-bundle.js"), ); const loadSupportExportModule = createLazyPromise( () => import("../../logging/diagnostic-support-export.js"), ); const loadDaemonStatusGatherModule = createLazyPromise( () => import("../daemon-cli/status.gather.js"), ); const DEFAULT_GATEWAY_RPC_TIMEOUT_MS = 10_000; const SETUP_INFERENCE_DETECT_RPC_TIMEOUT_MS = 40_000; type GatewayCliDependencies = { loadGatewayHealthModule?: typeof loadGatewayHealthModule; loadHealthStyleModule?: typeof loadHealthStyleModule; }; function gatewayCallOpts(cmd: Command, defaultTimeoutMs = DEFAULT_GATEWAY_RPC_TIMEOUT_MS): Command { return addGatewayClientOptions(cmd, { timeoutMs: defaultTimeoutMs }).option( "--json", "Output JSON", false, ); } async function callGatewayReadOnlyCli(method: string, opts: GatewayRpcOpts, params?: unknown) { return await callGatewayFromCliWithTransport(method, opts, params, { defaultTimeoutMs: DEFAULT_GATEWAY_RPC_TIMEOUT_MS, sharedStateMode: "read-only", }); } function parseGatewayCallParams(value = "{}"): unknown { try { return JSON.parse(value) as unknown; } catch { throw new Error("--params must be valid JSON."); } } async function runGatewayCommand( action: () => Promise, label?: string, opts?: { json?: boolean }, ) { // JSON mode preserves structured gateway errors for automation callers. try { await action(); } catch (err) { if (!opts?.json) { rethrowExpectedCliError(err); } if (opts?.json) { const { formatGatewayAuthErrorJson, formatGatewayClientRequestErrorJson, formatGatewayTransportErrorJson, } = await import("../../gateway/call.js"); defaultRuntime.writeJson( formatGatewayAuthErrorJson(err) ?? formatGatewayClientRequestErrorJson(err) ?? formatGatewayTransportErrorJson(err) ?? formatCliJsonFailure(err), ); defaultRuntime.exit(1); return; } const message = formatErrorMessage(err); defaultRuntime.error(label ? `${label}: ${message}` : message); defaultRuntime.exit(1); } } function parseDaysOption(raw: unknown, fallback = 30): number { if (typeof raw === "number" && Number.isFinite(raw)) { return Math.max(1, Math.floor(raw)); } if (typeof raw === "string") { const parsed = parseStrictPositiveInteger(raw); if (parsed !== undefined) { return parsed; } // A present-but-unparseable value (including an explicit empty one) is // operator error; the main RPC path rejects malformed --timeout the same // way instead of silently defaulting. throw new Error(`Invalid --days. Use a positive integer, e.g. --days 30. Received: "${raw}".`); } return fallback; } async function renderCostUsageSummaryAsync( summary: CostUsageSummary, days: number, rich: boolean, ): Promise { const { formatMissingCostEntries } = await import("../../infra/session-cost-usage-totals.js"); const { formatCostUsageCachePrefix, formatTokenCount, formatUsd } = await loadUsageFormatModule(); const totalCost = formatUsd(summary.totals.totalCost) ?? "$0.00"; const totalTokens = formatTokenCount(summary.totals.totalTokens) ?? "0"; const cachePrefix = formatCostUsageCachePrefix(summary.cacheStatus); const lines = [ colorize(rich, theme.heading, `Usage cost (${days} days)`), `${cachePrefix}${colorize(rich, theme.muted, "Total:")} ${totalCost} · ${totalTokens} tokens`, ]; if (summary.totals.missingCostEntries > 0) { lines.push( `${colorize(rich, theme.muted, "Missing cost:")} ${formatMissingCostEntries(summary.totals)}`, ); } const latest = summary.daily.at(-1); if (latest) { const latestCost = formatUsd(latest.totalCost) ?? "$0.00"; const latestTokens = formatTokenCount(latest.totalTokens) ?? "0"; lines.push( `${colorize(rich, theme.muted, "Latest day:")} ${latest.date} · ${latestCost} · ${latestTokens} tokens`, ); } return lines; } function formatBytes(value: number | undefined): string { if (value === undefined) { return "n/a"; } return formatByteSize(value, { style: "iec", maxUnit: "giga", separator: " ", fractionDigits: (amount, unit) => (unit === "byte" || amount >= 100 ? 0 : 1), }); } function formatStabilityEvent(record: DiagnosticStabilityEventRecord): string { const parts = [ new Date(record.ts).toISOString(), `#${record.seq}`, record.type, record.level ? `level=${record.level}` : "", record.action ? `action=${record.action}` : "", record.outcome ? `outcome=${record.outcome}` : "", record.surface ? `surface=${record.surface}` : "", record.channel ? `channel=${record.channel}` : "", record.pluginId ? `plugin=${record.pluginId}` : "", record.reason ? `reason=${record.reason}` : "", record.bytes !== undefined ? `bytes=${formatBytes(record.bytes)}` : "", record.limitBytes !== undefined ? `limit=${formatBytes(record.limitBytes)}` : "", record.queueDepth !== undefined ? `queueDepth=${record.queueDepth}` : "", record.queueLength !== undefined ? `queueLength=${record.queueLength}` : "", record.droppedEvents !== undefined ? `dropped=${record.droppedEvents}` : "", record.maxQueueLength !== undefined ? `maxQueue=${record.maxQueueLength}` : "", record.queued !== undefined ? `queued=${record.queued}` : "", record.memory ? `rss=${formatBytes(record.memory.rssBytes)}` : "", record.memory ? `heap=${formatBytes(record.memory.heapUsedBytes)}` : "", ].filter(Boolean); return parts.join(" "); } function renderStabilitySummary(snapshot: DiagnosticStabilitySnapshot, rich: boolean): string[] { const lines = [ colorize(rich, theme.heading, "Gateway Stability"), `${colorize(rich, theme.muted, "Events:")} ${snapshot.count}/${snapshot.capacity}${ snapshot.dropped > 0 ? ` · dropped=${snapshot.dropped}` : "" }`, ]; const topTypes = Object.entries(snapshot.summary.byType) .toSorted((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) .slice(0, 8) .map(([type, count]) => `${type}=${count}`) .join(", "); if (topTypes) { lines.push(`${colorize(rich, theme.muted, "Types:")} ${topTypes}`); } const memory = snapshot.summary.memory; if (memory) { lines.push( `${colorize(rich, theme.muted, "Memory:")} rss=${formatBytes( memory.latest?.rssBytes, )} heap=${formatBytes(memory.latest?.heapUsedBytes)} maxRss=${formatBytes( memory.maxRssBytes, )} pressure=${memory.pressureCount}`, ); } const payloadLarge = snapshot.summary.payloadLarge; if (payloadLarge) { const surfaces = Object.entries(payloadLarge.bySurface) .toSorted((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) .map(([surface, count]) => `${surface}=${count}`) .join(", "); lines.push( `${colorize(rich, theme.muted, "Large payloads:")} total=${payloadLarge.count} rejected=${ payloadLarge.rejected } truncated=${payloadLarge.truncated} chunked=${payloadLarge.chunked}${ surfaces ? ` · ${surfaces}` : "" }`, ); } if (snapshot.events.length > 0) { lines.push(colorize(rich, theme.muted, "Recent:")); for (const event of snapshot.events) { lines.push(` ${formatStabilityEvent(event)}`); } } return lines; } function normalizeStabilityBundleTarget(raw: unknown): string | null { if (raw === undefined || raw === false) { return null; } if (raw === true) { return "latest"; } if (typeof raw !== "string") { return "latest"; } const value = raw.trim(); return value === "" ? "latest" : value; } function formatBundleError(result: ReadDiagnosticStabilityBundleResult): string { if (result.status === "missing") { return `No stability bundles found in ${result.dir}`; } if (result.status === "failed") { return result.error instanceof Error ? result.error.message : String(result.error); } return "Unexpected stability bundle read result"; } async function readStabilityBundleTarget( bundleTarget: string, ): Promise { const { readDiagnosticStabilityBundleFileSync, readLatestDiagnosticStabilityBundleSync } = await loadStabilityBundleModule(); return bundleTarget === "latest" ? readLatestDiagnosticStabilityBundleSync() : readDiagnosticStabilityBundleFileSync(bundleTarget); } function renderStabilityBundleSummary(params: { bundle: DiagnosticStabilityBundle; path: string; snapshot: DiagnosticStabilitySnapshot; rich: boolean; }): string[] { const { bundle, path, rich, snapshot } = params; const processDetails = [ `pid=${bundle.process.pid}`, `node=${bundle.process.node}`, `${bundle.process.platform}/${bundle.process.arch}`, `uptime=${Math.round(bundle.process.uptimeMs / 1000)}s`, ].join(" "); const lines = [ colorize(rich, theme.heading, "Stability bundle"), `${colorize(rich, theme.muted, "Path:")} ${path}`, `${colorize(rich, theme.muted, "Generated:")} ${bundle.generatedAt}`, `${colorize(rich, theme.muted, "Reason:")} ${bundle.reason}`, `${colorize(rich, theme.muted, "Process:")} ${processDetails}`, `${colorize(rich, theme.muted, "Host:")} ${bundle.host.hostname}`, ]; if (bundle.error) { const errorParts = [ bundle.error.name ? `name=${bundle.error.name}` : "", bundle.error.code ? `code=${bundle.error.code}` : "", ].filter(Boolean); if (errorParts.length > 0) { lines.push(`${colorize(rich, theme.muted, "Error:")} ${errorParts.join(" ")}`); } } const memoryPressure = bundle.evidence?.memoryPressure; if (memoryPressure) { lines.push( `${colorize(rich, theme.muted, "Memory pressure:")} ${memoryPressure.level}/${ memoryPressure.reason } rss=${formatBytes(memoryPressure.memory.rssBytes)} heap=${formatBytes( memoryPressure.memory.heapUsedBytes, )} threshold=${formatBytes(memoryPressure.thresholdBytes)}`, ); if (memoryPressure.heapStatistics) { lines.push( `${colorize(rich, theme.muted, "V8 heap:")} used=${formatBytes( memoryPressure.heapStatistics.usedHeapSizeBytes, )} limit=${formatBytes( memoryPressure.heapStatistics.heapSizeLimitBytes, )} available=${formatBytes(memoryPressure.heapStatistics.totalAvailableSizeBytes)}`, ); } if (memoryPressure.activeResources) { const resources = Object.entries(memoryPressure.activeResources.byType) .map(([type, count]) => `${type}=${count}`) .join(", "); lines.push( `${colorize(rich, theme.muted, "Active resources:")} total=${ memoryPressure.activeResources.total }${resources ? ` · ${resources}` : ""}`, ); } if (memoryPressure.topSessionFiles?.length) { const files = memoryPressure.topSessionFiles .slice(0, 5) .map((file) => `${file.relativePath}=${formatBytes(file.sizeBytes)}`) .join(", "); lines.push(`${colorize(rich, theme.muted, "Largest session files:")} ${files}`); } } lines.push("", ...renderStabilitySummary(snapshot, rich)); return lines; } function renderSupportExportResult( result: WriteDiagnosticSupportExportResult, rich: boolean, ): string[] { return [ colorize(rich, theme.heading, "Diagnostics export"), `${colorize(rich, theme.muted, "Path:")} ${result.path}`, `${colorize(rich, theme.muted, "Size:")} ${formatBytes(result.bytes)}`, `${colorize(rich, theme.muted, "Files:")} ${result.manifest.contents.length}`, `${colorize(rich, theme.muted, "Privacy:")} payload-free stability, sanitized logs/status/health/config`, ]; } function resolveSupportExportRpcOptions( rpc?: Pick, ): GatewayRpcOpts & { timeout: string } { return { url: rpc?.url, token: rpc?.token, password: rpc?.password, timeout: rpc?.timeout ?? "3000", json: true, }; } function parseOptionalPositiveIntegerOption(raw: unknown, label: string): number | undefined { if (raw === undefined) { return undefined; } const parsed = parseStrictPositiveInteger(raw); if (parsed === undefined) { throw new Error(`${label} must be a positive integer.`); } return parsed; } async function writeSupportExportFromCli(opts: { json?: boolean; output?: string; logLines?: string; logBytes?: string; stabilityBundle?: string | false; rpc?: Pick; }): Promise { const { writeDiagnosticSupportExport } = await loadSupportExportModule(); const rpc = resolveSupportExportRpcOptions(opts.rpc); const result = await writeDiagnosticSupportExport({ outputPath: opts.output, logLimit: parseOptionalPositiveIntegerOption(opts.logLines, "--log-lines"), logMaxBytes: parseOptionalPositiveIntegerOption(opts.logBytes, "--log-bytes"), stabilityBundle: opts.stabilityBundle, readStatusSnapshot: async () => { const { gatherDaemonStatus } = await loadDaemonStatusGatherModule(); return await gatherDaemonStatus({ rpc, probe: true, requireRpc: false, deep: false, }); }, readHealthSnapshot: async () => await callGatewayReadOnlyCli("health", rpc), }); if (opts.json) { defaultRuntime.writeJson(result); return; } const rich = isRich(); for (const line of renderSupportExportResult(result, rich)) { defaultRuntime.log(line); } } export function registerGatewayCli(program: Command, deps: GatewayCliDependencies = {}) { const gateway = addGatewayRunCommand( program .command("gateway") .description("Run, inspect, and query the WebSocket Gateway") .addHelpText( "after", () => `\n${theme.heading("Examples:")}\n${formatHelpExamples([ ["openclaw gateway run", "Run the gateway in the foreground."], ["openclaw gateway status", "Show service status plus connectivity/capability."], ["openclaw gateway auth-token --show", "Reveal the shared token interactively."], ["openclaw gateway discover", "Find local and wide-area gateway beacons."], ["openclaw gateway stability", "Show recent stability diagnostics."], ["openclaw gateway call health", "Call a gateway RPC method directly."], ])}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/gateway", "docs.openclaw.ai/cli/gateway")}\n`, ), ); addGatewayRunCommand( gateway.command("run").description("Run the WebSocket Gateway (foreground)"), ); addGatewayServiceCommands(gateway, { statusDescription: "Show gateway service status + probe connectivity/capability", }); addGatewayRestartHandoffCommands(gateway); setCommandJsonMode(gateway, "output", ({ argv }) => isGatewayMachineOutput(argv)); gateway .command("auth-token") .description("Reveal the configured shared Gateway token") .option("--show", "Print the token to an interactive terminal", false) .action(async (opts) => { await runGatewayCommand(async () => { if (!opts.show) { throw new Error( "Pass --show to confirm that you want to print the Gateway token to this terminal.", ); } const { gatewayAuthTokenCommand } = await import("../../commands/gateway-auth-token.js"); await gatewayAuthTokenCommand(defaultRuntime); }, "Gateway auth token failed"); }); gatewayCallOpts( gateway .command("call") .description("Call a Gateway method") .argument("", "Method name (health/status/system-presence/cron.*)") .option( "--expect-url ", "Fail if the resolved Gateway URL differs; preserves configured authentication", ) .option("--params ", "JSON object string for params", "{}") .action(async (method, opts, command) => { await runGatewayCommand( async () => { // Setup detection owns a 30s worker deadline; its transport must // leave enough grace for the Gateway to return the typed outcome. const callOpts = method === "openclaw.setup.detect" && command.getOptionValueSource("timeout") === "default" ? { ...opts, timeout: String(SETUP_INFERENCE_DETECT_RPC_TIMEOUT_MS) } : opts; const rpcOpts = resolveGatewayRpcOptionsWithLocalPort(callOpts, command); const params = parseGatewayCallParams(String(opts.params ?? "{}")); const result = await callGatewayReadOnlyCli(method, rpcOpts, params); if (rpcOpts.json) { defaultRuntime.writeJson(result); return; } const rich = isRich(); defaultRuntime.log( `${colorize(rich, theme.heading, "Gateway call")}: ${colorize(rich, theme.muted, String(method))}`, ); defaultRuntime.writeJson(result); }, "Gateway call failed", { json: Boolean(opts.json) }, ); }), ); gatewayCallOpts( gateway .command("suspend") .description("Prepare the Gateway for cooperative host suspension") .option("--request-id ", "Stable suspension request id") .option("--wait ", "Wait up to this many seconds for active work to drain") .action(async (opts, command) => { await runGatewayCommand( async () => { const rpcOpts = resolveGatewayRpcOptionsWithLocalPort(opts, command); await runGatewaySuspend( { rpcOpts, requestId: opts.requestId, waitSeconds: opts.wait, json: Boolean(rpcOpts.json), }, { callGateway: callGatewayReadOnlyCli, runtime: defaultRuntime }, ); }, "Gateway suspend failed", { json: Boolean(opts.json) }, ); }), ); gatewayCallOpts( gateway .command("resume") .description("Release a cooperative Gateway suspension") .argument("", "Suspension id returned by gateway suspend") .action(async (suspensionId, opts, command) => { await runGatewayCommand( async () => { const rpcOpts = resolveGatewayRpcOptionsWithLocalPort(opts, command); await runGatewayResume( { rpcOpts, suspensionId: String(suspensionId), json: Boolean(rpcOpts.json) }, { callGateway: callGatewayReadOnlyCli, runtime: defaultRuntime }, ); }, "Gateway resume failed", { json: Boolean(opts.json) }, ); }), ); gatewayCallOpts( gateway .command("usage-cost") .description("Fetch usage cost summary from session logs") .option("--days ", "Number of days to include", "30") .option("--agent ", "Scope the cost summary to a specific agent id") .option("--all-agents", "Aggregate the cost summary across all agents", false) .action(async (opts, command) => { await runGatewayCommand( async () => { const rpcOpts = resolveGatewayRpcOptionsWithLocalPort(opts, command); const days = parseDaysOption(opts.days); const agentId = typeof opts.agent === "string" ? opts.agent.trim() : undefined; // The gateway honors agentScope only when no agentId is set, so reject the // ambiguous combination here instead of silently dropping --all-agents. if (agentId && opts.allAgents) { throw new Error("Use --agent or --all-agents, not both"); } const summary = (await callGatewayReadOnlyCli("usage.cost", rpcOpts, { days, ...(agentId ? { agentId } : {}), ...(opts.allAgents ? { agentScope: "all" } : {}), })) as CostUsageSummary; if (rpcOpts.json) { defaultRuntime.writeJson(summary); return; } const rich = isRich(); for (const line of await renderCostUsageSummaryAsync(summary, days, rich)) { defaultRuntime.log(line); } }, "Gateway usage cost failed", { json: Boolean(opts.json) }, ); }), ); gatewayCallOpts( gateway .command("health") .description("Fetch Gateway health") .action(async (opts, command) => { await runGatewayCommand( async () => { const rpcOpts = resolveGatewayRpcOptionsWithLocalPort(opts, command); let result: unknown; try { result = await callGatewayReadOnlyCli("health", rpcOpts); } catch (error) { const { emitReachableGatewayAuthDiagnostic, readNonObservingHealthConfig } = await ( deps.loadGatewayHealthModule ?? loadGatewayHealthModule )(); const handled = await emitReachableGatewayAuthDiagnostic({ error, config: rpcOpts.config ?? (await readNonObservingHealthConfig()), runtime: defaultRuntime, timeoutMs: parseTimeoutMsWithFallback(rpcOpts.timeout, 10_000, { invalidType: "error", }), token: rpcOpts.token, password: rpcOpts.password, localPortOverride: rpcOpts.localPortOverride, json: Boolean(rpcOpts.json), }); if (handled) { return; } throw error; } if (rpcOpts.json) { defaultRuntime.writeJson(result); return; } const [{ formatHealthChannelLines }, { styleHealthChannelLine }] = await Promise.all([ (deps.loadGatewayHealthModule ?? loadGatewayHealthModule)(), (deps.loadHealthStyleModule ?? loadHealthStyleModule)(), ]); const rich = isRich(); const obj: Record = result && typeof result === "object" ? (result as Record) : {}; const durationMs = typeof obj.durationMs === "number" ? obj.durationMs : null; defaultRuntime.log(colorize(rich, theme.heading, "Gateway Health")); defaultRuntime.log( `${colorize(rich, theme.success, "OK")}${durationMs != null ? ` (${durationMs}ms)` : ""}`, ); if (obj.channels && typeof obj.channels === "object") { for (const line of formatHealthChannelLines(obj as HealthSummary)) { defaultRuntime.log(styleHealthChannelLine(line, rich)); } } }, undefined, { json: Boolean(opts.json) }, ); }), ); gatewayCallOpts( gateway .command("stability") .description("Fetch payload-free Gateway stability diagnostics") .option("--limit ", "Maximum number of recent events", "25") .option("--type ", "Filter by diagnostic event type") .option("--since-seq ", "Only include events after this sequence") .option( "--bundle [path]", 'Read a persisted stability bundle instead of calling Gateway; pass "latest" for newest', ) .option("--export", "Write a shareable support diagnostics export", false) .option("--output ", "Diagnostics export output .zip path") .action(async (opts, command) => { await runGatewayCommand( async () => { const { normalizeDiagnosticStabilityQuery, selectDiagnosticStabilitySnapshot } = await import("../../logging/diagnostic-stability.js"); const rpcOpts = resolveGatewayRpcOptions(opts, command); const query = normalizeDiagnosticStabilityQuery( { limit: opts.limit, sinceSeq: opts.sinceSeq, type: opts.type, }, { defaultLimit: 25 }, ); const bundleTarget = normalizeStabilityBundleTarget(opts.bundle); if (opts.export) { await writeSupportExportFromCli({ json: rpcOpts.json, output: opts.output, stabilityBundle: bundleTarget ?? "latest", rpc: rpcOpts, }); return; } if (bundleTarget) { const result = await readStabilityBundleTarget(bundleTarget); if (result.status !== "found") { throw new Error(formatBundleError(result)); } const snapshot = selectDiagnosticStabilitySnapshot(result.bundle.snapshot, query); if (rpcOpts.json) { defaultRuntime.writeJson({ path: result.path, mtimeMs: result.mtimeMs, bundle: { ...result.bundle, snapshot, }, }); return; } const rich = isRich(); for (const line of renderStabilityBundleSummary({ bundle: result.bundle, path: result.path, rich, snapshot, })) { defaultRuntime.log(line); } return; } const result = await callGatewayReadOnlyCli( "diagnostics.stability", resolveGatewayRpcOptionsWithLocalPort(rpcOpts, command), { limit: query.limit, ...(query.type ? { type: query.type } : {}), ...(query.sinceSeq !== undefined ? { sinceSeq: query.sinceSeq } : {}), }, ); if (rpcOpts.json) { defaultRuntime.writeJson(result); return; } const rich = isRich(); for (const line of renderStabilitySummary( result as DiagnosticStabilitySnapshot, rich, )) { defaultRuntime.log(line); } }, "Gateway stability failed", { json: Boolean(opts.json) }, ); }), ); const diagnostics = gateway .command("diagnostics") .description("Export local support diagnostics"); diagnostics .command("export") .description("Write a shareable, payload-free diagnostics .zip") .option("--output ", "Output .zip path") .option("--log-lines ", "Maximum sanitized log lines to include", "5000") .option("--log-bytes ", "Maximum log bytes to inspect", "1000000") .option("--url ", "Gateway WebSocket URL for health snapshot") .option("--token ", "Gateway token for health snapshot") .option("--password ", "Gateway password for health snapshot") .option("--timeout ", "Status/health snapshot timeout in ms", "3000") .option("--no-stability-bundle", "Skip persisted stability bundle lookup") .option("--json", "Output JSON", false) .action(async (opts, command) => { await runGatewayCommand( async () => { const rpcOpts = resolveGatewayRpcOptions(opts, command); await writeSupportExportFromCli({ json: opts.json, output: opts.output, logLines: opts.logLines, logBytes: opts.logBytes, stabilityBundle: opts.stabilityBundle === false ? false : "latest", rpc: rpcOpts, }); }, "Gateway diagnostics export failed", { json: Boolean(opts.json) }, ); }); gateway .command("probe") .description( "Show gateway reachability, auth capability, and read-probe summary (local + remote)", ) .option("--url ", "Explicit Gateway WebSocket URL (still probes localhost)") .option("--port ", "Local Gateway port") .option("--ssh ", "SSH target for remote gateway tunnel (user@host or user@host:port)") .option("--ssh-identity ", "SSH identity file path") .option("--ssh-auto", "Try to derive an SSH target from Bonjour discovery", false) .option("--token ", "Gateway token (applies to all probes)") .option("--password ", "Gateway password (applies to all probes)") .option("--timeout ", "Overall probe budget in ms", "3000") .option("--json", "Output JSON", false) .action(async (opts, command) => { await runGatewayCommand( async () => { const rpcOpts = resolveGatewayRpcOptions(opts, command); const { gatewayStatusCommand } = await loadGatewayStatusModule(); await gatewayStatusCommand( { ...rpcOpts, port: opts.port ?? inheritOptionFromParent(command, "port"), }, defaultRuntime, ); }, undefined, { json: Boolean(opts.json) }, ); }); gateway .command("discover") .description("Discover gateways via Bonjour (local + wide-area if configured)") .option("--timeout ", "Per-command timeout in ms", "2000") .option("--json", "Output JSON", false) .action(async (opts: GatewayDiscoverOpts) => { await runGatewayCommand( async () => { const [ { readSourceConfigBestEffort }, { discoverGatewayBeacons, resolveGatewayDiscoveryEndpoint }, { resolveWideAreaDiscoveryDomain }, { dedupeBeacons, parseDiscoverTimeoutMs, renderBeaconLines }, { withProgress }, ] = await Promise.all([ loadConfigModule(), loadBonjourDiscoveryModule(), loadWideAreaDnsModule(), import("./discover.js"), import("../progress.js"), ]); const cfg = await readSourceConfigBestEffort(); const wideAreaDomain = resolveWideAreaDiscoveryDomain({ configDomain: cfg.discovery?.wideArea?.domain, }); const timeoutMs = parseDiscoverTimeoutMs(opts.timeout, 2000); const domains = ["local.", ...(wideAreaDomain ? [wideAreaDomain] : [])]; const beacons = await withProgress( { label: "Scanning for gateways…", indeterminate: true, enabled: opts.json !== true, delayMs: 0, }, async () => await discoverGatewayBeacons({ timeoutMs, wideAreaDomain }), ); const deduped = dedupeBeacons(beacons).toSorted((a, b) => (a.displayName || a.instanceName).localeCompare(b.displayName || b.instanceName), ); if (opts.json) { const enriched = deduped.map((beacon) => ({ ...beacon, wsUrl: resolveGatewayDiscoveryEndpoint(beacon)?.wsUrl ?? null, })); defaultRuntime.writeJson({ timeoutMs, domains, count: enriched.length, beacons: enriched, }); return; } const rich = isRich(); defaultRuntime.log(colorize(rich, theme.heading, "Gateway Discovery")); defaultRuntime.log( colorize( rich, theme.muted, `Found ${deduped.length} gateway(s) · domains: ${domains.join(", ")}`, ), ); if (deduped.length === 0) { return; } for (const beacon of deduped) { for (const line of renderBeaconLines(beacon, rich)) { defaultRuntime.log(line); } } }, "gateway discover failed", { json: Boolean(opts.json) }, ); }); } /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */