From 70e491d062f988800f7b432b80c0c733c64066ab Mon Sep 17 00:00:00 2001 From: LXT <307322522@qq.com> Date: Fri, 20 Mar 2026 21:04:28 +0800 Subject: [PATCH 1/4] CLI: align nodes list with node status --- src/cli/nodes-cli/register.status.ts | 146 ++++++++++++++++++++---- src/cli/program.nodes-basic.e2e.test.ts | 76 ++++++++++++ 2 files changed, 199 insertions(+), 23 deletions(-) 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[]) => { From 3aa6d28c40bddc62cb4e8025dbf375addc1b8bca Mon Sep 17 00:00:00 2001 From: LXT <307322522@qq.com> Date: Fri, 20 Mar 2026 21:50:09 +0800 Subject: [PATCH 2/4] CLI: tighten nodes list legacy fallback --- src/cli/nodes-cli/register.status.ts | 27 ++++----- src/cli/program.nodes-basic.e2e.test.ts | 73 ++++++++++++++++++++++++- 2 files changed, 83 insertions(+), 17 deletions(-) diff --git a/src/cli/nodes-cli/register.status.ts b/src/cli/nodes-cli/register.status.ts index e78f8feb7bd..3d8e6a3598e 100644 --- a/src/cli/nodes-cli/register.status.ts +++ b/src/cli/nodes-cli/register.status.ts @@ -124,9 +124,6 @@ function messageFromError(error: unknown): string { 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") || @@ -135,9 +132,7 @@ function shouldFallbackToPairList(error: unknown): boolean { ); } -type NodesListEntry = NodeListNode & { - lastConnectedAtMs?: number; -}; +type NodesListEntry = NodeListNode & PairedNode; function mergePairedNodeSources(params: { liveNodes: NodeListNode[] | null; @@ -147,17 +142,9 @@ function mergePairedNodeSources(params: { 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, paired: true, connected: false, - lastConnectedAtMs: paired.lastConnectedAtMs, }); } @@ -189,6 +176,7 @@ function mergePairedNodeSources(params: { 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); @@ -197,6 +185,7 @@ async function loadPairedNodesForList(opts: NodesRpcOpts): Promise<{ return { pending, paired: mergePairedNodeSources({ liveNodes, pairedNodes: paired }), + usedFallback: false, }; } catch (error) { if (!shouldFallbackToPairList(error)) { @@ -205,6 +194,7 @@ async function loadPairedNodesForList(opts: NodesRpcOpts): Promise<{ return { pending, paired: mergePairedNodeSources({ liveNodes: null, pairedNodes: paired }), + usedFallback: true, }; } } @@ -416,11 +406,16 @@ export function registerNodesStatusCommands(nodes: Command) { await runNodesCommand("list", async () => { const connectedOnly = Boolean(opts.connected); const sinceMs = parseSinceMs(opts.lastConnected, "Invalid --last-connected"); - const { pending, paired } = await loadPairedNodesForList(opts); + 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 filteredPaired = paired.filter((node) => { if (connectedOnly) { diff --git a/src/cli/program.nodes-basic.e2e.test.ts b/src/cli/program.nodes-basic.e2e.test.ts index 3ca4a2c369c..b3fa45389ff 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"); } @@ -155,7 +161,7 @@ describe("cli program (nodes basics)", () => { }; } if (opts.method === "node.list") { - throw new Error("unknown method: node.list"); + throw new Error("unknown method"); } return { ok: true }; }); @@ -176,6 +182,71 @@ describe("cli program (nodes basics)", () => { 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[]) => { From e2602f6807d8ca097cda19e0b6b12a9f9ef17d12 Mon Sep 17 00:00:00 2001 From: LXT <307322522@qq.com> Date: Fri, 20 Mar 2026 22:11:56 +0800 Subject: [PATCH 3/4] CLI: widen nodes list legacy fallback --- src/cli/nodes-cli/register.status.ts | 1 + src/cli/program.nodes-basic.e2e.test.ts | 29 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/cli/nodes-cli/register.status.ts b/src/cli/nodes-cli/register.status.ts index 3d8e6a3598e..e7d0aa69505 100644 --- a/src/cli/nodes-cli/register.status.ts +++ b/src/cli/nodes-cli/register.status.ts @@ -127,6 +127,7 @@ function shouldFallbackToPairList(error: unknown): boolean { return ( message.includes("unknown method") || message.includes("method not found") || + message.includes("invalid request") || message.includes("not implemented") || message.includes("unsupported") ); diff --git a/src/cli/program.nodes-basic.e2e.test.ts b/src/cli/program.nodes-basic.e2e.test.ts index b3fa45389ff..80b5bb13305 100644 --- a/src/cli/program.nodes-basic.e2e.test.ts +++ b/src/cli/program.nodes-basic.e2e.test.ts @@ -182,6 +182,35 @@ describe("cli program (nodes basics)", () => { 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("fails clearly for nodes list --connected when node.list is unavailable", async () => { callGateway.mockImplementation(async (...args: unknown[]) => { const opts = (args[0] ?? {}) as { method?: string }; From 7fc87c533bd4f8b0d8bc0621e4ac4e102000990b Mon Sep 17 00:00:00 2001 From: LXT <307322522@qq.com> Date: Fri, 20 Mar 2026 23:40:15 +0800 Subject: [PATCH 4/4] CLI: preserve nodes list pairing fallback --- src/cli/nodes-cli/register.status.ts | 1 + src/cli/program.nodes-basic.e2e.test.ts | 29 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/cli/nodes-cli/register.status.ts b/src/cli/nodes-cli/register.status.ts index e7d0aa69505..2ae92afd04c 100644 --- a/src/cli/nodes-cli/register.status.ts +++ b/src/cli/nodes-cli/register.status.ts @@ -128,6 +128,7 @@ function shouldFallbackToPairList(error: unknown): boolean { 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") ); diff --git a/src/cli/program.nodes-basic.e2e.test.ts b/src/cli/program.nodes-basic.e2e.test.ts index 80b5bb13305..13e4d97e218 100644 --- a/src/cli/program.nodes-basic.e2e.test.ts +++ b/src/cli/program.nodes-basic.e2e.test.ts @@ -211,6 +211,35 @@ describe("cli program (nodes basics)", () => { 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 };