Merge 7fc87c533bd4f8b0d8bc0621e4ac4e102000990b into 6b4c24c2e55b5b4013277bd799525086f6a0c40f

This commit is contained in:
wangji0923 2026-03-21 04:45:11 +00:00 committed by GitHub
commit 612901b427
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 325 additions and 23 deletions

View File

@ -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<string, NodesListEntry>();
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<typeof parsePairingList>["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,

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");
}
@ -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<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[]) => {