libp2p-mesh 2026.6.7 → 2026.6.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +13 -4
- package/dist/src/plugin.js +8 -1
- package/dist/src/wizard.js +159 -155
- package/index.ts +17 -4
- package/package.json +2 -2
- package/src/plugin.ts +10 -1
- package/src/wizard.ts +192 -197
package/dist/index.js
CHANGED
|
@@ -166,10 +166,19 @@ export default definePluginEntry({
|
|
|
166
166
|
description: "P2P network for cross-instance agent communication via libp2p.",
|
|
167
167
|
configSchema: createLibp2pMeshConfigSchema(),
|
|
168
168
|
register: (api) => {
|
|
169
|
+
if (api.registrationMode === "cli-metadata" ||
|
|
170
|
+
api.registrationMode === "discovery" ||
|
|
171
|
+
api.registrationMode === "full") {
|
|
172
|
+
api.registerCli(registerLibp2pMeshCli, {
|
|
173
|
+
commands: ["libp2p-mesh"],
|
|
174
|
+
});
|
|
175
|
+
if (api.registrationMode === "cli-metadata") {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (api.registrationMode !== "full") {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
169
182
|
registerLibp2pMesh(api);
|
|
170
|
-
// 5. Register CLI commands (setup wizard + config management)
|
|
171
|
-
api.registerCli(registerLibp2pMeshCli, {
|
|
172
|
-
commands: ["libp2p-mesh"],
|
|
173
|
-
});
|
|
174
183
|
},
|
|
175
184
|
});
|
package/dist/src/plugin.js
CHANGED
|
@@ -16,7 +16,14 @@ export function registerLibp2pMesh(api) {
|
|
|
16
16
|
const store = createInstancePeerStore({ logger: api.logger });
|
|
17
17
|
const delivery = createOpenClawRuntimeInboundDelivery({
|
|
18
18
|
config: api.config,
|
|
19
|
-
loadAdapter: (channel) =>
|
|
19
|
+
loadAdapter: async (channel) => {
|
|
20
|
+
const loadAdapter = api.runtime?.channel?.outbound?.loadAdapter;
|
|
21
|
+
if (typeof loadAdapter !== "function") {
|
|
22
|
+
api.logger.warn?.(`[libp2p-mesh] runtime channel outbound adapter is unavailable; cannot deliver to ${channel}`);
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
return loadAdapter(channel);
|
|
26
|
+
},
|
|
20
27
|
logger: api.logger,
|
|
21
28
|
});
|
|
22
29
|
const router = createInstanceRouter({
|
package/dist/src/wizard.js
CHANGED
|
@@ -19,19 +19,22 @@ export function validateMultiaddr(raw) {
|
|
|
19
19
|
function isTTY() {
|
|
20
20
|
return Boolean(process.stdout.isTTY && process.stdin.isTTY);
|
|
21
21
|
}
|
|
22
|
+
function supportsInteractiveSelect() {
|
|
23
|
+
return isTTY() && process.platform !== "win32";
|
|
24
|
+
}
|
|
22
25
|
/**
|
|
23
|
-
*
|
|
26
|
+
* Clear entire current line: carriage-return to col 0, then erase to end.
|
|
24
27
|
*/
|
|
25
|
-
function
|
|
26
|
-
|
|
27
|
-
return;
|
|
28
|
-
process.stdout.write(`\x1b[${n}A`);
|
|
28
|
+
function clearLine() {
|
|
29
|
+
process.stdout.write("\r\x1b[K");
|
|
29
30
|
}
|
|
30
31
|
/**
|
|
31
|
-
*
|
|
32
|
+
* Move cursor up `n` lines.
|
|
32
33
|
*/
|
|
33
|
-
function
|
|
34
|
-
|
|
34
|
+
function cursorUp(n) {
|
|
35
|
+
if (n <= 0)
|
|
36
|
+
return;
|
|
37
|
+
process.stdout.write(`\x1b[${n}A`);
|
|
35
38
|
}
|
|
36
39
|
function renderChoices(choices, selectedIdx) {
|
|
37
40
|
for (let i = 0; i < choices.length; i++) {
|
|
@@ -47,11 +50,16 @@ function renderChoices(choices, selectedIdx) {
|
|
|
47
50
|
}
|
|
48
51
|
}
|
|
49
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Re-render the choice list in-place. Assumes cursor is immediately below
|
|
55
|
+
* the last choice line.
|
|
56
|
+
*/
|
|
50
57
|
function reRenderChoices(choices, selectedIdx) {
|
|
51
58
|
const n = choices.length;
|
|
52
|
-
|
|
59
|
+
// Jump back to the first choice line
|
|
60
|
+
cursorUp(n);
|
|
53
61
|
for (let i = 0; i < n; i++) {
|
|
54
|
-
|
|
62
|
+
clearLine(); // \r\x1b[K -- always starts at col 0
|
|
55
63
|
const c = choices[i];
|
|
56
64
|
const pointer = i === selectedIdx ? "❯" : " ";
|
|
57
65
|
const hint = c.hint ? `(${c.hint})` : "";
|
|
@@ -63,15 +71,20 @@ function reRenderChoices(choices, selectedIdx) {
|
|
|
63
71
|
process.stdout.write(`${line}\n`);
|
|
64
72
|
}
|
|
65
73
|
}
|
|
74
|
+
// Cursor is now back below the last choice line — same position as before.
|
|
66
75
|
}
|
|
67
|
-
|
|
68
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Erase the choice list from the screen (used after selection).
|
|
78
|
+
* Assumes cursor is immediately below the last choice line.
|
|
79
|
+
*/
|
|
80
|
+
function eraseChoices(count) {
|
|
81
|
+
cursorUp(count);
|
|
69
82
|
for (let i = 0; i < count; i++) {
|
|
70
|
-
|
|
83
|
+
clearLine();
|
|
71
84
|
process.stdout.write("\n");
|
|
72
85
|
}
|
|
73
|
-
//
|
|
74
|
-
|
|
86
|
+
// Back to where the first choice was
|
|
87
|
+
cursorUp(count);
|
|
75
88
|
}
|
|
76
89
|
function interactiveSelect(prompt, choices) {
|
|
77
90
|
let selectedIdx = 0;
|
|
@@ -83,11 +96,38 @@ function interactiveSelect(prompt, choices) {
|
|
|
83
96
|
const wasRaw = process.stdin.isRaw;
|
|
84
97
|
const wasPaused = process.stdin.isPaused();
|
|
85
98
|
let resolved = false;
|
|
99
|
+
const onKeypress = (_str, key) => {
|
|
100
|
+
if (!key || !key.name)
|
|
101
|
+
return;
|
|
102
|
+
if (key.name === "up" || key.name === "k") {
|
|
103
|
+
selectedIdx =
|
|
104
|
+
(selectedIdx - 1 + choices.length) % choices.length;
|
|
105
|
+
reRenderChoices(choices, selectedIdx);
|
|
106
|
+
}
|
|
107
|
+
else if (key.name === "down" || key.name === "j") {
|
|
108
|
+
selectedIdx = (selectedIdx + 1) % choices.length;
|
|
109
|
+
reRenderChoices(choices, selectedIdx);
|
|
110
|
+
}
|
|
111
|
+
else if (key.name === "return" || key.name === "space") {
|
|
112
|
+
resolved = true;
|
|
113
|
+
const chosen = choices[selectedIdx];
|
|
114
|
+
cleanup();
|
|
115
|
+
eraseChoices(choices.length);
|
|
116
|
+
process.stdout.write(` → ${chosen.label}\n`);
|
|
117
|
+
resolve(chosen.value);
|
|
118
|
+
}
|
|
119
|
+
else if (key.ctrl && key.name === "c") {
|
|
120
|
+
cleanup();
|
|
121
|
+
process.stdout.write("\n");
|
|
122
|
+
process.exit(0);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
86
125
|
const cleanup = () => {
|
|
87
126
|
if (resolved)
|
|
88
127
|
return;
|
|
89
128
|
try {
|
|
90
|
-
|
|
129
|
+
// Always restore to non-raw so subsequent readline works
|
|
130
|
+
process.stdin.setRawMode(false);
|
|
91
131
|
}
|
|
92
132
|
catch {
|
|
93
133
|
// best-effort restore
|
|
@@ -111,38 +151,11 @@ function interactiveSelect(prompt, choices) {
|
|
|
111
151
|
process.stdin.setRawMode(true);
|
|
112
152
|
}
|
|
113
153
|
catch {
|
|
114
|
-
// setRawMode may fail on some terminals; clean up and fall back
|
|
115
154
|
cleanup();
|
|
116
155
|
fallbackNumberedSelect(prompt, choices).then(resolve);
|
|
117
156
|
return;
|
|
118
157
|
}
|
|
119
158
|
process.stdin.resume();
|
|
120
|
-
const onKeypress = (_str, key) => {
|
|
121
|
-
if (!key || !key.name)
|
|
122
|
-
return;
|
|
123
|
-
if (key.name === "up" || key.name === "k") {
|
|
124
|
-
selectedIdx =
|
|
125
|
-
(selectedIdx - 1 + choices.length) % choices.length;
|
|
126
|
-
reRenderChoices(choices, selectedIdx);
|
|
127
|
-
}
|
|
128
|
-
else if (key.name === "down" || key.name === "j") {
|
|
129
|
-
selectedIdx = (selectedIdx + 1) % choices.length;
|
|
130
|
-
reRenderChoices(choices, selectedIdx);
|
|
131
|
-
}
|
|
132
|
-
else if (key.name === "return" || key.name === "space") {
|
|
133
|
-
resolved = true;
|
|
134
|
-
const chosen = choices[selectedIdx];
|
|
135
|
-
cleanup();
|
|
136
|
-
clearChoices(choices.length);
|
|
137
|
-
process.stdout.write(` → ${chosen.label}\n`);
|
|
138
|
-
resolve(chosen.value);
|
|
139
|
-
}
|
|
140
|
-
else if (key.ctrl && key.name === "c") {
|
|
141
|
-
cleanup();
|
|
142
|
-
process.stdout.write("\n");
|
|
143
|
-
process.exit(0);
|
|
144
|
-
}
|
|
145
|
-
};
|
|
146
159
|
process.stdin.on("keypress", onKeypress);
|
|
147
160
|
});
|
|
148
161
|
}
|
|
@@ -283,7 +296,7 @@ export function createReadlinePrompter() {
|
|
|
283
296
|
: choices;
|
|
284
297
|
// Release readline so it doesn't fight over stdin in raw mode
|
|
285
298
|
disposeRL();
|
|
286
|
-
const result =
|
|
299
|
+
const result = supportsInteractiveSelect()
|
|
287
300
|
? await interactiveSelect(prompt, allChoices)
|
|
288
301
|
: await fallbackNumberedSelect(prompt, allChoices);
|
|
289
302
|
if (result === SENTINEL_FINISH)
|
|
@@ -364,10 +377,7 @@ export async function runSetupWizard(prompter, currentConfig, availableChannels)
|
|
|
364
377
|
"我们将引导你完成 P2P Mesh 网络的基础配置。",
|
|
365
378
|
"任何时候按 Ctrl+C 可退出,配置不会被保存。",
|
|
366
379
|
]);
|
|
367
|
-
//
|
|
368
|
-
// Layer 1: Core Path
|
|
369
|
-
// =================================================================
|
|
370
|
-
// Step 1: Discovery mode (select throws WizardFinishError on Finish)
|
|
380
|
+
// Step 1: Discovery mode
|
|
371
381
|
const discovery = await prompter.select("选择节点发现方式:", [
|
|
372
382
|
{
|
|
373
383
|
value: "mdns",
|
|
@@ -384,15 +394,13 @@ export async function runSetupWizard(prompter, currentConfig, availableChannels)
|
|
|
384
394
|
label: "DHT — Kademlia 分布式发现",
|
|
385
395
|
hint: "需要至少一个 bootstrap 入口",
|
|
386
396
|
},
|
|
387
|
-
]
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
config.bootstrapList = addrs;
|
|
395
|
-
}
|
|
397
|
+
]);
|
|
398
|
+
config.discovery = discovery;
|
|
399
|
+
// Step 2: Bootstrap addresses (only if discovery=bootstrap or dht)
|
|
400
|
+
if (discovery === "bootstrap" || discovery === "dht") {
|
|
401
|
+
const addrs = await prompter.multiline("输入 Bootstrap 节点地址(每行一个,空行结束,直接回车跳过):", " 格式: /ip4/<IP>/tcp/<端口>/p2p/<PeerID>");
|
|
402
|
+
if (addrs.length > 0) {
|
|
403
|
+
config.bootstrapList = addrs;
|
|
396
404
|
}
|
|
397
405
|
}
|
|
398
406
|
// Step 3: Inbound targets
|
|
@@ -400,116 +408,95 @@ export async function runSetupWizard(prompter, currentConfig, availableChannels)
|
|
|
400
408
|
prompter.displayWarning("未检测到已安装的聊天频道插件。你可以稍后在 openclaw.json 中手动配置 inboundTargets。");
|
|
401
409
|
}
|
|
402
410
|
else {
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
411
|
+
const targets = [];
|
|
412
|
+
while (true) {
|
|
413
|
+
const doneLabel = targets.length > 0
|
|
414
|
+
? "完成接收目标配置"
|
|
415
|
+
: hasInboundConfig(currentConfig)
|
|
416
|
+
? "保留现有接收目标并继续"
|
|
417
|
+
: "暂不配置接收目标";
|
|
418
|
+
const channelChoices = [
|
|
419
|
+
...availableChannels.map((ch) => ({
|
|
410
420
|
value: ch,
|
|
411
421
|
label: ch,
|
|
412
|
-
}))
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
assertNotFinished(more);
|
|
426
|
-
addMore = more ?? false;
|
|
422
|
+
})),
|
|
423
|
+
{
|
|
424
|
+
value: SENTINEL_SKIP,
|
|
425
|
+
label: doneLabel,
|
|
426
|
+
},
|
|
427
|
+
];
|
|
428
|
+
const channel = await prompter.select("选择接收 P2P 消息的聊天频道:", channelChoices);
|
|
429
|
+
if (channel === SENTINEL_SKIP)
|
|
430
|
+
break;
|
|
431
|
+
const target = await prompter.question(`输入 ${channel} 的接收目标(如 user:ou_xxx 或 chat:oc_xxx,直接回车跳过当前频道):`, undefined, { allowSkip: true });
|
|
432
|
+
assertNotFinished(target);
|
|
433
|
+
if (isSkip(target)) {
|
|
434
|
+
continue;
|
|
427
435
|
}
|
|
428
|
-
if (
|
|
429
|
-
|
|
430
|
-
!currentConfig.inboundChannel &&
|
|
431
|
-
!currentConfig.inboundTarget) {
|
|
432
|
-
// Single target: also set legacy inboundChannel/inboundTarget for backwards compat
|
|
433
|
-
config.inboundChannel = targets[0].channel;
|
|
434
|
-
config.inboundTarget = targets[0].target;
|
|
435
|
-
}
|
|
436
|
-
config.inboundTargets = targets;
|
|
436
|
+
if (target) {
|
|
437
|
+
targets.push({ channel, target });
|
|
437
438
|
}
|
|
438
439
|
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
const corePreview = formatConfigPreview(config);
|
|
443
|
-
prompter.displayBox("即将写入以下配置:", corePreview);
|
|
444
|
-
const coreConfirmed = await prompter.confirm("确认写入?", true);
|
|
445
|
-
if (!coreConfirmed) {
|
|
446
|
-
prompter.displayWarning("已取消,配置未保存。");
|
|
447
|
-
throw new WizardCancelledError();
|
|
440
|
+
if (targets.length > 0) {
|
|
441
|
+
applyInboundTargets(config, targets);
|
|
442
|
+
}
|
|
448
443
|
}
|
|
449
|
-
//
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
prompter.displayWarning("端口号无效,使用默认动态端口。");
|
|
470
|
-
}
|
|
444
|
+
// Step 4: Network profile
|
|
445
|
+
const networkMode = await prompter.select("选择网络使用场景:", [
|
|
446
|
+
{
|
|
447
|
+
value: "lan",
|
|
448
|
+
label: "局域网 / 默认配置",
|
|
449
|
+
hint: "同一 WiFi 或内网使用",
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
value: "wan",
|
|
453
|
+
label: "跨网络 / 高级配置",
|
|
454
|
+
hint: "跨 WiFi、跨城市或需要固定入口",
|
|
455
|
+
},
|
|
456
|
+
]);
|
|
457
|
+
if (networkMode === "wan") {
|
|
458
|
+
const port = await prompter.question("固定监听端口(直接回车跳过,使用动态端口)", "4001", { allowSkip: true });
|
|
459
|
+
assertNotFinished(port);
|
|
460
|
+
if (!isSkip(port)) {
|
|
461
|
+
const portNum = parseInt(port, 10);
|
|
462
|
+
if (!isNaN(portNum) && portNum > 0 && portNum < 65536) {
|
|
463
|
+
config.listenAddrs = [`/ip4/0.0.0.0/tcp/${portNum}`];
|
|
471
464
|
}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
const wantNAT = await prompter.confirm("是否启用 NAT 穿透?(默认开启,推荐保留)", true, { allowSkip: true });
|
|
475
|
-
assertNotFinished(wantNAT);
|
|
476
|
-
if (wantNAT !== null) {
|
|
477
|
-
config.enableNATTraversal = wantNAT;
|
|
478
|
-
}
|
|
479
|
-
// Circuit Relay
|
|
480
|
-
const wantRelay = await prompter.confirm("需要配置 Circuit Relay 中继节点吗?", false, { allowSkip: true });
|
|
481
|
-
assertNotFinished(wantRelay);
|
|
482
|
-
if (wantRelay === true) {
|
|
483
|
-
const relays = await prompter.multiline("输入 Relay 节点地址(每行一个,空行结束):", " 格式: /ip4/<IP>/tcp/<端口>/p2p/<PeerID>");
|
|
484
|
-
if (relays.length > 0) {
|
|
485
|
-
config.relayList = relays;
|
|
465
|
+
else {
|
|
466
|
+
prompter.displayWarning("端口号无效,使用默认动态端口。");
|
|
486
467
|
}
|
|
487
468
|
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
}
|
|
469
|
+
const natTraversal = await prompter.select("NAT 穿透:", [
|
|
470
|
+
{
|
|
471
|
+
value: "true",
|
|
472
|
+
label: "启用",
|
|
473
|
+
hint: "默认,推荐保留",
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
value: "false",
|
|
477
|
+
label: "禁用",
|
|
478
|
+
hint: "仅在明确需要时关闭",
|
|
479
|
+
},
|
|
480
|
+
]);
|
|
481
|
+
config.enableNATTraversal = natTraversal === "true";
|
|
482
|
+
const relays = await prompter.multiline("输入 Relay 节点地址(每行一个,空行结束,直接回车跳过):", " 格式: /ip4/<IP>/tcp/<端口>/p2p/<PeerID>");
|
|
483
|
+
if (relays.length > 0) {
|
|
484
|
+
config.relayList = relays;
|
|
499
485
|
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
(wantNAT !== null && !config.enableNATTraversal)) {
|
|
505
|
-
const finalPreview = formatConfigPreview(config);
|
|
506
|
-
prompter.displayBox("高级配置已追加,最终预览:", finalPreview);
|
|
507
|
-
const finalConfirmed = await prompter.confirm("确认写入?", true);
|
|
508
|
-
if (!finalConfirmed) {
|
|
509
|
-
prompter.displayWarning("已取消高级配置,核心配置已保存。");
|
|
510
|
-
}
|
|
486
|
+
const name = await prompter.question("节点名称(直接回车跳过)", undefined, { allowSkip: true });
|
|
487
|
+
assertNotFinished(name);
|
|
488
|
+
if (!isSkip(name) && name) {
|
|
489
|
+
config.instanceName = name;
|
|
511
490
|
}
|
|
512
491
|
}
|
|
492
|
+
// Step 5: Final preview and confirm
|
|
493
|
+
const finalPreview = formatConfigPreview(config);
|
|
494
|
+
prompter.displayBox("即将写入以下配置:", finalPreview);
|
|
495
|
+
const confirmed = await prompter.confirm("确认写入?", true);
|
|
496
|
+
if (!confirmed) {
|
|
497
|
+
prompter.displayWarning("已取消,配置未保存。");
|
|
498
|
+
throw new WizardCancelledError();
|
|
499
|
+
}
|
|
513
500
|
prompter.displaySuccess("配置完成。运行 openclaw gateway restart 使配置生效。");
|
|
514
501
|
return config;
|
|
515
502
|
}
|
|
@@ -542,6 +529,23 @@ export class WizardFinishError extends Error {
|
|
|
542
529
|
}
|
|
543
530
|
}
|
|
544
531
|
// --- Helpers ---
|
|
532
|
+
function hasInboundConfig(config) {
|
|
533
|
+
return ((Array.isArray(config.inboundTargets) && config.inboundTargets.length > 0) ||
|
|
534
|
+
(Boolean(config.inboundChannel) && Boolean(config.inboundTarget)));
|
|
535
|
+
}
|
|
536
|
+
function applyInboundTargets(config, targets) {
|
|
537
|
+
delete config.inboundTargets;
|
|
538
|
+
delete config.inboundChannel;
|
|
539
|
+
delete config.inboundTarget;
|
|
540
|
+
if (targets.length === 0) {
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
config.inboundTargets = targets;
|
|
544
|
+
if (targets.length === 1) {
|
|
545
|
+
config.inboundChannel = targets[0].channel;
|
|
546
|
+
config.inboundTarget = targets[0].target;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
545
549
|
function formatConfigPreview(config) {
|
|
546
550
|
const lines = [];
|
|
547
551
|
if (config.discovery) {
|
package/index.ts
CHANGED
|
@@ -169,10 +169,23 @@ export default definePluginEntry({
|
|
|
169
169
|
description: "P2P network for cross-instance agent communication via libp2p.",
|
|
170
170
|
configSchema: createLibp2pMeshConfigSchema(),
|
|
171
171
|
register: (api) => {
|
|
172
|
+
if (
|
|
173
|
+
api.registrationMode === "cli-metadata" ||
|
|
174
|
+
api.registrationMode === "discovery" ||
|
|
175
|
+
api.registrationMode === "full"
|
|
176
|
+
) {
|
|
177
|
+
api.registerCli(registerLibp2pMeshCli, {
|
|
178
|
+
commands: ["libp2p-mesh"],
|
|
179
|
+
});
|
|
180
|
+
if (api.registrationMode === "cli-metadata") {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (api.registrationMode !== "full") {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
172
189
|
registerLibp2pMesh(api);
|
|
173
|
-
// 5. Register CLI commands (setup wizard + config management)
|
|
174
|
-
api.registerCli(registerLibp2pMeshCli, {
|
|
175
|
-
commands: ["libp2p-mesh"],
|
|
176
|
-
});
|
|
177
190
|
},
|
|
178
191
|
});
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "libp2p-mesh",
|
|
3
|
-
"version": "2026.6.
|
|
3
|
+
"version": "2026.6.8",
|
|
4
4
|
"description": "OpenClaw libp2p P2P mesh network plugin for cross-instance agent communication",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
8
8
|
"scripts": {
|
|
9
9
|
"build": "tsc -p tsconfig.json",
|
|
10
|
-
"test": "node --import tsx --
|
|
10
|
+
"test": "node --import tsx --test test/*.test.ts",
|
|
11
11
|
"prepack": "npm run build"
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
package/src/plugin.ts
CHANGED
|
@@ -20,7 +20,16 @@ export function registerLibp2pMesh(api: OpenClawPluginApi) {
|
|
|
20
20
|
const store = createInstancePeerStore({ logger: api.logger });
|
|
21
21
|
const delivery = createOpenClawRuntimeInboundDelivery({
|
|
22
22
|
config: api.config,
|
|
23
|
-
loadAdapter: (channel: string) =>
|
|
23
|
+
loadAdapter: async (channel: string) => {
|
|
24
|
+
const loadAdapter = api.runtime?.channel?.outbound?.loadAdapter;
|
|
25
|
+
if (typeof loadAdapter !== "function") {
|
|
26
|
+
api.logger.warn?.(
|
|
27
|
+
`[libp2p-mesh] runtime channel outbound adapter is unavailable; cannot deliver to ${channel}`,
|
|
28
|
+
);
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
return loadAdapter(channel);
|
|
32
|
+
},
|
|
24
33
|
logger: api.logger,
|
|
25
34
|
});
|
|
26
35
|
const router = createInstanceRouter({
|
package/src/wizard.ts
CHANGED
|
@@ -57,19 +57,23 @@ function isTTY(): boolean {
|
|
|
57
57
|
return Boolean(process.stdout.isTTY && process.stdin.isTTY);
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
function supportsInteractiveSelect(): boolean {
|
|
61
|
+
return isTTY() && process.platform !== "win32";
|
|
62
|
+
}
|
|
63
|
+
|
|
60
64
|
/**
|
|
61
|
-
*
|
|
65
|
+
* Clear entire current line: carriage-return to col 0, then erase to end.
|
|
62
66
|
*/
|
|
63
|
-
function
|
|
64
|
-
|
|
65
|
-
process.stdout.write(`\x1b[${n}A`);
|
|
67
|
+
function clearLine(): void {
|
|
68
|
+
process.stdout.write("\r\x1b[K");
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
/**
|
|
69
|
-
*
|
|
72
|
+
* Move cursor up `n` lines.
|
|
70
73
|
*/
|
|
71
|
-
function
|
|
72
|
-
|
|
74
|
+
function cursorUp(n: number): void {
|
|
75
|
+
if (n <= 0) return;
|
|
76
|
+
process.stdout.write(`\x1b[${n}A`);
|
|
73
77
|
}
|
|
74
78
|
|
|
75
79
|
function renderChoices(
|
|
@@ -89,14 +93,19 @@ function renderChoices(
|
|
|
89
93
|
}
|
|
90
94
|
}
|
|
91
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Re-render the choice list in-place. Assumes cursor is immediately below
|
|
98
|
+
* the last choice line.
|
|
99
|
+
*/
|
|
92
100
|
function reRenderChoices(
|
|
93
101
|
choices: PromptChoice[],
|
|
94
102
|
selectedIdx: number,
|
|
95
103
|
): void {
|
|
96
104
|
const n = choices.length;
|
|
97
|
-
|
|
105
|
+
// Jump back to the first choice line
|
|
106
|
+
cursorUp(n);
|
|
98
107
|
for (let i = 0; i < n; i++) {
|
|
99
|
-
|
|
108
|
+
clearLine(); // \r\x1b[K -- always starts at col 0
|
|
100
109
|
const c = choices[i]!;
|
|
101
110
|
const pointer = i === selectedIdx ? "❯" : " ";
|
|
102
111
|
const hint = c.hint ? `(${c.hint})` : "";
|
|
@@ -107,16 +116,21 @@ function reRenderChoices(
|
|
|
107
116
|
process.stdout.write(`${line}\n`);
|
|
108
117
|
}
|
|
109
118
|
}
|
|
119
|
+
// Cursor is now back below the last choice line — same position as before.
|
|
110
120
|
}
|
|
111
121
|
|
|
112
|
-
|
|
113
|
-
|
|
122
|
+
/**
|
|
123
|
+
* Erase the choice list from the screen (used after selection).
|
|
124
|
+
* Assumes cursor is immediately below the last choice line.
|
|
125
|
+
*/
|
|
126
|
+
function eraseChoices(count: number): void {
|
|
127
|
+
cursorUp(count);
|
|
114
128
|
for (let i = 0; i < count; i++) {
|
|
115
|
-
|
|
129
|
+
clearLine();
|
|
116
130
|
process.stdout.write("\n");
|
|
117
131
|
}
|
|
118
|
-
//
|
|
119
|
-
|
|
132
|
+
// Back to where the first choice was
|
|
133
|
+
cursorUp(count);
|
|
120
134
|
}
|
|
121
135
|
|
|
122
136
|
function interactiveSelect(
|
|
@@ -137,10 +151,38 @@ function interactiveSelect(
|
|
|
137
151
|
|
|
138
152
|
let resolved = false;
|
|
139
153
|
|
|
154
|
+
const onKeypress = (
|
|
155
|
+
_str: string | undefined,
|
|
156
|
+
key: { name?: string; ctrl?: boolean },
|
|
157
|
+
) => {
|
|
158
|
+
if (!key || !key.name) return;
|
|
159
|
+
|
|
160
|
+
if (key.name === "up" || key.name === "k") {
|
|
161
|
+
selectedIdx =
|
|
162
|
+
(selectedIdx - 1 + choices.length) % choices.length;
|
|
163
|
+
reRenderChoices(choices, selectedIdx);
|
|
164
|
+
} else if (key.name === "down" || key.name === "j") {
|
|
165
|
+
selectedIdx = (selectedIdx + 1) % choices.length;
|
|
166
|
+
reRenderChoices(choices, selectedIdx);
|
|
167
|
+
} else if (key.name === "return" || key.name === "space") {
|
|
168
|
+
resolved = true;
|
|
169
|
+
const chosen = choices[selectedIdx]!;
|
|
170
|
+
cleanup();
|
|
171
|
+
eraseChoices(choices.length);
|
|
172
|
+
process.stdout.write(` → ${chosen.label}\n`);
|
|
173
|
+
resolve(chosen.value);
|
|
174
|
+
} else if (key.ctrl && key.name === "c") {
|
|
175
|
+
cleanup();
|
|
176
|
+
process.stdout.write("\n");
|
|
177
|
+
process.exit(0);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
140
181
|
const cleanup = () => {
|
|
141
182
|
if (resolved) return;
|
|
142
183
|
try {
|
|
143
|
-
|
|
184
|
+
// Always restore to non-raw so subsequent readline works
|
|
185
|
+
process.stdin.setRawMode(false);
|
|
144
186
|
} catch {
|
|
145
187
|
// best-effort restore
|
|
146
188
|
}
|
|
@@ -161,40 +203,12 @@ function interactiveSelect(
|
|
|
161
203
|
try {
|
|
162
204
|
process.stdin.setRawMode(true);
|
|
163
205
|
} catch {
|
|
164
|
-
// setRawMode may fail on some terminals; clean up and fall back
|
|
165
206
|
cleanup();
|
|
166
207
|
fallbackNumberedSelect(prompt, choices).then(resolve);
|
|
167
208
|
return;
|
|
168
209
|
}
|
|
169
210
|
process.stdin.resume();
|
|
170
211
|
|
|
171
|
-
const onKeypress = (
|
|
172
|
-
_str: string | undefined,
|
|
173
|
-
key: { name?: string; ctrl?: boolean },
|
|
174
|
-
) => {
|
|
175
|
-
if (!key || !key.name) return;
|
|
176
|
-
|
|
177
|
-
if (key.name === "up" || key.name === "k") {
|
|
178
|
-
selectedIdx =
|
|
179
|
-
(selectedIdx - 1 + choices.length) % choices.length;
|
|
180
|
-
reRenderChoices(choices, selectedIdx);
|
|
181
|
-
} else if (key.name === "down" || key.name === "j") {
|
|
182
|
-
selectedIdx = (selectedIdx + 1) % choices.length;
|
|
183
|
-
reRenderChoices(choices, selectedIdx);
|
|
184
|
-
} else if (key.name === "return" || key.name === "space") {
|
|
185
|
-
resolved = true;
|
|
186
|
-
const chosen = choices[selectedIdx]!;
|
|
187
|
-
cleanup();
|
|
188
|
-
clearChoices(choices.length);
|
|
189
|
-
process.stdout.write(` → ${chosen.label}\n`);
|
|
190
|
-
resolve(chosen.value);
|
|
191
|
-
} else if (key.ctrl && key.name === "c") {
|
|
192
|
-
cleanup();
|
|
193
|
-
process.stdout.write("\n");
|
|
194
|
-
process.exit(0);
|
|
195
|
-
}
|
|
196
|
-
};
|
|
197
|
-
|
|
198
212
|
process.stdin.on("keypress", onKeypress);
|
|
199
213
|
});
|
|
200
214
|
}
|
|
@@ -355,7 +369,7 @@ export function createReadlinePrompter(): WizardPrompter {
|
|
|
355
369
|
// Release readline so it doesn't fight over stdin in raw mode
|
|
356
370
|
disposeRL();
|
|
357
371
|
|
|
358
|
-
const result =
|
|
372
|
+
const result = supportsInteractiveSelect()
|
|
359
373
|
? await interactiveSelect(prompt, allChoices)
|
|
360
374
|
: await fallbackNumberedSelect(prompt, allChoices);
|
|
361
375
|
|
|
@@ -455,11 +469,7 @@ export async function runSetupWizard(
|
|
|
455
469
|
"任何时候按 Ctrl+C 可退出,配置不会被保存。",
|
|
456
470
|
]);
|
|
457
471
|
|
|
458
|
-
//
|
|
459
|
-
// Layer 1: Core Path
|
|
460
|
-
// =================================================================
|
|
461
|
-
|
|
462
|
-
// Step 1: Discovery mode (select throws WizardFinishError on Finish)
|
|
472
|
+
// Step 1: Discovery mode
|
|
463
473
|
const discovery = await prompter.select(
|
|
464
474
|
"选择节点发现方式:",
|
|
465
475
|
[
|
|
@@ -479,20 +489,17 @@ export async function runSetupWizard(
|
|
|
479
489
|
hint: "需要至少一个 bootstrap 入口",
|
|
480
490
|
},
|
|
481
491
|
],
|
|
482
|
-
{ includeSkip: true },
|
|
483
492
|
);
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
config.bootstrapList = addrs;
|
|
495
|
-
}
|
|
493
|
+
config.discovery = discovery;
|
|
494
|
+
|
|
495
|
+
// Step 2: Bootstrap addresses (only if discovery=bootstrap or dht)
|
|
496
|
+
if (discovery === "bootstrap" || discovery === "dht") {
|
|
497
|
+
const addrs = await prompter.multiline(
|
|
498
|
+
"输入 Bootstrap 节点地址(每行一个,空行结束,直接回车跳过):",
|
|
499
|
+
" 格式: /ip4/<IP>/tcp/<端口>/p2p/<PeerID>",
|
|
500
|
+
);
|
|
501
|
+
if (addrs.length > 0) {
|
|
502
|
+
config.bootstrapList = addrs;
|
|
496
503
|
}
|
|
497
504
|
}
|
|
498
505
|
|
|
@@ -502,162 +509,124 @@ export async function runSetupWizard(
|
|
|
502
509
|
"未检测到已安装的聊天频道插件。你可以稍后在 openclaw.json 中手动配置 inboundTargets。",
|
|
503
510
|
);
|
|
504
511
|
} else {
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
if (target) {
|
|
538
|
-
targets.push({ channel, target });
|
|
539
|
-
}
|
|
540
|
-
const more = await prompter.confirm("是否添加更多接收目标?", false);
|
|
541
|
-
assertNotFinished(more);
|
|
542
|
-
addMore = more ?? false;
|
|
512
|
+
const targets: Array<{ id?: string; channel: string; target: string }> = [];
|
|
513
|
+
while (true) {
|
|
514
|
+
const doneLabel =
|
|
515
|
+
targets.length > 0
|
|
516
|
+
? "完成接收目标配置"
|
|
517
|
+
: hasInboundConfig(currentConfig)
|
|
518
|
+
? "保留现有接收目标并继续"
|
|
519
|
+
: "暂不配置接收目标";
|
|
520
|
+
const channelChoices: PromptChoice[] = [
|
|
521
|
+
...availableChannels.map((ch) => ({
|
|
522
|
+
value: ch,
|
|
523
|
+
label: ch,
|
|
524
|
+
})),
|
|
525
|
+
{
|
|
526
|
+
value: SENTINEL_SKIP,
|
|
527
|
+
label: doneLabel,
|
|
528
|
+
},
|
|
529
|
+
];
|
|
530
|
+
const channel = await prompter.select(
|
|
531
|
+
"选择接收 P2P 消息的聊天频道:",
|
|
532
|
+
channelChoices,
|
|
533
|
+
);
|
|
534
|
+
if (channel === SENTINEL_SKIP) break;
|
|
535
|
+
|
|
536
|
+
const target = await prompter.question(
|
|
537
|
+
`输入 ${channel} 的接收目标(如 user:ou_xxx 或 chat:oc_xxx,直接回车跳过当前频道):`,
|
|
538
|
+
undefined,
|
|
539
|
+
{ allowSkip: true },
|
|
540
|
+
);
|
|
541
|
+
assertNotFinished(target);
|
|
542
|
+
if (isSkip(target)) {
|
|
543
|
+
continue;
|
|
543
544
|
}
|
|
544
|
-
if (
|
|
545
|
-
|
|
546
|
-
targets.length === 1 &&
|
|
547
|
-
!currentConfig.inboundChannel &&
|
|
548
|
-
!currentConfig.inboundTarget
|
|
549
|
-
) {
|
|
550
|
-
// Single target: also set legacy inboundChannel/inboundTarget for backwards compat
|
|
551
|
-
config.inboundChannel = targets[0]!.channel;
|
|
552
|
-
config.inboundTarget = targets[0]!.target;
|
|
553
|
-
}
|
|
554
|
-
config.inboundTargets = targets;
|
|
545
|
+
if (target) {
|
|
546
|
+
targets.push({ channel, target });
|
|
555
547
|
}
|
|
556
548
|
}
|
|
557
|
-
// wantInbound === null (skip) or false (no) → skip inbound config
|
|
558
|
-
}
|
|
559
549
|
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
const coreConfirmed = await prompter.confirm("确认写入?", true);
|
|
564
|
-
if (!coreConfirmed) {
|
|
565
|
-
prompter.displayWarning("已取消,配置未保存。");
|
|
566
|
-
throw new WizardCancelledError();
|
|
550
|
+
if (targets.length > 0) {
|
|
551
|
+
applyInboundTargets(config, targets);
|
|
552
|
+
}
|
|
567
553
|
}
|
|
568
554
|
|
|
569
|
-
//
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
555
|
+
// Step 4: Network profile
|
|
556
|
+
const networkMode = await prompter.select(
|
|
557
|
+
"选择网络使用场景:",
|
|
558
|
+
[
|
|
559
|
+
{
|
|
560
|
+
value: "lan",
|
|
561
|
+
label: "局域网 / 默认配置",
|
|
562
|
+
hint: "同一 WiFi 或内网使用",
|
|
563
|
+
},
|
|
564
|
+
{
|
|
565
|
+
value: "wan",
|
|
566
|
+
label: "跨网络 / 高级配置",
|
|
567
|
+
hint: "跨 WiFi、跨城市或需要固定入口",
|
|
568
|
+
},
|
|
569
|
+
],
|
|
577
570
|
);
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
"是否使用固定监听端口?(推荐跨网络场景)",
|
|
583
|
-
false,
|
|
571
|
+
if (networkMode === "wan") {
|
|
572
|
+
const port = await prompter.question(
|
|
573
|
+
"固定监听端口(直接回车跳过,使用动态端口)",
|
|
574
|
+
"4001",
|
|
584
575
|
{ allowSkip: true },
|
|
585
576
|
);
|
|
586
|
-
assertNotFinished(
|
|
587
|
-
if (
|
|
588
|
-
const
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
const portNum = parseInt(port, 10);
|
|
594
|
-
if (!isNaN(portNum) && portNum > 0 && portNum < 65536) {
|
|
595
|
-
config.listenAddrs = [`/ip4/0.0.0.0/tcp/${portNum}`];
|
|
596
|
-
} else {
|
|
597
|
-
prompter.displayWarning("端口号无效,使用默认动态端口。");
|
|
598
|
-
}
|
|
577
|
+
assertNotFinished(port);
|
|
578
|
+
if (!isSkip(port)) {
|
|
579
|
+
const portNum = parseInt(port, 10);
|
|
580
|
+
if (!isNaN(portNum) && portNum > 0 && portNum < 65536) {
|
|
581
|
+
config.listenAddrs = [`/ip4/0.0.0.0/tcp/${portNum}`];
|
|
582
|
+
} else {
|
|
583
|
+
prompter.displayWarning("端口号无效,使用默认动态端口。");
|
|
599
584
|
}
|
|
600
585
|
}
|
|
601
586
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
587
|
+
const natTraversal = await prompter.select(
|
|
588
|
+
"NAT 穿透:",
|
|
589
|
+
[
|
|
590
|
+
{
|
|
591
|
+
value: "true",
|
|
592
|
+
label: "启用",
|
|
593
|
+
hint: "默认,推荐保留",
|
|
594
|
+
},
|
|
595
|
+
{
|
|
596
|
+
value: "false",
|
|
597
|
+
label: "禁用",
|
|
598
|
+
hint: "仅在明确需要时关闭",
|
|
599
|
+
},
|
|
600
|
+
],
|
|
607
601
|
);
|
|
608
|
-
|
|
609
|
-
if (wantNAT !== null) {
|
|
610
|
-
config.enableNATTraversal = wantNAT;
|
|
611
|
-
}
|
|
602
|
+
config.enableNATTraversal = natTraversal === "true";
|
|
612
603
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
"
|
|
616
|
-
false,
|
|
617
|
-
{ allowSkip: true },
|
|
604
|
+
const relays = await prompter.multiline(
|
|
605
|
+
"输入 Relay 节点地址(每行一个,空行结束,直接回车跳过):",
|
|
606
|
+
" 格式: /ip4/<IP>/tcp/<端口>/p2p/<PeerID>",
|
|
618
607
|
);
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
const relays = await prompter.multiline(
|
|
622
|
-
"输入 Relay 节点地址(每行一个,空行结束):",
|
|
623
|
-
" 格式: /ip4/<IP>/tcp/<端口>/p2p/<PeerID>",
|
|
624
|
-
);
|
|
625
|
-
if (relays.length > 0) {
|
|
626
|
-
config.relayList = relays;
|
|
627
|
-
}
|
|
608
|
+
if (relays.length > 0) {
|
|
609
|
+
config.relayList = relays;
|
|
628
610
|
}
|
|
629
611
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
false,
|
|
612
|
+
const name = await prompter.question(
|
|
613
|
+
"节点名称(直接回车跳过)",
|
|
614
|
+
undefined,
|
|
634
615
|
{ allowSkip: true },
|
|
635
616
|
);
|
|
636
|
-
assertNotFinished(
|
|
637
|
-
if (
|
|
638
|
-
|
|
639
|
-
allowSkip: true,
|
|
640
|
-
});
|
|
641
|
-
assertNotFinished(name);
|
|
642
|
-
if (!isSkip(name) && name) {
|
|
643
|
-
config.instanceName = name;
|
|
644
|
-
}
|
|
617
|
+
assertNotFinished(name);
|
|
618
|
+
if (!isSkip(name) && name) {
|
|
619
|
+
config.instanceName = name;
|
|
645
620
|
}
|
|
621
|
+
}
|
|
646
622
|
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
)
|
|
654
|
-
const finalPreview = formatConfigPreview(config);
|
|
655
|
-
prompter.displayBox("高级配置已追加,最终预览:", finalPreview);
|
|
656
|
-
const finalConfirmed = await prompter.confirm("确认写入?", true);
|
|
657
|
-
if (!finalConfirmed) {
|
|
658
|
-
prompter.displayWarning("已取消高级配置,核心配置已保存。");
|
|
659
|
-
}
|
|
660
|
-
}
|
|
623
|
+
// Step 5: Final preview and confirm
|
|
624
|
+
const finalPreview = formatConfigPreview(config);
|
|
625
|
+
prompter.displayBox("即将写入以下配置:", finalPreview);
|
|
626
|
+
const confirmed = await prompter.confirm("确认写入?", true);
|
|
627
|
+
if (!confirmed) {
|
|
628
|
+
prompter.displayWarning("已取消,配置未保存。");
|
|
629
|
+
throw new WizardCancelledError();
|
|
661
630
|
}
|
|
662
631
|
|
|
663
632
|
prompter.displaySuccess(
|
|
@@ -699,6 +668,32 @@ export class WizardFinishError extends Error {
|
|
|
699
668
|
|
|
700
669
|
// --- Helpers ---
|
|
701
670
|
|
|
671
|
+
function hasInboundConfig(config: Record<string, unknown>): boolean {
|
|
672
|
+
return (
|
|
673
|
+
(Array.isArray(config.inboundTargets) && config.inboundTargets.length > 0) ||
|
|
674
|
+
(Boolean(config.inboundChannel) && Boolean(config.inboundTarget))
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function applyInboundTargets(
|
|
679
|
+
config: Record<string, unknown>,
|
|
680
|
+
targets: Array<{ id?: string; channel: string; target: string }>,
|
|
681
|
+
): void {
|
|
682
|
+
delete config.inboundTargets;
|
|
683
|
+
delete config.inboundChannel;
|
|
684
|
+
delete config.inboundTarget;
|
|
685
|
+
|
|
686
|
+
if (targets.length === 0) {
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
config.inboundTargets = targets;
|
|
691
|
+
if (targets.length === 1) {
|
|
692
|
+
config.inboundChannel = targets[0]!.channel;
|
|
693
|
+
config.inboundTarget = targets[0]!.target;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
702
697
|
function formatConfigPreview(
|
|
703
698
|
config: Record<string, unknown>,
|
|
704
699
|
): string[] {
|