diff --git a/src/cli/nodes-cli.ts b/src/cli/nodes-cli.ts index 03e3e7656..669ab5c51 100644 --- a/src/cli/nodes-cli.ts +++ b/src/cli/nodes-cli.ts @@ -1,5 +1,5 @@ import type { Command } from "commander"; -import { callGateway } from "../gateway/call.js"; +import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; import { defaultRuntime } from "../runtime.js"; type NodesRpcOpts = { @@ -11,6 +11,16 @@ type NodesRpcOpts = { command?: string; params?: string; invokeTimeout?: string; + idempotencyKey?: string; +}; + +type NodeListNode = { + nodeId: string; + displayName?: string; + platform?: string; + version?: string; + remoteIp?: string; + connected?: boolean; }; type PendingRequest = { @@ -40,11 +50,15 @@ type PairingList = { paired: PairedNode[]; }; -const nodesCallOpts = (cmd: Command) => +const nodesCallOpts = (cmd: Command, defaults?: { timeoutMs?: number }) => cmd .option("--url ", "Gateway WebSocket URL", "ws://127.0.0.1:18789") .option("--token ", "Gateway token (if required)") - .option("--timeout ", "Timeout in ms", "10000") + .option( + "--timeout ", + "Timeout in ms", + String(defaults?.timeoutMs ?? 10_000), + ) .option("--json", "Output JSON", false); const callGatewayCli = async ( @@ -85,6 +99,67 @@ function parsePairingList(value: unknown): PairingList { return { pending, paired }; } +function parseNodeList(value: unknown): NodeListNode[] { + const obj = + typeof value === "object" && value !== null + ? (value as Record) + : {}; + return Array.isArray(obj.nodes) ? (obj.nodes as NodeListNode[]) : []; +} + +function normalizeNodeKey(value: string) { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+/, "") + .replace(/-+$/, ""); +} + +async function resolveNodeId(opts: NodesRpcOpts, query: string) { + const q = String(query ?? "").trim(); + if (!q) throw new Error("node required"); + + let nodes: NodeListNode[] = []; + try { + const res = (await callGatewayCli("node.list", opts, {})) as unknown; + nodes = parseNodeList(res); + } catch { + const res = (await callGatewayCli("node.pair.list", opts, {})) as unknown; + const { paired } = parsePairingList(res); + nodes = paired.map((n) => ({ + nodeId: n.nodeId, + displayName: n.displayName, + platform: n.platform, + version: n.version, + 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(", ")})`, + ); +} + export function registerNodesCli(program: Command) { const nodes = program .command("nodes") @@ -215,32 +290,38 @@ export function registerNodesCli(program: Command) { nodesCallOpts( nodes .command("invoke") - .description("Invoke a command on a connected node") - .requiredOption("--node ", "Node id (instanceId)") + .description("Invoke a command on a paired node") + .requiredOption("--node ", "Node id, name, or IP") .requiredOption("--command ", "Command (e.g. screen.eval)") - .option("--params ", "JSON object string for params") + .option("--params ", "JSON object string for params", "{}") .option( "--invoke-timeout ", "Node invoke timeout in ms (default 15000)", + "15000", ) + .option("--idempotency-key ", "Idempotency key (optional)") .action(async (opts: NodesRpcOpts) => { try { - const nodeId = String(opts.node ?? "").trim(); + const nodeId = await resolveNodeId(opts, String(opts.node ?? "")); const command = String(opts.command ?? "").trim(); if (!nodeId || !command) { defaultRuntime.error("--node and --command required"); defaultRuntime.exit(1); return; } - const params = opts.params - ? (JSON.parse(String(opts.params)) as unknown) - : undefined; + const params = JSON.parse(String(opts.params ?? "{}")) as unknown; const timeoutMs = opts.invokeTimeout ? Number.parseInt(String(opts.invokeTimeout), 10) : undefined; - const invokeParams: Record = { nodeId, command }; - if (params !== undefined) invokeParams.params = params; + const invokeParams: Record = { + nodeId, + command, + params, + idempotencyKey: String( + opts.idempotencyKey ?? randomIdempotencyKey(), + ), + }; if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) { invokeParams.timeoutMs = timeoutMs; } @@ -250,11 +331,13 @@ export function registerNodesCli(program: Command) { opts, invokeParams, ); + defaultRuntime.log(JSON.stringify(result, null, 2)); } catch (err) { defaultRuntime.error(`nodes invoke failed: ${String(err)}`); defaultRuntime.exit(1); } }), + { timeoutMs: 30_000 }, ); } diff --git a/src/cli/program.test.ts b/src/cli/program.test.ts index c255de4cc..84631a313 100644 --- a/src/cli/program.test.ts +++ b/src/cli/program.test.ts @@ -22,6 +22,7 @@ vi.mock("../provider-web.js", () => ({ })); vi.mock("../gateway/call.js", () => ({ callGateway, + randomIdempotencyKey: () => "idem-test", })); vi.mock("../webchat/server.js", () => ({ startWebChatServer, @@ -93,12 +94,24 @@ describe("cli program", () => { }); it("runs nodes invoke and calls node.invoke", async () => { - callGateway.mockResolvedValue({ - ok: true, - nodeId: "ios-node", - command: "screen.eval", - payload: { result: "ok" }, - }); + callGateway + .mockResolvedValueOnce({ + ts: Date.now(), + nodes: [ + { + nodeId: "ios-node", + displayName: "iOS Node", + remoteIp: "192.168.0.88", + connected: true, + }, + ], + }) + .mockResolvedValueOnce({ + ok: true, + nodeId: "ios-node", + command: "screen.eval", + payload: { result: "ok" }, + }); const program = buildProgram(); runtime.log.mockClear(); @@ -116,13 +129,20 @@ describe("cli program", () => { { from: "user" }, ); - expect(callGateway).toHaveBeenCalledWith( + expect(callGateway).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ method: "node.list", params: {} }), + ); + expect(callGateway).toHaveBeenNthCalledWith( + 2, expect.objectContaining({ method: "node.invoke", params: { nodeId: "ios-node", command: "screen.eval", params: { javaScript: "1+1" }, + timeoutMs: 15000, + idempotencyKey: "idem-test", }, }), ); diff --git a/src/gateway/client.ts b/src/gateway/client.ts index 9f4751095..5525a7fbe 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -51,7 +51,8 @@ export class GatewayClient { start() { if (this.closed) return; const url = this.opts.url ?? "ws://127.0.0.1:18789"; - this.ws = new WebSocket(url, { maxPayload: 512 * 1024 }); + // Allow node screen snapshots and other large responses. + this.ws = new WebSocket(url, { maxPayload: 10 * 1024 * 1024 }); this.ws.on("open", () => this.sendConnect()); this.ws.on("message", (data) => this.handleMessage(data.toString())); diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index ecec9a7ae..e4500a57d 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -38,6 +38,8 @@ import { HelloOkSchema, type NodeInvokeParams, NodeInvokeParamsSchema, + type NodeListParams, + NodeListParamsSchema, type NodePairApproveParams, NodePairApproveParamsSchema, type NodePairListParams, @@ -105,6 +107,8 @@ export const validateNodePairRejectParams = ajv.compile( export const validateNodePairVerifyParams = ajv.compile( NodePairVerifyParamsSchema, ); +export const validateNodeListParams = + ajv.compile(NodeListParamsSchema); export const validateNodeInvokeParams = ajv.compile( NodeInvokeParamsSchema, ); @@ -163,6 +167,7 @@ export { NodePairApproveParamsSchema, NodePairRejectParamsSchema, NodePairVerifyParamsSchema, + NodeListParamsSchema, NodeInvokeParamsSchema, SessionsListParamsSchema, SessionsPatchParamsSchema, @@ -205,6 +210,7 @@ export type { NodePairApproveParams, NodePairRejectParams, NodePairVerifyParams, + NodeListParams, NodeInvokeParams, SessionsListParams, SessionsPatchParams, diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index 34dae46c7..0d4816d69 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -243,12 +243,18 @@ export const NodePairVerifyParamsSchema = Type.Object( { additionalProperties: false }, ); +export const NodeListParamsSchema = Type.Object( + {}, + { additionalProperties: false }, +); + export const NodeInvokeParamsSchema = Type.Object( { nodeId: NonEmptyString, command: NonEmptyString, params: Type.Optional(Type.Unknown()), timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })), + idempotencyKey: NonEmptyString, }, { additionalProperties: false }, ); @@ -507,6 +513,7 @@ export const ProtocolSchemas: Record = { NodePairApproveParams: NodePairApproveParamsSchema, NodePairRejectParams: NodePairRejectParamsSchema, NodePairVerifyParams: NodePairVerifyParamsSchema, + NodeListParams: NodeListParamsSchema, NodeInvokeParams: NodeInvokeParamsSchema, SessionsListParams: SessionsListParamsSchema, SessionsPatchParams: SessionsPatchParamsSchema, @@ -545,6 +552,7 @@ export type NodePairListParams = Static; export type NodePairApproveParams = Static; export type NodePairRejectParams = Static; export type NodePairVerifyParams = Static; +export type NodeListParams = Static; export type NodeInvokeParams = Static; export type SessionsListParams = Static; export type SessionsPatchParams = Static; diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index ae6d9947f..dccc02c6a 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -399,6 +399,7 @@ describe("gateway server", () => { command: "screen.eval", params: { javaScript: "2+2" }, timeoutMs: 123, + idempotencyKey: "idem-1", }); expect(res.ok).toBe(true); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 5789de48f..8704bf0a9 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -101,6 +101,7 @@ import { validateCronStatusParams, validateCronUpdateParams, validateNodeInvokeParams, + validateNodeListParams, validateNodePairApproveParams, validateNodePairListParams, validateNodePairRejectParams, @@ -177,6 +178,7 @@ const METHODS = [ "node.pair.approve", "node.pair.reject", "node.pair.verify", + "node.list", "node.invoke", "cron.list", "cron.status", @@ -2056,6 +2058,49 @@ export async function startGatewayServer( } break; } + case "node.list": { + const params = (req.params ?? {}) as Record; + if (!validateNodeListParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid node.list params: ${formatValidationErrors(validateNodeListParams.errors)}`, + ), + ); + break; + } + + try { + const list = await listNodePairing(); + const connected = bridge?.listConnected?.() ?? []; + const connectedById = new Map( + connected.map((n) => [n.nodeId, n]), + ); + + const nodes = list.paired.map((n) => { + const live = connectedById.get(n.nodeId); + return { + nodeId: n.nodeId, + displayName: live?.displayName ?? n.displayName, + platform: live?.platform ?? n.platform, + version: live?.version ?? n.version, + remoteIp: live?.remoteIp ?? n.remoteIp, + connected: Boolean(live), + }; + }); + + respond(true, { ts: Date.now(), nodes }, undefined); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), + ); + } + break; + } case "node.invoke": { const params = (req.params ?? {}) as Record; if (!validateNodeInvokeParams(params)) { @@ -2082,6 +2127,7 @@ export async function startGatewayServer( command: string; params?: unknown; timeoutMs?: number; + idempotencyKey: string; }; const nodeId = String(p.nodeId ?? "").trim(); const command = String(p.command ?? "").trim();