diff --git a/CHANGELOG.md b/CHANGELOG.md index 102b3a992..0479c0108 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index 960bbd2c1..0e80a7c73 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -51,11 +51,16 @@ Notes: All query commands use WebSocket RPC. -Shared options: -- `--url `: Gateway WebSocket URL (defaults to `gateway.remote.url` when configured). -- `--token `: Gateway token (if required). -- `--password `: Gateway password (password auth). -- `--timeout `: 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 `: Gateway WebSocket URL. +- `--token `: Gateway token. +- `--password `: Gateway password. +- `--timeout `: 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 ` @@ -104,4 +116,3 @@ Examples: clawdbot gateway discover --timeout 4000 clawdbot gateway discover --json | jq '.beacons[].wsUrl' ``` - diff --git a/docs/cli/index.md b/docs/cli/index.md index fa6ddee98..99e505a95 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -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 `: isolate state under `~/.clawdbot-`. +- `--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 diff --git a/src/cli/gateway-cli.coverage.test.ts b/src/cli/gateway-cli.coverage.test.ts index 1cf7f01a6..6e25780b7 100644 --- a/src/cli/gateway-cli.coverage.test.ts +++ b/src/cli/gateway-cli.coverage.test.ts @@ -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; diff --git a/src/cli/gateway-cli.ts b/src/cli/gateway-cli.ts index c040b8feb..82541fa60 100644 --- a/src/cli/gateway-cli.ts +++ b/src/cli/gateway-cli.ts @@ -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 ", "Gateway token (if required)") .option("--password ", "Gateway password (password auth)") .option("--timeout ", "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 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) + : {}; + 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; + 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; + defaultRuntime.log( + `Telegram: ${tg.configured === true ? "configured" : "not configured"}`, + ); + } + if (obj.discord && typeof obj.discord === "object") { + const dc = obj.discord as Record; + 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 ", + "Explicit Gateway WebSocket URL (still probes localhost)", + ) + .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) => { + try { + await gatewayStatusCommand(opts, defaultRuntime); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); gateway .command("discover") diff --git a/src/cli/program.ts b/src/cli/program.ts index 23eac4ccb..fb24c10d1 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -72,6 +72,8 @@ export function buildProgram() { "Use a named profile (isolates CLAWDBOT_STATE_DIR/CLAWDBOT_CONFIG_PATH under ~/.clawdbot-)", ); + program.option("--no-color", "Disable ANSI colors", false); + program.configureHelp({ optionTerm: (option) => theme.option(option.flags), subcommandTerm: (cmd) => theme.command(cmd.name()), diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts new file mode 100644 index 000000000..64d536f98 --- /dev/null +++ b/src/commands/gateway-status.test.ts @@ -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>; + expect(targets.length).toBeGreaterThanOrEqual(2); + expect(targets[0]?.health).toBeTruthy(); + expect(targets[0]?.summary).toBeTruthy(); + }); +}); diff --git a/src/commands/gateway-status.ts b/src/commands/gateway-status.ts new file mode 100644 index 000000000..daa41ca39 --- /dev/null +++ b/src/commands/gateway-status.ts @@ -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>; + 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 | 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; + const gateway = (cfg.gateway ?? {}) as Record; + const bridge = (cfg.bridge ?? {}) as Record; + const discovery = (cfg.discovery ?? {}) as Record; + const wideArea = (discovery.wideArea ?? {}) as Record; + + const remote = (gateway.remote ?? {}) as Record; + const auth = (gateway.auth ?? {}) as Record; + const controlUi = (gateway.controlUi ?? {}) as Record; + const tailscale = (gateway.tailscale ?? {}) as Record; + + 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 won’t 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); +} diff --git a/src/entry.ts b/src/entry.ts index 6aa98c7f5..fd44bd5bd 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -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. diff --git a/src/gateway/client.ts b/src/gateway/client.ts index b0c06b293..33b82dd38 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -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); diff --git a/src/gateway/probe.ts b/src/gateway/probe.ts new file mode 100644 index 000000000..236199935 --- /dev/null +++ b/src/gateway/probe.ts @@ -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 { + 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((resolve) => { + let settled = false; + const settle = (result: Omit) => { + 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(); + }); +} diff --git a/src/terminal/theme.ts b/src/terminal/theme.ts index 8eccb3f2c..06316623b 100644 --- a/src/terminal/theme.ts +++ b/src/terminal/theme.ts @@ -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,