diff --git a/src/cli/nodes-cli/register.status.ts b/src/cli/nodes-cli/register.status.ts index 03e00cbbec4..2ae92afd04c 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,110 @@ 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(); + return ( + message.includes("unknown method") || + message.includes("method not found") || + message.includes("invalid request") || + message.includes("missing scope: operator.read") || + message.includes("not implemented") || + message.includes("unsupported") + ); +} + +type NodesListEntry = NodeListNode & PairedNode; + +function mergePairedNodeSources(params: { + liveNodes: NodeListNode[] | null; + pairedNodes: PairedNode[]; +}): NodesListEntry[] { + const merged = new Map(); + + for (const paired of params.pairedNodes) { + merged.set(paired.nodeId, { + ...paired, + paired: true, + connected: false, + }); + } + + 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[]; + usedFallback: boolean; +}> { + 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 }), + usedFallback: false, + }; + } catch (error) { + if (!shouldFallbackToPairList(error)) { + throw error; + } + return { + pending, + paired: mergePairedNodeSources({ liveNodes: null, pairedNodes: paired }), + usedFallback: true, + }; + } +} + export function registerNodesStatusCommands(nodes: Command) { nodesCallOpts( nodes @@ -304,35 +408,29 @@ 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, usedFallback } = await loadPairedNodesForList(opts); const { heading, muted, warn } = getNodesTheme(); const tableWidth = getTerminalTableWidth(); const now = Date.now(); const hasFilters = connectedOnly || sinceMs !== undefined; + if (usedFallback && hasFilters) { + throw new Error( + "node.list is unavailable on this gateway; --connected and --last-connected require live node data", + ); + } 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 +468,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..13e4d97e218 100644 --- a/src/cli/program.nodes-basic.e2e.test.ts +++ b/src/cli/program.nodes-basic.e2e.test.ts @@ -38,6 +38,12 @@ describe("cli program (nodes basics)", () => { await program.parseAsync(argv, { from: "user" }); } + async function expectRunProgramFailure(argv: string[], expectedError: RegExp) { + runtime.error.mockClear(); + await expect(program.parseAsync(argv, { from: "user" })).rejects.toThrow(/exit/i); + expect(runtime.error.mock.calls.some(([msg]) => expectedError.test(String(msg)))).toBe(true); + } + function getRuntimeOutput() { return runtime.log.mock.calls.map((c) => formatRuntimeLogCallArg(c[0])).join("\n"); } @@ -100,6 +106,205 @@ 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"); + } + 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 list and falls back to node.pair.list on invalid request", 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("invalid request"); + } + return { ok: true }; + }); + + await runProgram(["nodes", "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 on missing read scope", 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("missing scope: operator.read"); + } + return { ok: true }; + }); + + await runProgram(["nodes", "list"]); + + const output = getRuntimeOutput(); + expect(output).toContain("Pending: 0 · Paired: 1"); + expect(output).toContain("One"); + }); + + it("fails clearly for nodes list --connected 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"); + } + return { ok: true }; + }); + + await expectRunProgramFailure( + ["nodes", "list", "--connected"], + /node\.list is unavailable .* require live node data/i, + ); + }); + + it("preserves legacy paired metadata in nodes list --json fallback output", async () => { + callGateway.mockImplementation(async (...args: unknown[]) => { + const opts = (args[0] ?? {}) as { method?: string }; + if (opts.method === "node.pair.list") { + return { + pending: [], + paired: [ + { + nodeId: "n1", + token: "tok-1", + displayName: "One", + remoteIp: "10.0.0.1", + approvedAtMs: 123, + createdAtMs: 122, + }, + ], + }; + } + if (opts.method === "node.list") { + throw new Error("unknown method"); + } + return { ok: true }; + }); + + await runProgram(["nodes", "list", "--json"]); + + const lastLog = runtime.log.mock.calls.at(-1)?.[0]; + expect(typeof lastLog).toBe("string"); + const payload = JSON.parse(typeof lastLog === "string" ? lastLog : "") as { + paired: Array>; + }; + expect(payload.paired).toHaveLength(1); + expect(payload.paired[0]?.token).toBe("tok-1"); + expect(payload.paired[0]?.approvedAtMs).toBe(123); + expect(payload.paired[0]?.createdAtMs).toBe(122); + }); + it("runs nodes status --last-connected and filters by age", async () => { const now = Date.now(); callGateway.mockImplementation(async (...args: unknown[]) => {