CLI: tighten nodes list legacy fallback

This commit is contained in:
LXT 2026-03-20 21:50:09 +08:00
parent 70e491d062
commit 3aa6d28c40
2 changed files with 83 additions and 17 deletions

View File

@ -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<typeof parsePairingList>["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) {

View File

@ -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<Record<string, unknown>>;
};
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[]) => {