melbot / src /cli /gateway-cli /register.ts
amos-fernandes's picture
Upload 4501 files
3a65265 verified
import type { Command } from "commander";
import { gatewayStatusCommand } from "../../commands/gateway-status.js";
import { formatHealthChannelLines, type HealthSummary } from "../../commands/health.js";
import { discoverGatewayBeacons } from "../../infra/bonjour-discovery.js";
import type { CostUsageSummary } from "../../infra/session-cost-usage.js";
import { WIDE_AREA_DISCOVERY_DOMAIN } from "../../infra/widearea-dns.js";
import { defaultRuntime } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js";
import { colorize, isRich, theme } from "../../terminal/theme.js";
import { formatTokenCount, formatUsd } from "../../utils/usage-format.js";
import { withProgress } from "../progress.js";
import { runCommandWithRuntime } from "../cli-utils.js";
import {
runDaemonInstall,
runDaemonRestart,
runDaemonStart,
runDaemonStatus,
runDaemonStop,
runDaemonUninstall,
} from "../daemon-cli.js";
import { callGatewayCli, gatewayCallOpts } from "./call.js";
import type { GatewayDiscoverOpts } from "./discover.js";
import {
dedupeBeacons,
parseDiscoverTimeoutMs,
pickBeaconHost,
pickGatewayPort,
renderBeaconLines,
} from "./discover.js";
import { addGatewayRunCommand } from "./run.js";
function styleHealthChannelLine(line: string, rich: boolean): string {
if (!rich) return line;
const colon = line.indexOf(":");
if (colon === -1) return line;
const label = line.slice(0, colon + 1);
const detail = line.slice(colon + 1).trimStart();
const normalized = detail.toLowerCase();
const applyPrefix = (prefix: string, color: (value: string) => string) =>
`${label} ${color(detail.slice(0, prefix.length))}${detail.slice(prefix.length)}`;
if (normalized.startsWith("failed")) return applyPrefix("failed", theme.error);
if (normalized.startsWith("ok")) return applyPrefix("ok", theme.success);
if (normalized.startsWith("linked")) return applyPrefix("linked", theme.success);
if (normalized.startsWith("configured")) return applyPrefix("configured", theme.success);
if (normalized.startsWith("not linked")) return applyPrefix("not linked", theme.warn);
if (normalized.startsWith("not configured")) return applyPrefix("not configured", theme.muted);
if (normalized.startsWith("unknown")) return applyPrefix("unknown", theme.warn);
return line;
}
function runGatewayCommand(action: () => Promise<void>, label?: string) {
return runCommandWithRuntime(defaultRuntime, action, (err) => {
const message = String(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" && raw.trim() !== "") {
const parsed = Number(raw);
if (Number.isFinite(parsed)) return Math.max(1, Math.floor(parsed));
}
return fallback;
}
function renderCostUsageSummary(summary: CostUsageSummary, days: number, rich: boolean): string[] {
const totalCost = formatUsd(summary.totals.totalCost) ?? "$0.00";
const totalTokens = formatTokenCount(summary.totals.totalTokens) ?? "0";
const lines = [
colorize(rich, theme.heading, `Usage cost (${days} days)`),
`${colorize(rich, theme.muted, "Total:")} ${totalCost} · ${totalTokens} tokens`,
];
if (summary.totals.missingCostEntries > 0) {
lines.push(
`${colorize(rich, theme.muted, "Missing entries:")} ${summary.totals.missingCostEntries}`,
);
}
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;
}
export function registerGatewayCli(program: Command) {
const gateway = addGatewayRunCommand(
program
.command("gateway")
.description("Run the WebSocket Gateway")
.addHelpText(
"after",
() =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/gateway", "docs.molt.bot/cli/gateway")}\n`,
),
);
addGatewayRunCommand(
gateway.command("run").description("Run the WebSocket Gateway (foreground)"),
);
gateway
.command("status")
.description("Show gateway service status + probe the Gateway")
.option("--url <url>", "Gateway WebSocket URL (defaults to config/remote/local)")
.option("--token <token>", "Gateway token (if required)")
.option("--password <password>", "Gateway password (password auth)")
.option("--timeout <ms>", "Timeout in ms", "10000")
.option("--no-probe", "Skip RPC probe")
.option("--deep", "Scan system-level services", false)
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonStatus({
rpc: opts,
probe: Boolean(opts.probe),
deep: Boolean(opts.deep),
json: Boolean(opts.json),
});
});
gateway
.command("install")
.description("Install the Gateway service (launchd/systemd/schtasks)")
.option("--port <port>", "Gateway port")
.option("--runtime <runtime>", "Daemon runtime (node|bun). Default: node")
.option("--token <token>", "Gateway token (token auth)")
.option("--force", "Reinstall/overwrite if already installed", false)
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonInstall(opts);
});
gateway
.command("uninstall")
.description("Uninstall the Gateway service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonUninstall(opts);
});
gateway
.command("start")
.description("Start the Gateway service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonStart(opts);
});
gateway
.command("stop")
.description("Stop the Gateway service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonStop(opts);
});
gateway
.command("restart")
.description("Restart the Gateway service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonRestart(opts);
});
gatewayCallOpts(
gateway
.command("call")
.description("Call a Gateway method")
.argument("<method>", "Method name (health/status/system-presence/cron.*)")
.option("--params <json>", "JSON object string for params", "{}")
.action(async (method, opts) => {
await runGatewayCommand(async () => {
const params = JSON.parse(String(opts.params ?? "{}"));
const result = await callGatewayCli(method, opts, params);
if (opts.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
const rich = isRich();
defaultRuntime.log(
`${colorize(rich, theme.heading, "Gateway call")}: ${colorize(rich, theme.muted, String(method))}`,
);
defaultRuntime.log(JSON.stringify(result, null, 2));
}, "Gateway call failed");
}),
);
gatewayCallOpts(
gateway
.command("usage-cost")
.description("Fetch usage cost summary from session logs")
.option("--days <days>", "Number of days to include", "30")
.action(async (opts) => {
await runGatewayCommand(async () => {
const days = parseDaysOption(opts.days);
const result = await callGatewayCli("usage.cost", opts, { days });
if (opts.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
const rich = isRich();
const summary = result as CostUsageSummary;
for (const line of renderCostUsageSummary(summary, days, rich)) {
defaultRuntime.log(line);
}
}, "Gateway usage cost failed");
}),
);
gatewayCallOpts(
gateway
.command("health")
.description("Fetch Gateway health")
.action(async (opts) => {
await runGatewayCommand(async () => {
const result = await callGatewayCli("health", opts);
if (opts.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
const rich = isRich();
const obj =
result && typeof result === "object" ? (result as Record<string, unknown>) : {};
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));
}
}
});
}),
);
gateway
.command("probe")
.description("Show gateway reachability + discovery + health + status summary (local + remote)")
.option("--url <url>", "Explicit Gateway WebSocket URL (still probes localhost)")
.option("--ssh <target>", "SSH target for remote gateway tunnel (user@host or user@host:port)")
.option("--ssh-identity <path>", "SSH identity file path")
.option("--ssh-auto", "Try to derive an SSH target from Bonjour discovery", false)
.option("--token <token>", "Gateway token (applies to all probes)")
.option("--password <password>", "Gateway password (applies to all probes)")
.option("--timeout <ms>", "Overall probe budget in ms", "3000")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runGatewayCommand(async () => {
await gatewayStatusCommand(opts, defaultRuntime);
});
});
gateway
.command("discover")
.description(
`Discover gateways via Bonjour (multicast local. + unicast ${WIDE_AREA_DISCOVERY_DOMAIN})`,
)
.option("--timeout <ms>", "Per-command timeout in ms", "2000")
.option("--json", "Output JSON", false)
.action(async (opts: GatewayDiscoverOpts) => {
await runGatewayCommand(async () => {
const timeoutMs = parseDiscoverTimeoutMs(opts.timeout, 2000);
const beacons = await withProgress(
{
label: "Scanning for gateways…",
indeterminate: true,
enabled: opts.json !== true,
delayMs: 0,
},
async () => await discoverGatewayBeacons({ timeoutMs }),
);
const deduped = dedupeBeacons(beacons).sort((a, b) =>
String(a.displayName || a.instanceName).localeCompare(
String(b.displayName || b.instanceName),
),
);
if (opts.json) {
const enriched = deduped.map((b) => {
const host = pickBeaconHost(b);
const port = pickGatewayPort(b);
return { ...b, wsUrl: host ? `ws://${host}:${port}` : null };
});
defaultRuntime.log(
JSON.stringify(
{
timeoutMs,
domains: ["local.", WIDE_AREA_DISCOVERY_DOMAIN],
count: enriched.length,
beacons: enriched,
},
null,
2,
),
);
return;
}
const rich = isRich();
defaultRuntime.log(colorize(rich, theme.heading, "Gateway Discovery"));
defaultRuntime.log(
colorize(
rich,
theme.muted,
`Found ${deduped.length} gateway(s) · domains: local., ${WIDE_AREA_DISCOVERY_DOMAIN}`,
),
);
if (deduped.length === 0) return;
for (const beacon of deduped) {
for (const line of renderBeaconLines(beacon, rich)) {
defaultRuntime.log(line);
}
}
}, "gateway discover failed");
});
}