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] 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[]) => {