import type { Command } from "commander"; import { defaultRuntime } from "../../runtime.js"; import { formatAge, formatPermissions, parseNodeList, parsePairingList } from "./format.js"; import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; import type { NodesRpcOpts } from "./types.js"; import { renderTable } from "../../terminal/table.js"; import { parseDurationMs } from "../parse-duration.js"; import { shortenHomeInString } from "../../utils.js"; function formatVersionLabel(raw: string) { const trimmed = raw.trim(); if (!trimmed) return raw; if (trimmed.toLowerCase().startsWith("v")) return trimmed; return /^\d/.test(trimmed) ? `v${trimmed}` : trimmed; } function resolveNodeVersions(node: { platform?: string; version?: string; coreVersion?: string; uiVersion?: string; }) { const core = node.coreVersion?.trim() || undefined; const ui = node.uiVersion?.trim() || undefined; if (core || ui) return { core, ui }; const legacy = node.version?.trim(); if (!legacy) return { core: undefined, ui: undefined }; const platform = node.platform?.trim().toLowerCase() ?? ""; const headless = platform === "darwin" || platform === "linux" || platform === "win32" || platform === "windows"; return headless ? { core: legacy, ui: undefined } : { core: undefined, ui: legacy }; } function formatNodeVersions(node: { platform?: string; version?: string; coreVersion?: string; uiVersion?: string; }) { const { core, ui } = resolveNodeVersions(node); const parts: string[] = []; if (core) parts.push(`core ${formatVersionLabel(core)}`); if (ui) parts.push(`ui ${formatVersionLabel(ui)}`); return parts.length > 0 ? parts.join(" · ") : null; } function formatPathEnv(raw?: string): string | null { if (typeof raw !== "string") return null; const trimmed = raw.trim(); if (!trimmed) return null; const parts = trimmed.split(":").filter(Boolean); const display = parts.length <= 3 ? trimmed : `${parts.slice(0, 2).join(":")}:…:${parts.slice(-1)[0]}`; return shortenHomeInString(display); } function parseSinceMs(raw: unknown, label: string): number | undefined { if (raw === undefined || raw === null) return undefined; const value = typeof raw === "string" ? raw.trim() : typeof raw === "number" ? String(raw).trim() : null; if (value === null) { defaultRuntime.error(`${label}: invalid duration value`); defaultRuntime.exit(1); return undefined; } if (!value) return undefined; try { return parseDurationMs(value); } catch (err) { const message = err instanceof Error ? err.message : String(err); defaultRuntime.error(`${label}: ${message}`); defaultRuntime.exit(1); return undefined; } } export function registerNodesStatusCommands(nodes: Command) { nodesCallOpts( nodes .command("status") .description("List known nodes with connection status and capabilities") .option("--connected", "Only show connected nodes") .option("--last-connected ", "Only show nodes connected within duration (e.g. 24h)") .action(async (opts: NodesRpcOpts) => { await runNodesCommand("status", async () => { const connectedOnly = Boolean(opts.connected); const sinceMs = parseSinceMs(opts.lastConnected, "Invalid --last-connected"); const result = (await callGatewayCli("node.list", opts, {})) as unknown; const obj = typeof result === "object" && result !== null ? (result as Record) : {}; const { ok, warn, muted } = getNodesTheme(); const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); const now = Date.now(); const nodes = parseNodeList(result); const lastConnectedById = sinceMs !== undefined ? new Map( parsePairingList(await callGatewayCli("node.pair.list", opts, {})).paired.map( (entry) => [entry.nodeId, entry], ), ) : null; const filtered = nodes.filter((n) => { if (connectedOnly && !n.connected) return false; if (sinceMs !== undefined) { const paired = lastConnectedById?.get(n.nodeId); const lastConnectedAtMs = typeof paired?.lastConnectedAtMs === "number" ? paired.lastConnectedAtMs : typeof n.connectedAtMs === "number" ? n.connectedAtMs : undefined; if (typeof lastConnectedAtMs !== "number") return false; if (now - lastConnectedAtMs > sinceMs) return false; } return true; }); if (opts.json) { const ts = typeof obj.ts === "number" ? obj.ts : Date.now(); defaultRuntime.log(JSON.stringify({ ...obj, ts, nodes: filtered }, null, 2)); return; } const pairedCount = filtered.filter((n) => Boolean(n.paired)).length; const connectedCount = filtered.filter((n) => Boolean(n.connected)).length; const filteredLabel = filtered.length !== nodes.length ? ` (of ${nodes.length})` : ""; defaultRuntime.log( `Known: ${filtered.length}${filteredLabel} · Paired: ${pairedCount} · Connected: ${connectedCount}`, ); if (filtered.length === 0) return; const rows = filtered.map((n) => { const name = n.displayName?.trim() ? n.displayName.trim() : n.nodeId; const perms = formatPermissions(n.permissions); const versions = formatNodeVersions(n); const pathEnv = formatPathEnv(n.pathEnv); const detailParts = [ n.deviceFamily ? `device: ${n.deviceFamily}` : null, n.modelIdentifier ? `hw: ${n.modelIdentifier}` : null, perms ? `perms: ${perms}` : null, versions, pathEnv ? `path: ${pathEnv}` : null, ].filter(Boolean) as string[]; const caps = Array.isArray(n.caps) ? n.caps.map(String).filter(Boolean).sort().join(", ") : "?"; const paired = n.paired ? ok("paired") : warn("unpaired"); const connected = n.connected ? ok("connected") : muted("disconnected"); const since = typeof n.connectedAtMs === "number" ? ` (${formatAge(Math.max(0, now - n.connectedAtMs))} ago)` : ""; return { Node: name, ID: n.nodeId, IP: n.remoteIp ?? "", Detail: detailParts.join(" · "), Status: `${paired} · ${connected}${since}`, Caps: caps, }; }); defaultRuntime.log( renderTable({ width: tableWidth, columns: [ { key: "Node", header: "Node", minWidth: 14, flex: true }, { key: "ID", header: "ID", minWidth: 10 }, { key: "IP", header: "IP", minWidth: 10 }, { key: "Detail", header: "Detail", minWidth: 18, flex: true }, { key: "Status", header: "Status", minWidth: 18 }, { key: "Caps", header: "Caps", minWidth: 12, flex: true }, ], rows, }).trimEnd(), ); }); }), ); nodesCallOpts( nodes .command("describe") .description("Describe a node (capabilities + supported invoke commands)") .requiredOption("--node ", "Node id, name, or IP") .action(async (opts: NodesRpcOpts) => { await runNodesCommand("describe", async () => { const nodeId = await resolveNodeId(opts, String(opts.node ?? "")); const result = (await callGatewayCli("node.describe", opts, { nodeId, })) as unknown; if (opts.json) { defaultRuntime.log(JSON.stringify(result, null, 2)); return; } const obj = typeof result === "object" && result !== null ? (result as Record) : {}; const displayName = typeof obj.displayName === "string" ? obj.displayName : nodeId; const connected = Boolean(obj.connected); const paired = Boolean(obj.paired); const caps = Array.isArray(obj.caps) ? obj.caps.map(String).filter(Boolean).sort() : null; const commands = Array.isArray(obj.commands) ? obj.commands.map(String).filter(Boolean).sort() : []; const perms = formatPermissions(obj.permissions); const family = typeof obj.deviceFamily === "string" ? obj.deviceFamily : null; const model = typeof obj.modelIdentifier === "string" ? obj.modelIdentifier : null; const ip = typeof obj.remoteIp === "string" ? obj.remoteIp : null; const pathEnv = typeof obj.pathEnv === "string" ? obj.pathEnv : null; const versions = formatNodeVersions( obj as { platform?: string; version?: string; coreVersion?: string; uiVersion?: string; }, ); const { heading, ok, warn, muted } = getNodesTheme(); const status = `${paired ? ok("paired") : warn("unpaired")} · ${ connected ? ok("connected") : muted("disconnected") }`; const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); const rows = [ { Field: "ID", Value: nodeId }, displayName ? { Field: "Name", Value: displayName } : null, ip ? { Field: "IP", Value: ip } : null, family ? { Field: "Device", Value: family } : null, model ? { Field: "Model", Value: model } : null, perms ? { Field: "Perms", Value: perms } : null, versions ? { Field: "Version", Value: versions } : null, pathEnv ? { Field: "PATH", Value: pathEnv } : null, { Field: "Status", Value: status }, { Field: "Caps", Value: caps ? caps.join(", ") : "?" }, ].filter(Boolean) as Array<{ Field: string; Value: string }>; defaultRuntime.log(heading("Node")); defaultRuntime.log( renderTable({ width: tableWidth, columns: [ { key: "Field", header: "Field", minWidth: 8 }, { key: "Value", header: "Value", minWidth: 24, flex: true }, ], rows, }).trimEnd(), ); defaultRuntime.log(""); defaultRuntime.log(heading("Commands")); if (commands.length === 0) { defaultRuntime.log(muted("- (none reported)")); return; } for (const c of commands) defaultRuntime.log(`- ${c}`); }); }), ); nodesCallOpts( nodes .command("list") .description("List pending and paired nodes") .option("--connected", "Only show connected nodes") .option("--last-connected ", "Only show nodes connected within duration (e.g. 24h)") .action(async (opts: NodesRpcOpts) => { await runNodesCommand("list", async () => { const connectedOnly = Boolean(opts.connected); const sinceMs = parseSinceMs(opts.lastConnected, "Invalid --last-connected"); const result = (await callGatewayCli("node.pair.list", opts, {})) as unknown; const { pending, paired } = parsePairingList(result); const { heading, muted, warn } = getNodesTheme(); const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); const now = Date.now(); const hasFilters = connectedOnly || sinceMs !== undefined; const pendingRows = hasFilters ? [] : pending; const connectedById = hasFilters ? new Map( parseNodeList(await callGatewayCli("node.list", opts, {})).map((node) => [ node.nodeId, node, ]), ) : null; const filteredPaired = paired.filter((node) => { if (connectedOnly) { const live = connectedById?.get(node.nodeId); if (!live?.connected) return false; } if (sinceMs !== undefined) { const live = connectedById?.get(node.nodeId); const lastConnectedAtMs = typeof node.lastConnectedAtMs === "number" ? node.lastConnectedAtMs : typeof live?.connectedAtMs === "number" ? live.connectedAtMs : undefined; if (typeof lastConnectedAtMs !== "number") return false; if (now - lastConnectedAtMs > sinceMs) return false; } return true; }); const filteredLabel = hasFilters && filteredPaired.length !== paired.length ? ` (of ${paired.length})` : ""; defaultRuntime.log( `Pending: ${pendingRows.length} · Paired: ${filteredPaired.length}${filteredLabel}`, ); if (opts.json) { defaultRuntime.log( JSON.stringify({ pending: pendingRows, paired: filteredPaired }, null, 2), ); return; } if (pendingRows.length > 0) { const pendingRowsRendered = pendingRows.map((r) => ({ Request: r.requestId, Node: r.displayName?.trim() ? r.displayName.trim() : r.nodeId, IP: r.remoteIp ?? "", Requested: typeof r.ts === "number" ? `${formatAge(Math.max(0, now - r.ts))} ago` : muted("unknown"), Repair: r.isRepair ? warn("yes") : "", })); defaultRuntime.log(""); defaultRuntime.log(heading("Pending")); defaultRuntime.log( renderTable({ width: tableWidth, columns: [ { key: "Request", header: "Request", minWidth: 8 }, { key: "Node", header: "Node", minWidth: 14, flex: true }, { key: "IP", header: "IP", minWidth: 10 }, { key: "Requested", header: "Requested", minWidth: 12 }, { key: "Repair", header: "Repair", minWidth: 6 }, ], rows: pendingRowsRendered, }).trimEnd(), ); } if (filteredPaired.length > 0) { const pairedRows = filteredPaired.map((n) => { const live = connectedById?.get(n.nodeId); const lastConnectedAtMs = typeof n.lastConnectedAtMs === "number" ? n.lastConnectedAtMs : typeof live?.connectedAtMs === "number" ? live.connectedAtMs : undefined; return { Node: n.displayName?.trim() ? n.displayName.trim() : n.nodeId, Id: n.nodeId, IP: n.remoteIp ?? "", LastConnect: typeof lastConnectedAtMs === "number" ? `${formatAge(Math.max(0, now - lastConnectedAtMs))} ago` : muted("unknown"), }; }); defaultRuntime.log(""); defaultRuntime.log(heading("Paired")); defaultRuntime.log( renderTable({ width: tableWidth, columns: [ { key: "Node", header: "Node", minWidth: 14, flex: true }, { key: "Id", header: "ID", minWidth: 10 }, { key: "IP", header: "IP", minWidth: 10 }, { key: "LastConnect", header: "Last Connect", minWidth: 14 }, ], rows: pairedRows, }).trimEnd(), ); } }); }), ); }