diff --git a/src/cli/nodes-cli/register.status.ts b/src/cli/nodes-cli/register.status.ts index 03e00cbbec4..e78f8feb7bd 100644 --- a/src/cli/nodes-cli/register.status.ts +++ b/src/cli/nodes-cli/register.status.ts @@ -8,7 +8,7 @@ import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; import { formatPermissions, parseNodeList, parsePairingList } from "./format.js"; import { renderPendingPairingRequestsTable } from "./pairing-render.js"; import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; -import type { NodesRpcOpts } from "./types.js"; +import type { NodeListNode, NodesRpcOpts, PairedNode } from "./types.js"; function formatVersionLabel(raw: string) { const trimmed = raw.trim(); @@ -97,6 +97,118 @@ function parseSinceMs(raw: unknown, label: string): number | undefined { } } +function messageFromError(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === "string") { + return error; + } + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof (error as { message?: unknown }).message === "string" + ) { + return (error as { message: string }).message; + } + if (typeof error === "object" && error !== null) { + try { + return JSON.stringify(error); + } catch { + return ""; + } + } + return ""; +} + +function shouldFallbackToPairList(error: unknown): boolean { + const message = messageFromError(error).toLowerCase(); + if (!message.includes("node.list")) { + return false; + } + return ( + message.includes("unknown method") || + message.includes("method not found") || + message.includes("not implemented") || + message.includes("unsupported") + ); +} + +type NodesListEntry = NodeListNode & { + lastConnectedAtMs?: number; +}; + +function mergePairedNodeSources(params: { + liveNodes: NodeListNode[] | null; + pairedNodes: PairedNode[]; +}): NodesListEntry[] { + const merged = new Map(); + + for (const paired of params.pairedNodes) { + merged.set(paired.nodeId, { + nodeId: paired.nodeId, + displayName: paired.displayName, + platform: paired.platform, + version: paired.version, + coreVersion: paired.coreVersion, + uiVersion: paired.uiVersion, + remoteIp: paired.remoteIp, + permissions: paired.permissions, + paired: true, + connected: false, + lastConnectedAtMs: paired.lastConnectedAtMs, + }); + } + + for (const live of params.liveNodes ?? []) { + const previous = merged.get(live.nodeId); + if (!live.paired && !previous?.paired) { + continue; + } + merged.set(live.nodeId, { + ...previous, + ...live, + displayName: live.displayName ?? previous?.displayName, + platform: live.platform ?? previous?.platform, + version: live.version ?? previous?.version, + coreVersion: live.coreVersion ?? previous?.coreVersion, + uiVersion: live.uiVersion ?? previous?.uiVersion, + remoteIp: live.remoteIp ?? previous?.remoteIp, + permissions: live.permissions ?? previous?.permissions, + paired: true, + connected: live.connected ?? previous?.connected, + connectedAtMs: live.connectedAtMs ?? previous?.connectedAtMs, + lastConnectedAtMs: previous?.lastConnectedAtMs, + }); + } + + return [...merged.values()]; +} + +async function loadPairedNodesForList(opts: NodesRpcOpts): Promise<{ + pending: ReturnType["pending"]; + paired: NodesListEntry[]; +}> { + const pairingResult = await callGatewayCli("node.pair.list", opts, {}); + const { pending, paired } = parsePairingList(pairingResult); + try { + const liveNodes = parseNodeList(await callGatewayCli("node.list", opts, {})); + return { + pending, + paired: mergePairedNodeSources({ liveNodes, pairedNodes: paired }), + }; + } catch (error) { + if (!shouldFallbackToPairList(error)) { + throw error; + } + return { + pending, + paired: mergePairedNodeSources({ liveNodes: null, pairedNodes: paired }), + }; + } +} + export function registerNodesStatusCommands(nodes: Command) { nodesCallOpts( nodes @@ -304,35 +416,24 @@ export function registerNodesStatusCommands(nodes: Command) { 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, {}); - const { pending, paired } = parsePairingList(result); + const { pending, paired } = await loadPairedNodesForList(opts); const { heading, muted, warn } = getNodesTheme(); const tableWidth = getTerminalTableWidth(); 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) { + if (!node.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 + typeof node.connectedAtMs === "number" + ? node.connectedAtMs + : typeof node.lastConnectedAtMs === "number" + ? node.lastConnectedAtMs : undefined; if (typeof lastConnectedAtMs !== "number") { return false; @@ -370,12 +471,11 @@ export function registerNodesStatusCommands(nodes: Command) { 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 + typeof n.connectedAtMs === "number" + ? n.connectedAtMs + : typeof n.lastConnectedAtMs === "number" + ? n.lastConnectedAtMs : undefined; return { Node: n.displayName?.trim() ? n.displayName.trim() : n.nodeId, diff --git a/src/cli/program.nodes-basic.e2e.test.ts b/src/cli/program.nodes-basic.e2e.test.ts index 16b6816dd6e..3ca4a2c369c 100644 --- a/src/cli/program.nodes-basic.e2e.test.ts +++ b/src/cli/program.nodes-basic.e2e.test.ts @@ -100,6 +100,82 @@ describe("cli program (nodes basics)", () => { expect(output).not.toContain("Two"); }); + it("runs nodes list and includes paired nodes from node.list when node.pair.list is empty", async () => { + const now = Date.now(); + callGateway.mockImplementation(async (...args: unknown[]) => { + const opts = (args[0] ?? {}) as { method?: string }; + if (opts.method === "node.pair.list") { + return { + pending: [], + paired: [], + }; + } + if (opts.method === "node.list") { + return { + ts: now, + nodes: [ + { + nodeId: "n1", + displayName: "One", + remoteIp: "10.0.0.1", + paired: true, + connected: true, + connectedAtMs: now - 1_000, + }, + ], + }; + } + return { ok: true }; + }); + + await runProgram(["nodes", "list"]); + + expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "node.pair.list" })); + expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "node.list" })); + + const output = getRuntimeOutput(); + expect(output).toContain("Pending: 0 · Paired: 1"); + expect(output).toContain("One"); + }); + + it("runs nodes list and falls back to node.pair.list when node.list is unavailable", async () => { + callGateway.mockImplementation(async (...args: unknown[]) => { + const opts = (args[0] ?? {}) as { method?: string }; + if (opts.method === "node.pair.list") { + return { + pending: [], + paired: [ + { + nodeId: "n1", + displayName: "One", + remoteIp: "10.0.0.1", + lastConnectedAtMs: Date.now() - 1_000, + }, + ], + }; + } + if (opts.method === "node.list") { + throw new Error("unknown method: node.list"); + } + return { ok: true }; + }); + + await runProgram(["nodes", "list"]); + + expect(callGateway).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ method: "node.pair.list" }), + ); + expect(callGateway).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ method: "node.list" }), + ); + + const output = getRuntimeOutput(); + expect(output).toContain("Pending: 0 · Paired: 1"); + expect(output).toContain("One"); + }); + it("runs nodes status --last-connected and filters by age", async () => { const now = Date.now(); callGateway.mockImplementation(async (...args: unknown[]) => {