diff --git a/src/agents/tools/nodes-utils.ts b/src/agents/tools/nodes-utils.ts index da1d9116ab..121a65400c 100644 --- a/src/agents/tools/nodes-utils.ts +++ b/src/agents/tools/nodes-utils.ts @@ -1,3 +1,4 @@ +import { resolveNodeIdFromCandidates } from "../../shared/node-match.js"; import { callGatewayTool, type GatewayCallOptions } from "./gateway.js"; export type NodeListNode = { @@ -61,14 +62,6 @@ function parsePairingList(value: unknown): PairingList { return { pending, paired }; } -function normalizeNodeKey(value: string) { - return value - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+/, "") - .replace(/-+$/, ""); -} - async function loadNodes(opts: GatewayCallOptions): Promise { try { const res = await callGatewayTool("node.list", opts, {}); @@ -131,40 +124,7 @@ export function resolveNodeIdFromList( } throw new Error("node required"); } - - const qNorm = normalizeNodeKey(q); - const matches = nodes.filter((n) => { - if (n.nodeId === q) { - return true; - } - if (typeof n.remoteIp === "string" && n.remoteIp === q) { - return true; - } - const name = typeof n.displayName === "string" ? n.displayName : ""; - if (name && normalizeNodeKey(name) === qNorm) { - return true; - } - if (q.length >= 6 && n.nodeId.startsWith(q)) { - return true; - } - return false; - }); - - if (matches.length === 1) { - return matches[0].nodeId; - } - if (matches.length === 0) { - const known = nodes - .map((n) => n.displayName || n.remoteIp || n.nodeId) - .filter(Boolean) - .join(", "); - throw new Error(`unknown node: ${q}${known ? ` (known: ${known})` : ""}`); - } - throw new Error( - `ambiguous node: ${q} (matches: ${matches - .map((n) => n.displayName || n.remoteIp || n.nodeId) - .join(", ")})`, - ); + return resolveNodeIdFromCandidates(nodes, q); } export async function resolveNodeId( diff --git a/src/cli/nodes-cli/rpc.ts b/src/cli/nodes-cli/rpc.ts index e5593fd679..194c5386cf 100644 --- a/src/cli/nodes-cli/rpc.ts +++ b/src/cli/nodes-cli/rpc.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import type { NodeListNode, NodesRpcOpts } from "./types.js"; import { callGateway } from "../../gateway/call.js"; +import { resolveNodeIdFromCandidates } from "../../shared/node-match.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; import { withProgress } from "../progress.js"; import { parseNodeList, parsePairingList } from "./format.js"; @@ -47,14 +48,6 @@ export function unauthorizedHintForMessage(message: string): string | null { return null; } -function normalizeNodeKey(value: string) { - return value - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+/, "") - .replace(/-+$/, ""); -} - export async function resolveNodeId(opts: NodesRpcOpts, query: string) { const q = String(query ?? "").trim(); if (!q) { @@ -76,38 +69,5 @@ export async function resolveNodeId(opts: NodesRpcOpts, query: string) { remoteIp: n.remoteIp, })); } - - const qNorm = normalizeNodeKey(q); - const matches = nodes.filter((n) => { - if (n.nodeId === q) { - return true; - } - if (typeof n.remoteIp === "string" && n.remoteIp === q) { - return true; - } - const name = typeof n.displayName === "string" ? n.displayName : ""; - if (name && normalizeNodeKey(name) === qNorm) { - return true; - } - if (q.length >= 6 && n.nodeId.startsWith(q)) { - return true; - } - return false; - }); - - if (matches.length === 1) { - return matches[0].nodeId; - } - if (matches.length === 0) { - const known = nodes - .map((n) => n.displayName || n.remoteIp || n.nodeId) - .filter(Boolean) - .join(", "); - throw new Error(`unknown node: ${q}${known ? ` (known: ${known})` : ""}`); - } - throw new Error( - `ambiguous node: ${q} (matches: ${matches - .map((n) => n.displayName || n.remoteIp || n.nodeId) - .join(", ")})`, - ); + return resolveNodeIdFromCandidates(nodes, q); } diff --git a/src/shared/node-match.test.ts b/src/shared/node-match.test.ts new file mode 100644 index 0000000000..d8d33f1bac --- /dev/null +++ b/src/shared/node-match.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { resolveNodeIdFromCandidates } from "./node-match.js"; + +describe("resolveNodeIdFromCandidates", () => { + it("matches nodeId", () => { + expect( + resolveNodeIdFromCandidates( + [ + { nodeId: "mac-123", displayName: "Mac Studio", remoteIp: "100.0.0.1" }, + { nodeId: "pi-456", displayName: "Raspberry Pi", remoteIp: "100.0.0.2" }, + ], + "pi-456", + ), + ).toBe("pi-456"); + }); + + it("matches displayName using normalization", () => { + expect( + resolveNodeIdFromCandidates([{ nodeId: "mac-123", displayName: "Mac Studio" }], "mac studio"), + ).toBe("mac-123"); + }); + + it("matches nodeId prefix (>=6 chars)", () => { + expect(resolveNodeIdFromCandidates([{ nodeId: "mac-abcdef" }], "mac-ab")).toBe("mac-abcdef"); + }); + + it("throws unknown node with known list", () => { + expect(() => + resolveNodeIdFromCandidates( + [ + { nodeId: "mac-123", displayName: "Mac Studio", remoteIp: "100.0.0.1" }, + { nodeId: "pi-456" }, + ], + "nope", + ), + ).toThrow(/unknown node: nope.*known: /); + }); + + it("throws ambiguous node with matches list", () => { + expect(() => + resolveNodeIdFromCandidates([{ nodeId: "mac-abcdef" }, { nodeId: "mac-abc999" }], "mac-abc"), + ).toThrow(/ambiguous node: mac-abc.*matches:/); + }); +}); diff --git a/src/shared/node-match.ts b/src/shared/node-match.ts new file mode 100644 index 0000000000..cc4f523399 --- /dev/null +++ b/src/shared/node-match.ts @@ -0,0 +1,69 @@ +export type NodeMatchCandidate = { + nodeId: string; + displayName?: string; + remoteIp?: string; +}; + +export function normalizeNodeKey(value: string) { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+/, "") + .replace(/-+$/, ""); +} + +function listKnownNodes(nodes: NodeMatchCandidate[]): string { + return nodes + .map((n) => n.displayName || n.remoteIp || n.nodeId) + .filter(Boolean) + .join(", "); +} + +export function resolveNodeMatches( + nodes: NodeMatchCandidate[], + query: string, +): NodeMatchCandidate[] { + const q = query.trim(); + if (!q) { + return []; + } + + const qNorm = normalizeNodeKey(q); + return nodes.filter((n) => { + if (n.nodeId === q) { + return true; + } + if (typeof n.remoteIp === "string" && n.remoteIp === q) { + return true; + } + const name = typeof n.displayName === "string" ? n.displayName : ""; + if (name && normalizeNodeKey(name) === qNorm) { + return true; + } + if (q.length >= 6 && n.nodeId.startsWith(q)) { + return true; + } + return false; + }); +} + +export function resolveNodeIdFromCandidates(nodes: NodeMatchCandidate[], query: string): string { + const q = query.trim(); + if (!q) { + throw new Error("node required"); + } + + const matches = resolveNodeMatches(nodes, q); + if (matches.length === 1) { + return matches[0]?.nodeId ?? ""; + } + if (matches.length === 0) { + const known = listKnownNodes(nodes); + throw new Error(`unknown node: ${q}${known ? ` (known: ${known})` : ""}`); + } + throw new Error( + `ambiguous node: ${q} (matches: ${matches + .map((n) => n.displayName || n.remoteIp || n.nodeId) + .join(", ")})`, + ); +}