refactor: node tools and canvas host url
This commit is contained in:
@@ -107,4 +107,52 @@ describe("canvas-cli coverage", () => {
|
||||
expect(runtimeErrors).toHaveLength(0);
|
||||
expect(runtimeLogs.join("\n")).toContain("ok");
|
||||
});
|
||||
|
||||
it("pushes A2UI text payload", async () => {
|
||||
runtimeLogs.length = 0;
|
||||
runtimeErrors.length = 0;
|
||||
callGateway.mockClear();
|
||||
|
||||
const { registerCanvasCli } = await import("./canvas-cli.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerCanvasCli(program);
|
||||
|
||||
await program.parseAsync(
|
||||
["canvas", "a2ui", "push", "--node", "mac-1", "--text", "Hello A2UI"],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
const invoke = callGateway.mock.calls.find(
|
||||
(call) => call[0]?.method === "node.invoke",
|
||||
)?.[0];
|
||||
|
||||
expect(invoke?.params?.command).toBe("canvas.a2ui.pushJSONL");
|
||||
expect(invoke?.params?.params?.jsonl).toContain("Hello A2UI");
|
||||
});
|
||||
|
||||
it("rejects invalid A2UI JSONL", async () => {
|
||||
runtimeLogs.length = 0;
|
||||
runtimeErrors.length = 0;
|
||||
callGateway.mockClear();
|
||||
|
||||
vi.resetModules();
|
||||
vi.doMock("node:fs/promises", () => ({
|
||||
default: { readFile: vi.fn(async () => "{broken") },
|
||||
}));
|
||||
|
||||
const { registerCanvasCli } = await import("./canvas-cli.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerCanvasCli(program);
|
||||
|
||||
await expect(
|
||||
program.parseAsync(
|
||||
["canvas", "a2ui", "push", "--node", "mac-1", "--jsonl", "/tmp/a2ui.jsonl"],
|
||||
{ from: "user" },
|
||||
),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(runtimeErrors.join("\n")).toContain("Invalid A2UI JSONL");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ type CanvasOpts = {
|
||||
height?: string;
|
||||
js?: string;
|
||||
jsonl?: string;
|
||||
text?: string;
|
||||
format?: string;
|
||||
maxWidth?: string;
|
||||
quality?: string;
|
||||
@@ -54,6 +55,16 @@ type PairingList = {
|
||||
paired: PairedNode[];
|
||||
};
|
||||
|
||||
const A2UI_ACTION_KEYS = [
|
||||
"beginRendering",
|
||||
"surfaceUpdate",
|
||||
"dataModelUpdate",
|
||||
"deleteSurface",
|
||||
"createSurface",
|
||||
] as const;
|
||||
|
||||
type A2UIVersion = "v0.8" | "v0.9";
|
||||
|
||||
const canvasCallOpts = (cmd: Command) =>
|
||||
cmd
|
||||
.option("--url <url>", "Gateway WebSocket URL", "ws://127.0.0.1:18789")
|
||||
@@ -104,6 +115,86 @@ function normalizeNodeKey(value: string) {
|
||||
.replace(/-+$/, "");
|
||||
}
|
||||
|
||||
function buildA2UITextJsonl(text: string) {
|
||||
const surfaceId = "main";
|
||||
const rootId = "root";
|
||||
const textId = "text";
|
||||
const payloads = [
|
||||
{
|
||||
surfaceUpdate: {
|
||||
surfaceId,
|
||||
components: [
|
||||
{
|
||||
id: rootId,
|
||||
component: { Column: { children: { explicitList: [textId] } } },
|
||||
},
|
||||
{
|
||||
id: textId,
|
||||
component: {
|
||||
Text: { text: { literalString: text }, usageHint: "body" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ beginRendering: { surfaceId, root: rootId } },
|
||||
];
|
||||
return payloads.map((payload) => JSON.stringify(payload)).join("\n");
|
||||
}
|
||||
|
||||
function validateA2UIJsonl(jsonl: string) {
|
||||
const lines = jsonl.split(/\r?\n/);
|
||||
const errors: string[] = [];
|
||||
let sawV08 = false;
|
||||
let sawV09 = false;
|
||||
let messageCount = 0;
|
||||
|
||||
lines.forEach((line, idx) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return;
|
||||
messageCount += 1;
|
||||
let obj: unknown;
|
||||
try {
|
||||
obj = JSON.parse(trimmed) as unknown;
|
||||
} catch (err) {
|
||||
errors.push(`line ${idx + 1}: ${String(err)}`);
|
||||
return;
|
||||
}
|
||||
if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
|
||||
errors.push(`line ${idx + 1}: expected JSON object`);
|
||||
return;
|
||||
}
|
||||
const record = obj as Record<string, unknown>;
|
||||
const actionKeys = A2UI_ACTION_KEYS.filter((key) => key in record);
|
||||
if (actionKeys.length !== 1) {
|
||||
errors.push(
|
||||
`line ${idx + 1}: expected exactly one action key (${A2UI_ACTION_KEYS.join(
|
||||
", ",
|
||||
)})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (actionKeys[0] === "createSurface") {
|
||||
sawV09 = true;
|
||||
} else {
|
||||
sawV08 = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (messageCount === 0) {
|
||||
errors.push("no JSONL messages found");
|
||||
}
|
||||
if (sawV08 && sawV09) {
|
||||
errors.push("mixed A2UI v0.8 and v0.9 messages in one file");
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Invalid A2UI JSONL:\n- ${errors.join("\n- ")}`);
|
||||
}
|
||||
|
||||
const version: A2UIVersion = sawV09 ? "v0.9" : "v0.8";
|
||||
return { version, messageCount };
|
||||
}
|
||||
|
||||
async function loadNodes(opts: CanvasOpts): Promise<NodeListNode[]> {
|
||||
try {
|
||||
const res = (await callGatewayCli("node.list", opts, {})) as unknown;
|
||||
@@ -389,14 +480,30 @@ export function registerCanvasCli(program: Command) {
|
||||
.command("push")
|
||||
.description("Push A2UI JSONL to the canvas")
|
||||
.option("--jsonl <path>", "Path to JSONL payload")
|
||||
.option("--text <text>", "Render a quick A2UI text payload")
|
||||
.option("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||
.action(async (opts: CanvasOpts) => {
|
||||
try {
|
||||
if (!opts.jsonl) throw new Error("missing --jsonl");
|
||||
const jsonl = await fs.readFile(String(opts.jsonl), "utf8");
|
||||
const hasJsonl = Boolean(opts.jsonl);
|
||||
const hasText = typeof opts.text === "string";
|
||||
if (hasJsonl === hasText) {
|
||||
throw new Error("provide exactly one of --jsonl or --text");
|
||||
}
|
||||
|
||||
const jsonl = hasText
|
||||
? buildA2UITextJsonl(String(opts.text ?? ""))
|
||||
: await fs.readFile(String(opts.jsonl), "utf8");
|
||||
const { version, messageCount } = validateA2UIJsonl(jsonl);
|
||||
if (version === "v0.9") {
|
||||
throw new Error(
|
||||
"Detected A2UI v0.9 JSONL (createSurface). Clawdis currently supports v0.8 only.",
|
||||
);
|
||||
}
|
||||
await invokeCanvas(opts, "canvas.a2ui.pushJSONL", { jsonl });
|
||||
if (!opts.json) {
|
||||
defaultRuntime.log("canvas a2ui push ok");
|
||||
defaultRuntime.log(
|
||||
`canvas a2ui push ok (v0.8, ${messageCount} message${messageCount === 1 ? "" : "s"})`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`canvas a2ui push failed: ${String(err)}`);
|
||||
|
||||
@@ -38,6 +38,7 @@ type NodesRpcOpts = {
|
||||
sound?: string;
|
||||
priority?: string;
|
||||
delivery?: string;
|
||||
name?: string;
|
||||
facing?: string;
|
||||
format?: string;
|
||||
maxWidth?: string;
|
||||
@@ -478,6 +479,37 @@ export function registerNodesCli(program: Command) {
|
||||
}),
|
||||
);
|
||||
|
||||
nodesCallOpts(
|
||||
nodes
|
||||
.command("rename")
|
||||
.description("Rename a paired node (display name override)")
|
||||
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||
.requiredOption("--name <displayName>", "New display name")
|
||||
.action(async (opts: NodesRpcOpts) => {
|
||||
try {
|
||||
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
|
||||
const name = String(opts.name ?? "").trim();
|
||||
if (!nodeId || !name) {
|
||||
defaultRuntime.error("--node and --name required");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const result = await callGatewayCli("node.rename", opts, {
|
||||
nodeId,
|
||||
displayName: name,
|
||||
});
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`node rename ok: ${nodeId} -> ${name}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`nodes rename failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
nodesCallOpts(
|
||||
nodes
|
||||
.command("invoke")
|
||||
|
||||
@@ -60,6 +60,8 @@ import {
|
||||
NodePairRequestParamsSchema,
|
||||
type NodePairVerifyParams,
|
||||
NodePairVerifyParamsSchema,
|
||||
type NodeRenameParams,
|
||||
NodeRenameParamsSchema,
|
||||
PROTOCOL_VERSION,
|
||||
type PresenceEntry,
|
||||
PresenceEntrySchema,
|
||||
@@ -135,6 +137,9 @@ export const validateNodePairRejectParams = ajv.compile<NodePairRejectParams>(
|
||||
export const validateNodePairVerifyParams = ajv.compile<NodePairVerifyParams>(
|
||||
NodePairVerifyParamsSchema,
|
||||
);
|
||||
export const validateNodeRenameParams = ajv.compile<NodeRenameParams>(
|
||||
NodeRenameParamsSchema,
|
||||
);
|
||||
export const validateNodeListParams =
|
||||
ajv.compile<NodeListParams>(NodeListParamsSchema);
|
||||
export const validateNodeDescribeParams = ajv.compile<NodeDescribeParams>(
|
||||
|
||||
@@ -254,6 +254,11 @@ export const NodePairVerifyParamsSchema = Type.Object(
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const NodeRenameParamsSchema = Type.Object(
|
||||
{ nodeId: NonEmptyString, displayName: NonEmptyString },
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const NodeListParamsSchema = Type.Object(
|
||||
{},
|
||||
{ additionalProperties: false },
|
||||
@@ -652,6 +657,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
||||
NodePairApproveParams: NodePairApproveParamsSchema,
|
||||
NodePairRejectParams: NodePairRejectParamsSchema,
|
||||
NodePairVerifyParams: NodePairVerifyParamsSchema,
|
||||
NodeRenameParams: NodeRenameParamsSchema,
|
||||
NodeListParams: NodeListParamsSchema,
|
||||
NodeDescribeParams: NodeDescribeParamsSchema,
|
||||
NodeInvokeParams: NodeInvokeParamsSchema,
|
||||
@@ -707,6 +713,7 @@ export type NodePairListParams = Static<typeof NodePairListParamsSchema>;
|
||||
export type NodePairApproveParams = Static<typeof NodePairApproveParamsSchema>;
|
||||
export type NodePairRejectParams = Static<typeof NodePairRejectParamsSchema>;
|
||||
export type NodePairVerifyParams = Static<typeof NodePairVerifyParamsSchema>;
|
||||
export type NodeRenameParams = Static<typeof NodeRenameParamsSchema>;
|
||||
export type NodeListParams = Static<typeof NodeListParamsSchema>;
|
||||
export type NodeDescribeParams = Static<typeof NodeDescribeParamsSchema>;
|
||||
export type NodeInvokeParams = Static<typeof NodeInvokeParamsSchema>;
|
||||
|
||||
@@ -76,6 +76,7 @@ import { isVerbose } from "../globals.js";
|
||||
import { onAgentEvent } from "../infra/agent-events.js";
|
||||
import { startGatewayBonjourAdvertiser } from "../infra/bonjour.js";
|
||||
import { startNodeBridgeServer } from "../infra/bridge/server.js";
|
||||
import { resolveCanvasHostUrl } from "../infra/canvas-host-url.js";
|
||||
import { GatewayLockError } from "../infra/gateway-lock.js";
|
||||
import {
|
||||
getLastHeartbeatEvent,
|
||||
@@ -90,6 +91,7 @@ import { getMachineDisplayName } from "../infra/machine-name.js";
|
||||
import {
|
||||
approveNodePairing,
|
||||
listNodePairing,
|
||||
renamePairedNode,
|
||||
rejectNodePairing,
|
||||
requestNodePairing,
|
||||
verifyNodeToken,
|
||||
@@ -379,6 +381,7 @@ import {
|
||||
validateNodePairRejectParams,
|
||||
validateNodePairRequestParams,
|
||||
validateNodePairVerifyParams,
|
||||
validateNodeRenameParams,
|
||||
validateProvidersStatusParams,
|
||||
validateRequestFrame,
|
||||
validateSendParams,
|
||||
@@ -485,6 +488,7 @@ const METHODS = [
|
||||
"node.pair.approve",
|
||||
"node.pair.reject",
|
||||
"node.pair.verify",
|
||||
"node.rename",
|
||||
"node.list",
|
||||
"node.describe",
|
||||
"node.invoke",
|
||||
@@ -609,39 +613,6 @@ function buildSnapshot(): Snapshot {
|
||||
const MAX_PAYLOAD_BYTES = 512 * 1024; // cap incoming frame size
|
||||
const MAX_BUFFERED_BYTES = 1.5 * 1024 * 1024; // per-connection send buffer limit
|
||||
|
||||
function deriveCanvasHostUrl(
|
||||
req: IncomingMessage | undefined,
|
||||
canvasPort: number | undefined,
|
||||
hostOverride?: string,
|
||||
) {
|
||||
if (!req || !canvasPort) return undefined;
|
||||
const hostHeader = req.headers.host?.trim();
|
||||
const forwardedProto =
|
||||
typeof req.headers["x-forwarded-proto"] === "string"
|
||||
? req.headers["x-forwarded-proto"]
|
||||
: Array.isArray(req.headers["x-forwarded-proto"])
|
||||
? req.headers["x-forwarded-proto"][0]
|
||||
: undefined;
|
||||
const scheme = forwardedProto === "https" ? "https" : "http";
|
||||
|
||||
let host = (hostOverride ?? "").trim();
|
||||
if (host === "0.0.0.0" || host === "::") host = "";
|
||||
if (!host && hostHeader) {
|
||||
try {
|
||||
const parsed = new URL(`http://${hostHeader}`);
|
||||
host = parsed.hostname;
|
||||
} catch {
|
||||
host = "";
|
||||
}
|
||||
}
|
||||
if (!host) {
|
||||
host = req.socket?.localAddress?.trim() ?? "";
|
||||
}
|
||||
if (!host) return undefined;
|
||||
|
||||
const formattedHost = host.includes(":") ? `[${host}]` : host;
|
||||
return `${scheme}://${formattedHost}:${canvasPort}`;
|
||||
}
|
||||
const MAX_CHAT_HISTORY_MESSAGES_BYTES = 6 * 1024 * 1024; // keep history responses comfortably under client WS limits
|
||||
const HANDSHAKE_TIMEOUT_MS = 10_000;
|
||||
const TICK_INTERVAL_MS = 30_000;
|
||||
@@ -3519,11 +3490,13 @@ export async function startGatewayServer(
|
||||
bridgeHost && bridgeHost !== "0.0.0.0" && bridgeHost !== "::"
|
||||
? bridgeHost
|
||||
: undefined;
|
||||
const canvasHostUrl = deriveCanvasHostUrl(
|
||||
upgradeReq,
|
||||
canvasHostPortForWs,
|
||||
canvasHostServer ? canvasHostOverride : undefined,
|
||||
);
|
||||
const canvasHostUrl = resolveCanvasHostUrl({
|
||||
canvasPort: canvasHostPortForWs,
|
||||
hostOverride: canvasHostServer ? canvasHostOverride : undefined,
|
||||
requestHost: upgradeReq.headers.host,
|
||||
forwardedProto: upgradeReq.headers["x-forwarded-proto"],
|
||||
localAddress: upgradeReq.socket?.localAddress,
|
||||
});
|
||||
logWs("in", "open", { connId, remoteAddr });
|
||||
const isWebchatConnect = (params: ConnectParams | null | undefined) =>
|
||||
params?.client?.mode === "webchat" ||
|
||||
@@ -5438,6 +5411,59 @@ export async function startGatewayServer(
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "node.rename": {
|
||||
const params = (req.params ?? {}) as Record<string, unknown>;
|
||||
if (!validateNodeRenameParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid node.rename params: ${formatValidationErrors(validateNodeRenameParams.errors)}`,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
const { nodeId, displayName } = params as {
|
||||
nodeId: string;
|
||||
displayName: string;
|
||||
};
|
||||
try {
|
||||
const trimmed = displayName.trim();
|
||||
if (!trimmed) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"displayName required",
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
const updated = await renamePairedNode(nodeId, trimmed);
|
||||
if (!updated) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "unknown nodeId"),
|
||||
);
|
||||
break;
|
||||
}
|
||||
respond(
|
||||
true,
|
||||
{ nodeId: updated.nodeId, displayName: updated.displayName },
|
||||
undefined,
|
||||
);
|
||||
} catch (err) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "node.list": {
|
||||
const params = (req.params ?? {}) as Record<string, unknown>;
|
||||
if (!validateNodeListParams(params)) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
|
||||
import { resolveCanvasHostUrl } from "../canvas-host-url.js";
|
||||
import {
|
||||
getPairedNode,
|
||||
listNodePairing,
|
||||
@@ -188,23 +189,13 @@ export async function startNodeBridgeServer(
|
||||
? opts.serverName.trim()
|
||||
: os.hostname();
|
||||
|
||||
const isLoopbackHost = (host: string) => {
|
||||
const normalized = host.trim().toLowerCase();
|
||||
if (normalized === "localhost") return true;
|
||||
if (normalized === "::1") return true;
|
||||
if (normalized === "0.0.0.0" || normalized === "::") return true;
|
||||
return normalized.startsWith("127.");
|
||||
};
|
||||
|
||||
const buildCanvasHostUrl = (socket: net.Socket) => {
|
||||
const port = opts.canvasHostPort;
|
||||
if (!port) return undefined;
|
||||
const localHost = socket.localAddress?.trim() ?? "";
|
||||
const override = opts.canvasHostHost?.trim() ?? "";
|
||||
const host = !localHost || isLoopbackHost(localHost) ? override : localHost;
|
||||
if (!host) return undefined;
|
||||
const formatted = host.includes(":") ? `[${host}]` : host;
|
||||
return `http://${formatted}:${port}`;
|
||||
return resolveCanvasHostUrl({
|
||||
canvasPort: opts.canvasHostPort,
|
||||
hostOverride: opts.canvasHostHost,
|
||||
localAddress: socket.localAddress,
|
||||
scheme: "http",
|
||||
});
|
||||
};
|
||||
|
||||
type ConnectionState = {
|
||||
|
||||
64
src/infra/canvas-host-url.ts
Normal file
64
src/infra/canvas-host-url.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
type HostSource = string | null | undefined;
|
||||
|
||||
type CanvasHostUrlParams = {
|
||||
canvasPort?: number;
|
||||
hostOverride?: HostSource;
|
||||
requestHost?: HostSource;
|
||||
forwardedProto?: HostSource | HostSource[];
|
||||
localAddress?: HostSource;
|
||||
scheme?: "http" | "https";
|
||||
};
|
||||
|
||||
const isLoopbackHost = (value: string) => {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized) return false;
|
||||
if (normalized === "localhost") return true;
|
||||
if (normalized === "::1") return true;
|
||||
if (normalized === "0.0.0.0" || normalized === "::") return true;
|
||||
return normalized.startsWith("127.");
|
||||
};
|
||||
|
||||
const normalizeHost = (value: HostSource, rejectLoopback: boolean) => {
|
||||
if (!value) return "";
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "";
|
||||
if (rejectLoopback && isLoopbackHost(trimmed)) return "";
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const parseHostHeader = (value: HostSource) => {
|
||||
if (!value) return "";
|
||||
try {
|
||||
return new URL(`http://${String(value).trim()}`).hostname;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const parseForwardedProto = (value: HostSource | HostSource[]) => {
|
||||
if (Array.isArray(value)) return value[0];
|
||||
return value;
|
||||
};
|
||||
|
||||
export function resolveCanvasHostUrl(params: CanvasHostUrlParams) {
|
||||
const port = params.canvasPort;
|
||||
if (!port) return undefined;
|
||||
|
||||
const scheme =
|
||||
params.scheme ??
|
||||
(parseForwardedProto(params.forwardedProto)?.trim() === "https"
|
||||
? "https"
|
||||
: "http");
|
||||
|
||||
const override = normalizeHost(params.hostOverride, true);
|
||||
const requestHost = normalizeHost(parseHostHeader(params.requestHost), !!override);
|
||||
const localAddress = normalizeHost(
|
||||
params.localAddress,
|
||||
Boolean(override || requestHost),
|
||||
);
|
||||
|
||||
const host = override || requestHost || localAddress;
|
||||
if (!host) return undefined;
|
||||
const formatted = host.includes(":") ? `[${host}]` : host;
|
||||
return `${scheme}://${formatted}:${port}`;
|
||||
}
|
||||
@@ -292,3 +292,22 @@ export async function updatePairedNodeMetadata(
|
||||
await persistState(state, baseDir);
|
||||
});
|
||||
}
|
||||
|
||||
export async function renamePairedNode(
|
||||
nodeId: string,
|
||||
displayName: string,
|
||||
baseDir?: string,
|
||||
): Promise<NodePairingPairedNode | null> {
|
||||
return await withLock(async () => {
|
||||
const state = await loadState(baseDir);
|
||||
const normalized = normalizeNodeId(nodeId);
|
||||
const existing = state.pairedByNodeId[normalized];
|
||||
if (!existing) return null;
|
||||
const trimmed = displayName.trim();
|
||||
if (!trimmed) throw new Error("displayName required");
|
||||
const next: NodePairingPairedNode = { ...existing, displayName: trimmed };
|
||||
state.pairedByNodeId[normalized] = next;
|
||||
await persistState(state, baseDir);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user