diff --git a/src/cli/devices-cli.ts b/src/cli/devices-cli.ts index 0df80e6e1..900f7a886 100644 --- a/src/cli/devices-cli.ts +++ b/src/cli/devices-cli.ts @@ -3,6 +3,8 @@ import type { Command } from "commander"; import { callGateway } from "../gateway/call.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { defaultRuntime } from "../runtime.js"; +import { renderTable } from "../terminal/table.js"; +import { theme } from "../terminal/theme.js"; import { withProgress } from "./progress.js"; type DevicesRpcOpts = { @@ -96,11 +98,11 @@ function parseDevicePairingList(value: unknown): DevicePairingList { } function formatTokenSummary(tokens: DeviceTokenSummary[] | undefined) { - if (!tokens || tokens.length === 0) return "tokens: none"; + if (!tokens || tokens.length === 0) return "none"; const parts = tokens .map((t) => `${t.role}${t.revokedAtMs ? " (revoked)" : ""}`) .sort((a, b) => a.localeCompare(b)); - return `tokens: ${parts.join(", ")}`; + return parts.join(", "); } export function registerDevicesCli(program: Command) { @@ -118,32 +120,59 @@ export function registerDevicesCli(program: Command) { return; } if (list.pending?.length) { - defaultRuntime.log("Pending:"); - for (const req of list.pending) { - const name = req.displayName || req.deviceId; - const repair = req.isRepair ? " (repair)" : ""; - const ip = req.remoteIp ? ` · ${req.remoteIp}` : ""; - const age = - typeof req.ts === "number" ? ` · ${formatAge(Date.now() - req.ts)} ago` : ""; - const role = req.role ? ` · role: ${req.role}` : ""; - defaultRuntime.log(`- ${req.requestId}: ${name}${repair}${role}${ip}${age}`); - } + const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + defaultRuntime.log( + `${theme.heading("Pending")} ${theme.muted(`(${list.pending.length})`)}`, + ); + defaultRuntime.log( + renderTable({ + width: tableWidth, + columns: [ + { key: "Request", header: "Request", minWidth: 10 }, + { key: "Device", header: "Device", minWidth: 16, flex: true }, + { key: "Role", header: "Role", minWidth: 8 }, + { key: "IP", header: "IP", minWidth: 12 }, + { key: "Age", header: "Age", minWidth: 8 }, + { key: "Flags", header: "Flags", minWidth: 8 }, + ], + rows: list.pending.map((req) => ({ + Request: req.requestId, + Device: req.displayName || req.deviceId, + Role: req.role ?? "", + IP: req.remoteIp ?? "", + Age: typeof req.ts === "number" ? `${formatAge(Date.now() - req.ts)} ago` : "", + Flags: req.isRepair ? "repair" : "", + })), + }).trimEnd(), + ); } if (list.paired?.length) { - defaultRuntime.log("Paired:"); - for (const device of list.paired) { - const name = device.displayName || device.deviceId; - const roles = device.roles?.length ? `roles: ${device.roles.join(", ")}` : "roles: -"; - const scopes = device.scopes?.length - ? `scopes: ${device.scopes.join(", ")}` - : "scopes: -"; - const ip = device.remoteIp ? ` · ${device.remoteIp}` : ""; - const tokens = formatTokenSummary(device.tokens); - defaultRuntime.log(`- ${name} · ${roles} · ${scopes} · ${tokens}${ip}`); - } + const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + defaultRuntime.log( + `${theme.heading("Paired")} ${theme.muted(`(${list.paired.length})`)}`, + ); + defaultRuntime.log( + renderTable({ + width: tableWidth, + columns: [ + { key: "Device", header: "Device", minWidth: 16, flex: true }, + { key: "Roles", header: "Roles", minWidth: 12, flex: true }, + { key: "Scopes", header: "Scopes", minWidth: 12, flex: true }, + { key: "Tokens", header: "Tokens", minWidth: 12, flex: true }, + { key: "IP", header: "IP", minWidth: 12 }, + ], + rows: list.paired.map((device) => ({ + Device: device.displayName || device.deviceId, + Roles: device.roles?.length ? device.roles.join(", ") : "", + Scopes: device.scopes?.length ? device.scopes.join(", ") : "", + Tokens: formatTokenSummary(device.tokens), + IP: device.remoteIp ?? "", + })), + }).trimEnd(), + ); } if (!list.pending?.length && !list.paired?.length) { - defaultRuntime.log("No device pairing entries."); + defaultRuntime.log(theme.muted("No device pairing entries.")); } }), ); @@ -160,7 +189,7 @@ export function registerDevicesCli(program: Command) { return; } const deviceId = (result as { device?: { deviceId?: string } })?.device?.deviceId; - defaultRuntime.log(`device approved: ${deviceId ?? "ok"}`); + defaultRuntime.log(`${theme.success("Approved")} ${theme.command(deviceId ?? "ok")}`); }), ); @@ -176,7 +205,7 @@ export function registerDevicesCli(program: Command) { return; } const deviceId = (result as { deviceId?: string })?.deviceId; - defaultRuntime.log(`device rejected: ${deviceId ?? "ok"}`); + defaultRuntime.log(`${theme.warn("Rejected")} ${theme.command(deviceId ?? "ok")}`); }), ); diff --git a/src/cli/directory-cli.ts b/src/cli/directory-cli.ts index ea927c7b6..347695f63 100644 --- a/src/cli/directory-cli.ts +++ b/src/cli/directory-cli.ts @@ -8,6 +8,7 @@ import { resolveMessageChannelSelection } from "../infra/outbound/channel-select import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; +import { renderTable } from "../terminal/table.js"; function parseLimit(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) { @@ -22,9 +23,11 @@ function parseLimit(value: unknown): number | null { return parsed; } -function formatEntry(entry: { kind: string; id: string; name?: string | undefined }): string { - const name = entry.name?.trim(); - return name ? `${entry.id}\t${name}` : entry.id; +function buildRows(entries: Array<{ id: string; name?: string | undefined }>) { + return entries.map((entry) => ({ + ID: entry.id, + Name: entry.name?.trim() ?? "", + })); } export function registerDirectoryCli(program: Command) { @@ -77,10 +80,21 @@ export function registerDirectoryCli(program: Command) { return; } if (!result) { - defaultRuntime.log("not available"); + defaultRuntime.log(theme.muted("Not available.")); return; } - defaultRuntime.log(formatEntry(result)); + const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + defaultRuntime.log(`${theme.heading("Self")}`); + defaultRuntime.log( + renderTable({ + width: tableWidth, + columns: [ + { key: "ID", header: "ID", minWidth: 16, flex: true }, + { key: "Name", header: "Name", minWidth: 18, flex: true }, + ], + rows: buildRows([result]), + }).trimEnd(), + ); } catch (err) { defaultRuntime.error(danger(String(err))); defaultRuntime.exit(1); @@ -111,9 +125,22 @@ export function registerDirectoryCli(program: Command) { defaultRuntime.log(JSON.stringify(result, null, 2)); return; } - for (const entry of result) { - defaultRuntime.log(formatEntry(entry)); + if (result.length === 0) { + defaultRuntime.log(theme.muted("No peers found.")); + return; } + const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + defaultRuntime.log(`${theme.heading("Peers")} ${theme.muted(`(${result.length})`)}`); + defaultRuntime.log( + renderTable({ + width: tableWidth, + columns: [ + { key: "ID", header: "ID", minWidth: 16, flex: true }, + { key: "Name", header: "Name", minWidth: 18, flex: true }, + ], + rows: buildRows(result), + }).trimEnd(), + ); } catch (err) { defaultRuntime.error(danger(String(err))); defaultRuntime.exit(1); @@ -143,9 +170,22 @@ export function registerDirectoryCli(program: Command) { defaultRuntime.log(JSON.stringify(result, null, 2)); return; } - for (const entry of result) { - defaultRuntime.log(formatEntry(entry)); + if (result.length === 0) { + defaultRuntime.log(theme.muted("No groups found.")); + return; } + const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + defaultRuntime.log(`${theme.heading("Groups")} ${theme.muted(`(${result.length})`)}`); + defaultRuntime.log( + renderTable({ + width: tableWidth, + columns: [ + { key: "ID", header: "ID", minWidth: 16, flex: true }, + { key: "Name", header: "Name", minWidth: 18, flex: true }, + ], + rows: buildRows(result), + }).trimEnd(), + ); } catch (err) { defaultRuntime.error(danger(String(err))); defaultRuntime.exit(1); diff --git a/src/cli/dns-cli.ts b/src/cli/dns-cli.ts index 6dba47d1a..7798415e8 100644 --- a/src/cli/dns-cli.ts +++ b/src/cli/dns-cli.ts @@ -7,7 +7,9 @@ import type { Command } from "commander"; import { loadConfig } from "../config/config.js"; import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js"; import { getWideAreaZonePath, WIDE_AREA_DISCOVERY_DOMAIN } from "../infra/widearea-dns.js"; +import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; +import { renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; type RunOpts = { allowFailure?: boolean; inherit?: boolean }; @@ -112,14 +114,28 @@ export function registerDnsCli(program: Command) { const tailnetIPv6 = pickPrimaryTailnetIPv6(); const zonePath = getWideAreaZonePath(); - console.log(`Domain: ${WIDE_AREA_DISCOVERY_DOMAIN}`); - console.log(`Zone file (gateway-owned): ${zonePath}`); - console.log( - `Detected tailnet IP: ${tailnetIPv4 ?? "—"}${tailnetIPv6 ? ` (v6 ${tailnetIPv6})` : ""}`, + const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + defaultRuntime.log(theme.heading("DNS setup")); + defaultRuntime.log( + renderTable({ + width: tableWidth, + columns: [ + { key: "Key", header: "Key", minWidth: 18 }, + { key: "Value", header: "Value", minWidth: 24, flex: true }, + ], + rows: [ + { Key: "Domain", Value: WIDE_AREA_DISCOVERY_DOMAIN }, + { Key: "Zone file", Value: zonePath }, + { + Key: "Tailnet IP", + Value: `${tailnetIPv4 ?? "—"}${tailnetIPv6 ? ` (v6 ${tailnetIPv6})` : ""}`, + }, + ], + }).trimEnd(), ); - console.log(""); - console.log("Recommended ~/.clawdbot/clawdbot.json:"); - console.log( + defaultRuntime.log(""); + defaultRuntime.log(theme.heading("Recommended ~/.clawdbot/clawdbot.json:")); + defaultRuntime.log( JSON.stringify( { gateway: { bind: "auto" }, @@ -129,14 +145,16 @@ export function registerDnsCli(program: Command) { 2, ), ); - console.log(""); - console.log("Tailscale admin (DNS → Nameservers):"); - console.log(`- Add nameserver: ${tailnetIPv4 ?? ""}`); - console.log(`- Restrict to domain (Split DNS): clawdbot.internal`); + defaultRuntime.log(""); + defaultRuntime.log(theme.heading("Tailscale admin (DNS → Nameservers):")); + defaultRuntime.log( + theme.muted(`- Add nameserver: ${tailnetIPv4 ?? ""}`), + ); + defaultRuntime.log(theme.muted("- Restrict to domain (Split DNS): clawdbot.internal")); if (!opts.apply) { - console.log(""); - console.log("Run with --apply to install CoreDNS and configure it."); + defaultRuntime.log(""); + defaultRuntime.log(theme.muted("Run with --apply to install CoreDNS and configure it.")); return; } @@ -205,16 +223,18 @@ export function registerDnsCli(program: Command) { fs.writeFileSync(zonePath, zoneLines.join("\n"), "utf-8"); } - console.log(""); - console.log("Starting CoreDNS (sudo)…"); + defaultRuntime.log(""); + defaultRuntime.log(theme.heading("Starting CoreDNS (sudo)…")); run("sudo", ["brew", "services", "restart", "coredns"], { inherit: true, }); if (cfg.discovery?.wideArea?.enabled !== true) { - console.log(""); - console.log( - "Note: enable discovery.wideArea.enabled in ~/.clawdbot/clawdbot.json on the gateway and restart the gateway so it writes the DNS-SD zone.", + defaultRuntime.log(""); + defaultRuntime.log( + theme.muted( + "Note: enable discovery.wideArea.enabled in ~/.clawdbot/clawdbot.json on the gateway and restart the gateway so it writes the DNS-SD zone.", + ), ); } }); diff --git a/src/cli/pairing-cli.ts b/src/cli/pairing-cli.ts index d0ecbce14..4d405bff5 100644 --- a/src/cli/pairing-cli.ts +++ b/src/cli/pairing-cli.ts @@ -8,7 +8,9 @@ import { listChannelPairingRequests, type PairingChannel, } from "../pairing/pairing-store.js"; +import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; +import { renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; import { formatCliCommand } from "./command-format.js"; @@ -70,18 +72,35 @@ export function registerPairingCli(program: Command) { const channel = parseChannel(channelRaw, channels); const requests = await listChannelPairingRequests(channel); if (opts.json) { - console.log(JSON.stringify({ channel, requests }, null, 2)); + defaultRuntime.log(JSON.stringify({ channel, requests }, null, 2)); return; } if (requests.length === 0) { - console.log(`No pending ${channel} pairing requests.`); + defaultRuntime.log(theme.muted(`No pending ${channel} pairing requests.`)); return; } - for (const r of requests) { - const meta = r.meta ? JSON.stringify(r.meta) : ""; - const idLabel = resolvePairingIdLabel(channel); - console.log(`${r.code} ${idLabel}=${r.id}${meta ? ` meta=${meta}` : ""} ${r.createdAt}`); - } + const idLabel = resolvePairingIdLabel(channel); + const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + defaultRuntime.log( + `${theme.heading("Pairing requests")} ${theme.muted(`(${requests.length})`)}`, + ); + defaultRuntime.log( + renderTable({ + width: tableWidth, + columns: [ + { key: "Code", header: "Code", minWidth: 10 }, + { key: "ID", header: idLabel, minWidth: 12, flex: true }, + { key: "Meta", header: "Meta", minWidth: 8, flex: true }, + { key: "Requested", header: "Requested", minWidth: 12 }, + ], + rows: requests.map((r) => ({ + Code: r.code, + ID: r.id, + Meta: r.meta ? JSON.stringify(r.meta) : "", + Requested: r.createdAt, + })), + }).trimEnd(), + ); }); pairing @@ -113,11 +132,13 @@ export function registerPairingCli(program: Command) { throw new Error(`No pending pairing request found for code: ${String(resolvedCode)}`); } - console.log(`Approved ${channel} sender ${approved.id}.`); + defaultRuntime.log( + `${theme.success("Approved")} ${theme.muted(channel)} sender ${theme.command(approved.id)}.`, + ); if (!opts.notify) return; await notifyApproved(channel, approved.id).catch((err) => { - console.log(`Failed to notify requester: ${String(err)}`); + defaultRuntime.log(theme.warn(`Failed to notify requester: ${String(err)}`)); }); }); }