feat(cli): improve gateway status output

This commit is contained in:
Peter Steinberger
2026-01-09 09:27:27 +01:00
parent 89132fdd25
commit 5b50c97939
12 changed files with 917 additions and 31 deletions

View File

@@ -78,6 +78,8 @@
- Agent: bypass Anthropic OAuth tool-name blocks by capitalizing built-ins and keeping pruning tool matching case-insensitive. (#553) — thanks @andrewting19
- Commands/Tools: disable /restart and gateway restart tool by default (enable with commands.restart=true).
- Gateway/CLI: add `clawdbot gateway discover` (Bonjour scan on `local.` + `clawdbot.internal.`) with `--timeout` and `--json`. — thanks @steipete
- Gateway/CLI: make `clawdbot gateway status` human-readable by default, add `--json`, and probe localhost + configured remote (warn on multiple gateways). — thanks @steipete
- CLI: add global `--no-color` (and respect `NO_COLOR=1`) to disable ANSI output. — thanks @steipete
- CLI: centralize lobster palette + apply it to onboarding/config prompts. — thanks @steipete
## 2026.1.8

View File

@@ -51,11 +51,16 @@ Notes:
All query commands use WebSocket RPC.
Shared options:
- `--url <url>`: Gateway WebSocket URL (defaults to `gateway.remote.url` when configured).
- `--token <token>`: Gateway token (if required).
- `--password <password>`: Gateway password (password auth).
- `--timeout <ms>`: timeout (default `10000`).
Output modes:
- Default: human-readable (colored in TTY).
- `--json`: machine-readable JSON (no styling/spinner).
- `--no-color` (or `NO_COLOR=1`): disable ANSI while keeping human layout.
Shared options (where supported):
- `--url <url>`: Gateway WebSocket URL.
- `--token <token>`: Gateway token.
- `--password <password>`: Gateway password.
- `--timeout <ms>`: timeout/budget (varies per command).
- `--expect-final`: wait for a “final” response (agent calls).
### `gateway health`
@@ -66,8 +71,15 @@ clawdbot gateway health --url ws://127.0.0.1:18789
### `gateway status`
`gateway status` is the “debug everything” command. It always probes:
- your configured remote gateway (if set), and
- localhost (loopback) **even if remote is configured**.
If multiple gateways are reachable, it prints all of them and warns this is an unconventional setup (usually you want only one gateway).
```bash
clawdbot gateway status --url ws://127.0.0.1:18789
clawdbot gateway status
clawdbot gateway status --json
```
### `gateway call <method>`
@@ -104,4 +116,3 @@ Examples:
clawdbot gateway discover --timeout 4000
clawdbot gateway discover --json | jq '.beacons[].wsUrl'
```

View File

@@ -13,6 +13,7 @@ This page describes the current CLI behavior. If commands change, update this do
- `--dev`: isolate state under `~/.clawdbot-dev` and shift default ports.
- `--profile <name>`: isolate state under `~/.clawdbot-<name>`.
- `--no-color`: disable ANSI colors.
- `-V`, `--version`, `-v`: print version and exit.
## Output styling
@@ -20,7 +21,7 @@ This page describes the current CLI behavior. If commands change, update this do
- ANSI colors and progress indicators only render in TTY sessions.
- OSC-8 hyperlinks render as clickable links in supported terminals; otherwise we fall back to plain URLs.
- `--json` (and `--plain` where supported) disables styling for clean output.
- `--no-color` disables ANSI styling where supported; `NO_COLOR=1` is also respected.
- `--no-color` disables ANSI styling; `NO_COLOR=1` is also respected.
- Long-running commands show a progress indicator (OSC 9;4 when supported).
## Color palette

View File

@@ -13,6 +13,7 @@ const forceFreePortAndWait = vi.fn(async () => ({
}));
const serviceIsLoaded = vi.fn().mockResolvedValue(true);
const discoverGatewayBeacons = vi.fn(async () => []);
const gatewayStatusCommand = vi.fn(async () => {});
const runtimeLogs: string[] = [];
const runtimeErrors: string[] = [];
@@ -95,8 +96,12 @@ vi.mock("../infra/bonjour-discovery.js", () => ({
discoverGatewayBeacons: (opts: unknown) => discoverGatewayBeacons(opts),
}));
vi.mock("../commands/gateway-status.js", () => ({
gatewayStatusCommand: (opts: unknown) => gatewayStatusCommand(opts),
}));
describe("gateway-cli coverage", () => {
it("registers call/health/status commands and routes to callGateway", async () => {
it("registers call/health commands and routes to callGateway", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
callGateway.mockClear();
@@ -115,6 +120,21 @@ describe("gateway-cli coverage", () => {
expect(runtimeLogs.join("\n")).toContain('"ok": true');
});
it("registers gateway status and routes to gatewayStatusCommand", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
gatewayStatusCommand.mockClear();
const { registerGatewayCli } = await import("./gateway-cli.js");
const program = new Command();
program.exitOverride();
registerGatewayCli(program);
await program.parseAsync(["gateway", "status", "--json"], { from: "user" });
expect(gatewayStatusCommand).toHaveBeenCalledTimes(1);
});
it("registers gateway discover and prints JSON", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;

View File

@@ -1,6 +1,7 @@
import fs from "node:fs";
import type { Command } from "commander";
import { gatewayStatusCommand } from "../commands/gateway-status.js";
import {
CONFIG_PATH_CLAWDBOT,
type GatewayAuthMode,
@@ -42,6 +43,7 @@ type GatewayRpcOpts = {
password?: string;
timeout?: string;
expectFinal?: boolean;
json?: boolean;
};
type GatewayRunOpts = {
@@ -369,7 +371,8 @@ const gatewayCallOpts = (cmd: Command) =>
.option("--token <token>", "Gateway token (if required)")
.option("--password <password>", "Gateway password (password auth)")
.option("--timeout <ms>", "Timeout in ms", "10000")
.option("--expect-final", "Wait for final response (agent)", false);
.option("--expect-final", "Wait for final response (agent)", false)
.option("--json", "Output JSON", false);
const callGatewayCli = async (
method: string,
@@ -380,7 +383,7 @@ const callGatewayCli = async (
{
label: `Gateway ${method}`,
indeterminate: true,
enabled: true,
enabled: opts.json !== true,
},
async () =>
await callGateway({
@@ -729,7 +732,7 @@ export function registerGatewayCli(program: Command) {
gatewayCallOpts(
gateway
.command("call")
.description("Call a Gateway method and print JSON")
.description("Call a Gateway method")
.argument(
"<method>",
"Method name (health/status/system-presence/cron.*)",
@@ -739,6 +742,18 @@ export function registerGatewayCli(program: Command) {
try {
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));
} catch (err) {
defaultRuntime.error(`Gateway call failed: ${String(err)}`);
@@ -754,7 +769,46 @@ export function registerGatewayCli(program: Command) {
.action(async (opts) => {
try {
const result = await callGatewayCli("health", opts);
defaultRuntime.log(JSON.stringify(result, null, 2));
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.web && typeof obj.web === "object") {
const web = obj.web as Record<string, unknown>;
const linked = web.linked === true;
defaultRuntime.log(
`Web: ${linked ? "linked" : "not linked"}${
typeof web.authAgeMs === "number" && linked
? ` (${Math.round(web.authAgeMs / 60_000)}m)`
: ""
}`,
);
}
if (obj.telegram && typeof obj.telegram === "object") {
const tg = obj.telegram as Record<string, unknown>;
defaultRuntime.log(
`Telegram: ${tg.configured === true ? "configured" : "not configured"}`,
);
}
if (obj.discord && typeof obj.discord === "object") {
const dc = obj.discord as Record<string, unknown>;
defaultRuntime.log(
`Discord: ${dc.configured === true ? "configured" : "not configured"}`,
);
}
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
@@ -762,20 +816,27 @@ export function registerGatewayCli(program: Command) {
}),
);
gatewayCallOpts(
gateway
.command("status")
.description("Fetch Gateway status")
.action(async (opts) => {
try {
const result = await callGatewayCli("status", opts);
defaultRuntime.log(JSON.stringify(result, null, 2));
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
}),
);
gateway
.command("status")
.description(
"Show gateway reachability + discovery + health + status summary (local + remote)",
)
.option(
"--url <url>",
"Explicit Gateway WebSocket URL (still probes localhost)",
)
.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) => {
try {
await gatewayStatusCommand(opts, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
gateway
.command("discover")

View File

@@ -72,6 +72,8 @@ export function buildProgram() {
"Use a named profile (isolates CLAWDBOT_STATE_DIR/CLAWDBOT_CONFIG_PATH under ~/.clawdbot-<name>)",
);
program.option("--no-color", "Disable ANSI colors", false);
program.configureHelp({
optionTerm: (option) => theme.option(option.flags),
subcommandTerm: (cmd) => theme.command(cmd.name()),

View File

@@ -0,0 +1,131 @@
import { describe, expect, it, vi } from "vitest";
const loadConfig = vi.fn(() => ({
gateway: {
mode: "remote",
remote: { url: "ws://remote.example:18789", token: "rtok" },
auth: { token: "ltok" },
},
}));
const resolveGatewayPort = vi.fn(() => 18789);
const discoverGatewayBeacons = vi.fn(async () => []);
const pickPrimaryTailnetIPv4 = vi.fn(() => "100.64.0.10");
const probeGateway = vi.fn(async ({ url }: { url: string }) => {
if (url.includes("127.0.0.1")) {
return {
ok: true,
url,
connectLatencyMs: 12,
error: null,
close: null,
health: { ok: true },
status: { web: { linked: false }, sessions: { count: 0 } },
presence: [
{ mode: "gateway", reason: "self", host: "local", ip: "127.0.0.1" },
],
configSnapshot: {
path: "/tmp/cfg.json",
exists: true,
valid: true,
config: {
gateway: { mode: "local" },
bridge: { enabled: true, port: 18790 },
},
issues: [],
legacyIssues: [],
},
};
}
return {
ok: true,
url,
connectLatencyMs: 34,
error: null,
close: null,
health: { ok: true },
status: { web: { linked: true }, sessions: { count: 2 } },
presence: [
{ mode: "gateway", reason: "self", host: "remote", ip: "100.64.0.2" },
],
configSnapshot: {
path: "/tmp/remote.json",
exists: true,
valid: true,
config: { gateway: { mode: "remote" }, bridge: { enabled: false } },
issues: [],
legacyIssues: [],
},
};
});
vi.mock("../config/config.js", () => ({
loadConfig: () => loadConfig(),
resolveGatewayPort: (cfg: unknown) => resolveGatewayPort(cfg),
}));
vi.mock("../infra/bonjour-discovery.js", () => ({
discoverGatewayBeacons: (opts: unknown) => discoverGatewayBeacons(opts),
}));
vi.mock("../infra/tailnet.js", () => ({
pickPrimaryTailnetIPv4: () => pickPrimaryTailnetIPv4(),
}));
vi.mock("../gateway/probe.js", () => ({
probeGateway: (opts: unknown) => probeGateway(opts),
}));
describe("gateway-status command", () => {
it("prints human output by default", async () => {
const runtimeLogs: string[] = [];
const runtimeErrors: string[] = [];
const runtime = {
log: (msg: string) => runtimeLogs.push(msg),
error: (msg: string) => runtimeErrors.push(msg),
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
};
const { gatewayStatusCommand } = await import("./gateway-status.js");
await gatewayStatusCommand(
{ timeout: "1000" },
runtime as unknown as import("../runtime.js").RuntimeEnv,
);
expect(runtimeErrors).toHaveLength(0);
expect(runtimeLogs.join("\n")).toContain("Gateway Status");
expect(runtimeLogs.join("\n")).toContain("Discovery (this machine)");
expect(runtimeLogs.join("\n")).toContain("Targets");
});
it("prints a structured JSON envelope when --json is set", async () => {
const runtimeLogs: string[] = [];
const runtimeErrors: string[] = [];
const runtime = {
log: (msg: string) => runtimeLogs.push(msg),
error: (msg: string) => runtimeErrors.push(msg),
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
};
const { gatewayStatusCommand } = await import("./gateway-status.js");
await gatewayStatusCommand(
{ timeout: "1000", json: true },
runtime as unknown as import("../runtime.js").RuntimeEnv,
);
expect(runtimeErrors).toHaveLength(0);
const parsed = JSON.parse(runtimeLogs.join("\n")) as Record<
string,
unknown
>;
expect(parsed.ok).toBe(true);
expect(parsed.targets).toBeTruthy();
const targets = parsed.targets as Array<Record<string, unknown>>;
expect(targets.length).toBeGreaterThanOrEqual(2);
expect(targets[0]?.health).toBeTruthy();
expect(targets[0]?.summary).toBeTruthy();
});
});

View File

@@ -0,0 +1,523 @@
import { withProgress } from "../cli/progress.js";
import { loadConfig, resolveGatewayPort } from "../config/config.js";
import type { ClawdbotConfig, ConfigFileSnapshot } from "../config/types.js";
import { type GatewayProbeResult, probeGateway } from "../gateway/probe.js";
import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js";
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
import type { RuntimeEnv } from "../runtime.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
type TargetKind = "explicit" | "configRemote" | "localLoopback";
type GatewayStatusTarget = {
id: string;
kind: TargetKind;
url: string;
active: boolean;
};
type GatewayConfigSummary = {
path: string | null;
exists: boolean;
valid: boolean;
issues: Array<{ path: string; message: string }>;
legacyIssues: Array<{ path: string; message: string }>;
gateway: {
mode: string | null;
bind: string | null;
port: number | null;
controlUiEnabled: boolean | null;
controlUiBasePath: string | null;
authMode: string | null;
authTokenConfigured: boolean;
authPasswordConfigured: boolean;
remoteUrl: string | null;
remoteTokenConfigured: boolean;
remotePasswordConfigured: boolean;
tailscaleMode: string | null;
};
bridge: {
enabled: boolean | null;
bind: string | null;
port: number | null;
};
discovery: {
wideAreaEnabled: boolean | null;
};
};
function parseIntOrNull(value: unknown): number | null {
const s =
typeof value === "string"
? value.trim()
: typeof value === "number" || typeof value === "bigint"
? String(value)
: "";
if (!s) return null;
const n = Number.parseInt(s, 10);
return Number.isFinite(n) ? n : null;
}
function parseTimeoutMs(raw: unknown, fallbackMs: number): number {
const value =
typeof raw === "string"
? raw.trim()
: typeof raw === "number" || typeof raw === "bigint"
? String(raw)
: "";
if (!value) return fallbackMs;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`invalid --timeout: ${value}`);
}
return parsed;
}
function normalizeWsUrl(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
if (!trimmed.startsWith("ws://") && !trimmed.startsWith("wss://"))
return null;
return trimmed;
}
function resolveTargets(
cfg: ClawdbotConfig,
explicitUrl?: string,
): GatewayStatusTarget[] {
const targets: GatewayStatusTarget[] = [];
const add = (t: GatewayStatusTarget) => {
if (!targets.some((x) => x.url === t.url)) targets.push(t);
};
const explicit =
typeof explicitUrl === "string" ? normalizeWsUrl(explicitUrl) : null;
if (explicit)
add({ id: "explicit", kind: "explicit", url: explicit, active: true });
const remoteUrl =
typeof cfg.gateway?.remote?.url === "string"
? normalizeWsUrl(cfg.gateway.remote.url)
: null;
if (remoteUrl) {
add({
id: "configRemote",
kind: "configRemote",
url: remoteUrl,
active: cfg.gateway?.mode === "remote",
});
}
const port = resolveGatewayPort(cfg);
add({
id: "localLoopback",
kind: "localLoopback",
url: `ws://127.0.0.1:${port}`,
active: cfg.gateway?.mode !== "remote",
});
return targets;
}
function resolveProbeBudgetMs(overallMs: number, kind: TargetKind): number {
if (kind === "localLoopback") return Math.min(800, overallMs);
return Math.min(1500, overallMs);
}
function resolveAuthForTarget(
cfg: ClawdbotConfig,
target: GatewayStatusTarget,
overrides: { token?: string; password?: string },
): { token?: string; password?: string } {
const tokenOverride = overrides.token?.trim()
? overrides.token.trim()
: undefined;
const passwordOverride = overrides.password?.trim()
? overrides.password.trim()
: undefined;
if (tokenOverride || passwordOverride) {
return { token: tokenOverride, password: passwordOverride };
}
if (target.kind === "configRemote") {
const token =
typeof cfg.gateway?.remote?.token === "string"
? cfg.gateway.remote.token.trim()
: "";
const remotePassword = (
cfg.gateway?.remote as { password?: unknown } | undefined
)?.password;
const password =
typeof remotePassword === "string" ? remotePassword.trim() : "";
return {
token: token.length > 0 ? token : undefined,
password: password.length > 0 ? password : undefined,
};
}
const envToken = process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() || "";
const envPassword = process.env.CLAWDBOT_GATEWAY_PASSWORD?.trim() || "";
const cfgToken =
typeof cfg.gateway?.auth?.token === "string"
? cfg.gateway.auth.token.trim()
: "";
const cfgPassword =
typeof cfg.gateway?.auth?.password === "string"
? cfg.gateway.auth.password.trim()
: "";
return {
token: envToken || cfgToken || undefined,
password: envPassword || cfgPassword || undefined,
};
}
function pickGatewaySelfPresence(
presence: unknown,
): { host?: string; ip?: string; version?: string; platform?: string } | null {
if (!Array.isArray(presence)) return null;
const entries = presence as Array<Record<string, unknown>>;
const self =
entries.find((e) => e.mode === "gateway" && e.reason === "self") ??
entries.find(
(e) =>
typeof e.text === "string" && String(e.text).startsWith("Gateway:"),
) ??
null;
if (!self) return null;
return {
host: typeof self.host === "string" ? self.host : undefined,
ip: typeof self.ip === "string" ? self.ip : undefined,
version: typeof self.version === "string" ? self.version : undefined,
platform: typeof self.platform === "string" ? self.platform : undefined,
};
}
function extractConfigSummary(snapshotUnknown: unknown): GatewayConfigSummary {
const snap = snapshotUnknown as Partial<ConfigFileSnapshot> | null;
const path = typeof snap?.path === "string" ? snap.path : null;
const exists = Boolean(snap?.exists);
const valid = Boolean(snap?.valid);
const issuesRaw = Array.isArray(snap?.issues) ? snap.issues : [];
const legacyRaw = Array.isArray(snap?.legacyIssues) ? snap.legacyIssues : [];
const cfg = (snap?.config ?? {}) as Record<string, unknown>;
const gateway = (cfg.gateway ?? {}) as Record<string, unknown>;
const bridge = (cfg.bridge ?? {}) as Record<string, unknown>;
const discovery = (cfg.discovery ?? {}) as Record<string, unknown>;
const wideArea = (discovery.wideArea ?? {}) as Record<string, unknown>;
const remote = (gateway.remote ?? {}) as Record<string, unknown>;
const auth = (gateway.auth ?? {}) as Record<string, unknown>;
const controlUi = (gateway.controlUi ?? {}) as Record<string, unknown>;
const tailscale = (gateway.tailscale ?? {}) as Record<string, unknown>;
const authMode = typeof auth.mode === "string" ? auth.mode : null;
const authTokenConfigured =
typeof auth.token === "string" ? auth.token.trim().length > 0 : false;
const authPasswordConfigured =
typeof auth.password === "string" ? auth.password.trim().length > 0 : false;
const remoteUrl =
typeof remote.url === "string" ? normalizeWsUrl(remote.url) : null;
const remoteTokenConfigured =
typeof remote.token === "string" ? remote.token.trim().length > 0 : false;
const remotePasswordConfigured =
typeof remote.password === "string"
? String(remote.password).trim().length > 0
: false;
const bridgeEnabled =
typeof bridge.enabled === "boolean" ? bridge.enabled : null;
const bridgeBind = typeof bridge.bind === "string" ? bridge.bind : null;
const bridgePort = parseIntOrNull(bridge.port);
const wideAreaEnabled =
typeof wideArea.enabled === "boolean" ? wideArea.enabled : null;
return {
path,
exists,
valid,
issues: issuesRaw
.filter((i): i is { path: string; message: string } =>
Boolean(
i && typeof i.path === "string" && typeof i.message === "string",
),
)
.map((i) => ({ path: i.path, message: i.message })),
legacyIssues: legacyRaw
.filter((i): i is { path: string; message: string } =>
Boolean(
i && typeof i.path === "string" && typeof i.message === "string",
),
)
.map((i) => ({ path: i.path, message: i.message })),
gateway: {
mode: typeof gateway.mode === "string" ? gateway.mode : null,
bind: typeof gateway.bind === "string" ? gateway.bind : null,
port: parseIntOrNull(gateway.port),
controlUiEnabled:
typeof controlUi.enabled === "boolean" ? controlUi.enabled : null,
controlUiBasePath:
typeof controlUi.basePath === "string" ? controlUi.basePath : null,
authMode,
authTokenConfigured,
authPasswordConfigured,
remoteUrl,
remoteTokenConfigured,
remotePasswordConfigured,
tailscaleMode: typeof tailscale.mode === "string" ? tailscale.mode : null,
},
bridge: {
enabled: bridgeEnabled,
bind: bridgeBind,
port: bridgePort,
},
discovery: { wideAreaEnabled },
};
}
function buildNetworkHints(cfg: ClawdbotConfig) {
const tailnetIPv4 = pickPrimaryTailnetIPv4();
const port = resolveGatewayPort(cfg);
return {
localLoopbackUrl: `ws://127.0.0.1:${port}`,
localTailnetUrl: tailnetIPv4 ? `ws://${tailnetIPv4}:${port}` : null,
tailnetIPv4: tailnetIPv4 ?? null,
};
}
function renderTargetHeader(target: GatewayStatusTarget, rich: boolean) {
const kindLabel =
target.kind === "localLoopback"
? "Local loopback"
: target.kind === "configRemote"
? target.active
? "Remote (configured)"
: "Remote (configured, inactive)"
: "URL (explicit)";
return `${colorize(rich, theme.heading, kindLabel)} ${colorize(rich, theme.muted, target.url)}`;
}
function renderProbeSummaryLine(probe: GatewayProbeResult, rich: boolean) {
if (probe.ok) {
const latency =
typeof probe.connectLatencyMs === "number"
? `${probe.connectLatencyMs}ms`
: "unknown";
return `${colorize(rich, theme.success, "Connect: ok")} (${latency})`;
}
const detail = probe.error ? ` - ${probe.error}` : "";
return `${colorize(rich, theme.error, "Connect: failed")}${detail}`;
}
export async function gatewayStatusCommand(
opts: {
url?: string;
token?: string;
password?: string;
timeout?: unknown;
json?: boolean;
},
runtime: RuntimeEnv,
) {
const startedAt = Date.now();
const cfg = loadConfig();
const rich = isRich() && opts.json !== true;
const overallTimeoutMs = parseTimeoutMs(opts.timeout, 3000);
const targets = resolveTargets(cfg, opts.url);
const network = buildNetworkHints(cfg);
const discoveryTimeoutMs = Math.min(1200, overallTimeoutMs);
const discoveryPromise = discoverGatewayBeacons({
timeoutMs: discoveryTimeoutMs,
});
const probePromises = targets.map(async (target) => {
const auth = resolveAuthForTarget(cfg, target, {
token: typeof opts.token === "string" ? opts.token : undefined,
password: typeof opts.password === "string" ? opts.password : undefined,
});
const timeoutMs = resolveProbeBudgetMs(overallTimeoutMs, target.kind);
const probe = await probeGateway({ url: target.url, auth, timeoutMs });
const configSummary = probe.configSnapshot
? extractConfigSummary(probe.configSnapshot)
: null;
const self = pickGatewaySelfPresence(probe.presence);
return { target, probe, configSummary, self };
});
const { discovery, probed } = await withProgress(
{
label: "Inspecting gateways…",
indeterminate: true,
enabled: opts.json !== true,
},
async () => {
const [discoveryRes, probesRes] = await Promise.allSettled([
discoveryPromise,
Promise.all(probePromises),
]);
return {
discovery:
discoveryRes.status === "fulfilled" ? discoveryRes.value : [],
probed: probesRes.status === "fulfilled" ? probesRes.value : [],
};
},
);
const reachable = probed.filter((p) => p.probe.ok);
const ok = reachable.length > 0;
const multipleGateways = reachable.length > 1;
const primary =
reachable.find((p) => p.target.kind === "explicit") ??
reachable.find((p) => p.target.kind === "configRemote") ??
reachable.find((p) => p.target.kind === "localLoopback") ??
null;
const warnings: Array<{
code: string;
message: string;
targetIds?: string[];
}> = [];
if (multipleGateways) {
warnings.push({
code: "multiple_gateways",
message:
"Unconventional setup: multiple reachable gateways detected. Usually only one gateway should exist on a network.",
targetIds: reachable.map((p) => p.target.id),
});
}
if (opts.json) {
runtime.log(
JSON.stringify(
{
ok,
ts: Date.now(),
durationMs: Date.now() - startedAt,
timeoutMs: overallTimeoutMs,
primaryTargetId: primary?.target.id ?? null,
warnings,
network,
discovery: {
timeoutMs: discoveryTimeoutMs,
count: discovery.length,
beacons: discovery.map((b) => ({
instanceName: b.instanceName,
displayName: b.displayName ?? null,
domain: b.domain ?? null,
host: b.host ?? null,
lanHost: b.lanHost ?? null,
tailnetDns: b.tailnetDns ?? null,
bridgePort: b.bridgePort ?? null,
gatewayPort: b.gatewayPort ?? null,
sshPort: b.sshPort ?? null,
wsUrl: (() => {
const host = b.tailnetDns || b.lanHost || b.host;
const port = b.gatewayPort ?? 18789;
return host ? `ws://${host}:${port}` : null;
})(),
})),
},
targets: probed.map((p) => ({
id: p.target.id,
kind: p.target.kind,
url: p.target.url,
active: p.target.active,
connect: {
ok: p.probe.ok,
latencyMs: p.probe.connectLatencyMs,
error: p.probe.error,
close: p.probe.close,
},
self: p.self,
config: p.configSummary,
health: p.probe.health,
summary: p.probe.status,
presence: p.probe.presence,
})),
},
null,
2,
),
);
if (!ok) runtime.exit(1);
return;
}
runtime.log(colorize(rich, theme.heading, "Gateway Status"));
runtime.log(
ok
? `${colorize(rich, theme.success, "Reachable")}: yes`
: `${colorize(rich, theme.error, "Reachable")}: no`,
);
runtime.log(
colorize(rich, theme.muted, `Probe budget: ${overallTimeoutMs}ms`),
);
if (warnings.length > 0) {
runtime.log("");
runtime.log(colorize(rich, theme.warn, "Warning:"));
for (const w of warnings) runtime.log(`- ${w.message}`);
}
runtime.log("");
runtime.log(colorize(rich, theme.heading, "Discovery (this machine)"));
runtime.log(
discovery.length > 0
? `Found ${discovery.length} gateway(s) via Bonjour (local. + clawdbot.internal.)`
: "Found 0 gateways via Bonjour (local. + clawdbot.internal.)",
);
if (discovery.length === 0) {
runtime.log(
colorize(
rich,
theme.muted,
"Tip: if the gateway is remote, mDNS wont cross networks; use Wide-Area Bonjour (split DNS) or SSH tunnels.",
),
);
}
runtime.log("");
runtime.log(colorize(rich, theme.heading, "Targets"));
for (const p of probed) {
runtime.log(renderTargetHeader(p.target, rich));
runtime.log(` ${renderProbeSummaryLine(p.probe, rich)}`);
if (p.probe.ok && p.self) {
const host = p.self.host ?? "unknown";
const ip = p.self.ip ? ` (${p.self.ip})` : "";
const platform = p.self.platform ? ` · ${p.self.platform}` : "";
const version = p.self.version ? ` · app ${p.self.version}` : "";
runtime.log(
` ${colorize(rich, theme.info, "Gateway")}: ${host}${ip}${platform}${version}`,
);
}
if (p.configSummary) {
const c = p.configSummary;
const bridge =
c.bridge.enabled === false
? "disabled"
: c.bridge.enabled === true
? "enabled"
: "unknown";
const wideArea =
c.discovery.wideAreaEnabled === true
? "enabled"
: c.discovery.wideAreaEnabled === false
? "disabled"
: "unknown";
runtime.log(
` ${colorize(rich, theme.info, "Bridge")}: ${bridge}${c.bridge.bind ? ` · bind ${c.bridge.bind}` : ""}${c.bridge.port ? ` · port ${c.bridge.port}` : ""}`,
);
runtime.log(
` ${colorize(rich, theme.info, "Wide-area discovery")}: ${wideArea}`,
);
}
runtime.log("");
}
if (!ok) runtime.exit(1);
}

View File

@@ -3,6 +3,11 @@ import process from "node:process";
import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js";
if (process.argv.includes("--no-color")) {
process.env.NO_COLOR = "1";
process.env.FORCE_COLOR = "0";
}
const parsed = parseCliProfileArgs(process.argv);
if (!parsed.ok) {
// Keep it simple; Commander will handle rich help/errors after we strip flags.

View File

@@ -32,6 +32,7 @@ export type GatewayClientOptions = {
maxProtocol?: number;
onEvent?: (evt: EventFrame) => void;
onHelloOk?: (hello: HelloOk) => void;
onConnectError?: (err: Error) => void;
onClose?: (code: number, reason: string) => void;
onGap?: (info: { expected: number; received: number }) => void;
};
@@ -130,6 +131,9 @@ export class GatewayClient {
this.opts.onHelloOk?.(helloOk);
})
.catch((err) => {
this.opts.onConnectError?.(
err instanceof Error ? err : new Error(String(err)),
);
const msg = `gateway connect failed: ${String(err)}`;
if (this.opts.mode === "probe") logDebug(msg);
else logError(msg);

123
src/gateway/probe.ts Normal file
View File

@@ -0,0 +1,123 @@
import { randomUUID } from "node:crypto";
import type { SystemPresence } from "../infra/system-presence.js";
import { GatewayClient } from "./client.js";
export type GatewayProbeAuth = {
token?: string;
password?: string;
};
export type GatewayProbeClose = {
code: number;
reason: string;
hint?: string;
};
export type GatewayProbeResult = {
ok: boolean;
url: string;
connectLatencyMs: number | null;
error: string | null;
close: GatewayProbeClose | null;
health: unknown;
status: unknown;
presence: SystemPresence[] | null;
configSnapshot: unknown;
};
function formatError(err: unknown): string {
if (err instanceof Error) return err.message;
return String(err);
}
export async function probeGateway(opts: {
url: string;
auth?: GatewayProbeAuth;
timeoutMs: number;
}): Promise<GatewayProbeResult> {
const startedAt = Date.now();
const instanceId = randomUUID();
let connectLatencyMs: number | null = null;
let connectError: string | null = null;
let close: GatewayProbeClose | null = null;
return await new Promise<GatewayProbeResult>((resolve) => {
let settled = false;
const settle = (result: Omit<GatewayProbeResult, "url">) => {
if (settled) return;
settled = true;
clearTimeout(timer);
client.stop();
resolve({ url: opts.url, ...result });
};
const client = new GatewayClient({
url: opts.url,
token: opts.auth?.token,
password: opts.auth?.password,
clientName: "cli",
clientVersion: "dev",
mode: "probe",
instanceId,
onConnectError: (err) => {
connectError = formatError(err);
},
onClose: (code, reason) => {
close = { code, reason };
},
onHelloOk: async () => {
connectLatencyMs = Date.now() - startedAt;
try {
const [health, status, presence, configSnapshot] = await Promise.all([
client.request("health"),
client.request("status"),
client.request("system-presence"),
client.request("config.get", {}),
]);
settle({
ok: true,
connectLatencyMs,
error: null,
close,
health,
status,
presence: Array.isArray(presence)
? (presence as SystemPresence[])
: null,
configSnapshot,
});
} catch (err) {
settle({
ok: false,
connectLatencyMs,
error: formatError(err),
close,
health: null,
status: null,
presence: null,
configSnapshot: null,
});
}
},
});
const timer = setTimeout(
() => {
settle({
ok: false,
connectLatencyMs,
error: connectError ? `connect failed: ${connectError}` : "timeout",
close,
health: null,
status: null,
presence: null,
configSnapshot: null,
});
},
Math.max(250, opts.timeoutMs),
);
client.start();
});
}

View File

@@ -1,8 +1,10 @@
import chalk from "chalk";
import chalk, { Chalk } from "chalk";
import { LOBSTER_PALETTE } from "./palette.js";
const hex = (value: string) => chalk.hex(value);
const baseChalk = process.env.NO_COLOR ? new Chalk({ level: 0 }) : chalk;
const hex = (value: string) => baseChalk.hex(value);
export const theme = {
accent: hex(LOBSTER_PALETTE.accent),
@@ -13,12 +15,13 @@ export const theme = {
warn: hex(LOBSTER_PALETTE.warn),
error: hex(LOBSTER_PALETTE.error),
muted: hex(LOBSTER_PALETTE.muted),
heading: chalk.bold.hex(LOBSTER_PALETTE.accent),
heading: baseChalk.bold.hex(LOBSTER_PALETTE.accent),
command: hex(LOBSTER_PALETTE.accentBright),
option: hex(LOBSTER_PALETTE.warn),
} as const;
export const isRich = () => Boolean(process.stdout.isTTY && chalk.level > 0);
export const isRich = () =>
Boolean(process.stdout.isTTY && baseChalk.level > 0);
export const colorize = (
rich: boolean,