libp2p-mesh 2026.6.2 → 2026.6.4

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/src/wizard.ts ADDED
@@ -0,0 +1,332 @@
1
+ import * as readline from "node:readline/promises";
2
+ import { MULTIADDR_PATTERN } from "./config-io.js";
3
+
4
+ // --- Types ---
5
+
6
+ export interface PromptChoice {
7
+ label: string;
8
+ value: string;
9
+ hint?: string;
10
+ }
11
+
12
+ export interface WizardPrompter {
13
+ question(prompt: string, defaultValue?: string): Promise<string>;
14
+ confirm(prompt: string, defaultValue?: boolean): Promise<boolean>;
15
+ select(prompt: string, choices: PromptChoice[]): Promise<string>;
16
+ multiline(prompt: string, helpText?: string): Promise<string[]>;
17
+ displayBox(title: string, lines: string[]): void;
18
+ displaySuccess(message: string): void;
19
+ displayError(message: string): void;
20
+ displayWarning(message: string): void;
21
+ close(): void;
22
+ }
23
+
24
+ // --- Validation ---
25
+
26
+ export function validateMultiaddr(raw: string): string | null {
27
+ const trimmed = raw.trim();
28
+ if (!trimmed) return "地址不能为空";
29
+ if (!MULTIADDR_PATTERN.test(trimmed)) {
30
+ return "多地址格式无效,必须以 /ip4/、/ip6/ 或 /dns/ 开头,如 /ip4/198.51.100.5/tcp/4001/p2p/12D3KooW...";
31
+ }
32
+ return null;
33
+ }
34
+
35
+ // --- Readline Prompter ---
36
+
37
+ export function createReadlinePrompter(): WizardPrompter {
38
+ const rl = readline.createInterface({
39
+ input: process.stdin,
40
+ output: process.stdout,
41
+ });
42
+
43
+ const displayWidth = 60;
44
+
45
+ function boxify(title: string, lines: string[]): void {
46
+ const top = "┌" + "─".repeat(displayWidth - 2) + "┐";
47
+ const padTitle = "│ " + title.padEnd(displayWidth - 6) + " │";
48
+ const sep = "│" + " ".repeat(displayWidth - 2) + "│";
49
+ const bottom = "└" + "─".repeat(displayWidth - 2) + "┘";
50
+ console.log(top);
51
+ console.log(padTitle);
52
+ console.log(sep);
53
+ for (const line of lines) {
54
+ const padLine = "│ " + line.padEnd(displayWidth - 6) + " │";
55
+ console.log(padLine);
56
+ }
57
+ console.log(bottom);
58
+ }
59
+
60
+ function formatChoices(choices: PromptChoice[]): string {
61
+ return choices
62
+ .map((c, i) => {
63
+ const hint = c.hint ? `(${c.hint})` : "";
64
+ return ` ${i + 1}. ${c.label} ${hint}`.trimEnd();
65
+ })
66
+ .join("\n");
67
+ }
68
+
69
+ async function question(prompt: string, defaultValue?: string): Promise<string> {
70
+ const suffix = defaultValue ? `(${defaultValue})` : "";
71
+ const answer = await rl.question(`${prompt}${suffix} → `);
72
+ return answer.trim() || defaultValue || "";
73
+ }
74
+
75
+ async function confirm(prompt: string, defaultValue?: boolean): Promise<boolean> {
76
+ const def = defaultValue === undefined ? true : defaultValue;
77
+ const yn = def ? "Y/n" : "y/N";
78
+ const answer = await rl.question(`${prompt}(${yn})→ `);
79
+ const trimmed = answer.trim().toLowerCase();
80
+ if (trimmed === "y" || trimmed === "yes") return true;
81
+ if (trimmed === "n" || trimmed === "no") return false;
82
+ return def;
83
+ }
84
+
85
+ async function select(prompt: string, choices: PromptChoice[]): Promise<string> {
86
+ console.log(`\n${prompt}`);
87
+ console.log(formatChoices(choices));
88
+ let answer = "";
89
+ while (true) {
90
+ const raw = await rl.question(` → `);
91
+ const num = parseInt(raw.trim(), 10);
92
+ if (num >= 1 && num <= choices.length) {
93
+ answer = choices[num - 1]!.value;
94
+ break;
95
+ }
96
+ console.log(` 请输入 1-${choices.length} 之间的数字。`);
97
+ }
98
+ return answer;
99
+ }
100
+
101
+ async function multiline(prompt: string, helpText?: string): Promise<string[]> {
102
+ console.log(`\n${prompt}`);
103
+ if (helpText) console.log(helpText);
104
+ const lines: string[] = [];
105
+ while (true) {
106
+ const line = await rl.question(" ");
107
+ if (!line.trim()) break;
108
+ const err = validateMultiaddr(line);
109
+ if (err) {
110
+ console.log(` ⚠ ${err}`);
111
+ continue;
112
+ }
113
+ if (lines.includes(line.trim())) {
114
+ console.log(" ⚠ 该地址已存在");
115
+ continue;
116
+ }
117
+ lines.push(line.trim());
118
+ }
119
+ if (lines.length > 0) {
120
+ console.log(` ✓ 已添加 ${lines.length} 个地址`);
121
+ }
122
+ return lines;
123
+ }
124
+
125
+ function displayBox(title: string, lines: string[]): void {
126
+ boxify(title, lines);
127
+ }
128
+
129
+ function displaySuccess(message: string): void {
130
+ console.log(` ✓ ${message}`);
131
+ }
132
+
133
+ function displayError(message: string): void {
134
+ console.log(` ✗ ${message}`);
135
+ }
136
+
137
+ function displayWarning(message: string): void {
138
+ console.log(` ⚠ ${message}`);
139
+ }
140
+
141
+ return {
142
+ question,
143
+ confirm,
144
+ select,
145
+ multiline,
146
+ displayBox,
147
+ displaySuccess,
148
+ displayError,
149
+ displayWarning,
150
+ close: () => rl.close(),
151
+ };
152
+ }
153
+
154
+ // --- Setup Wizard Logic (pure — takes prompter, returns config object) ---
155
+
156
+ export async function runSetupWizard(
157
+ prompter: WizardPrompter,
158
+ currentConfig: Record<string, unknown>,
159
+ availableChannels: string[],
160
+ ): Promise<Record<string, unknown>> {
161
+ const config: Record<string, unknown> = { ...currentConfig };
162
+
163
+ // --- Welcome ---
164
+ prompter.displayBox("🕸️ libp2p-mesh 配置向导", [
165
+ "我们将引导你完成 P2P Mesh 网络的基础配置。",
166
+ "任何时候按 Ctrl+C 可退出,配置不会被保存。",
167
+ ]);
168
+
169
+ // =================================================================
170
+ // Layer 1: Core Path
171
+ // =================================================================
172
+
173
+ // Step 1: Discovery mode
174
+ const discovery = await prompter.select("选择节点发现方式:", [
175
+ { value: "mdns", label: "mDNS — 局域网自动发现", hint: "默认,同一 WiFi 下推荐" },
176
+ { value: "bootstrap", label: "Bootstrap — 手动指定已知节点地址", hint: "跨网络场景" },
177
+ { value: "dht", label: "DHT — Kademlia 分布式发现", hint: "需要至少一个 bootstrap 入口" },
178
+ ]);
179
+ config.discovery = discovery;
180
+
181
+ // Step 2: Bootstrap addresses (only if discovery=bootstrap or dht)
182
+ if (discovery === "bootstrap" || discovery === "dht") {
183
+ const addrs = await prompter.multiline(
184
+ "输入 Bootstrap 节点地址(每行一个,空行结束):",
185
+ " 格式: /ip4/<IP>/tcp/<端口>/p2p/<PeerID>",
186
+ );
187
+ if (addrs.length > 0) {
188
+ config.bootstrapList = addrs;
189
+ }
190
+ }
191
+
192
+ // Step 3: Inbound targets
193
+ if (availableChannels.length === 0) {
194
+ prompter.displayWarning("未检测到已安装的聊天频道插件。你可以稍后在 openclaw.json 中手动配置 inboundTargets。");
195
+ } else {
196
+ const targets: Array<{ id?: string; channel: string; target: string }> = [];
197
+ let addMore = true;
198
+ while (addMore) {
199
+ const channelChoices: PromptChoice[] = availableChannels.map((ch) => ({
200
+ value: ch,
201
+ label: ch,
202
+ }));
203
+ const channel = await prompter.select("选择接收 P2P 消息的聊天频道:", channelChoices);
204
+ const target = await prompter.question(`输入 ${channel} 的接收目标(如 user:ou_xxx 或 chat:oc_xxx):`);
205
+ if (target) {
206
+ targets.push({ channel, target });
207
+ }
208
+ addMore = await prompter.confirm("是否添加更多接收目标?", false);
209
+ }
210
+ if (targets.length > 0) {
211
+ if (targets.length === 1 && !currentConfig.inboundChannel && !currentConfig.inboundTarget) {
212
+ // Single target: also set legacy inboundChannel/inboundTarget for backwards compat
213
+ config.inboundChannel = targets[0]!.channel;
214
+ config.inboundTarget = targets[0]!.target;
215
+ }
216
+ config.inboundTargets = targets;
217
+ }
218
+ }
219
+
220
+ // Step 4: Preview core config and confirm
221
+ const corePreview = formatConfigPreview(config);
222
+ prompter.displayBox("即将写入以下配置:", corePreview);
223
+ const coreConfirmed = await prompter.confirm("确认写入?", true);
224
+ if (!coreConfirmed) {
225
+ prompter.displayWarning("已取消,配置未保存。");
226
+ throw new WizardCancelledError();
227
+ }
228
+
229
+ // =================================================================
230
+ // Layer 2: Advanced (optional)
231
+ // =================================================================
232
+
233
+ const wantsAdvanced = await prompter.confirm("需要在不同网络之间使用吗(跨 WiFi / 跨城市)?", false);
234
+ if (wantsAdvanced) {
235
+ // Fixed port
236
+ const wantFixedPort = await prompter.confirm("是否使用固定监听端口?(推荐跨网络场景)", false);
237
+ if (wantFixedPort) {
238
+ const port = await prompter.question("端口号", "4001");
239
+ const portNum = parseInt(port, 10);
240
+ if (!isNaN(portNum) && portNum > 0 && portNum < 65536) {
241
+ config.listenAddrs = [`/ip4/0.0.0.0/tcp/${portNum}`];
242
+ } else {
243
+ prompter.displayWarning("端口号无效,使用默认动态端口。");
244
+ }
245
+ }
246
+
247
+ // NAT traversal
248
+ const wantNAT = await prompter.confirm("是否启用 NAT 穿透?(默认开启,推荐保留)", true);
249
+ config.enableNATTraversal = wantNAT;
250
+
251
+ // Circuit Relay
252
+ const wantRelay = await prompter.confirm("需要配置 Circuit Relay 中继节点吗?", false);
253
+ if (wantRelay) {
254
+ const relays = await prompter.multiline(
255
+ "输入 Relay 节点地址(每行一个,空行结束):",
256
+ " 格式: /ip4/<IP>/tcp/<端口>/p2p/<PeerID>",
257
+ );
258
+ if (relays.length > 0) {
259
+ config.relayList = relays;
260
+ }
261
+ }
262
+
263
+ // Custom instance name
264
+ const wantName = await prompter.confirm("为此节点设置一个自定义名称吗?(用于 P2P 网络中的身份显示)", false);
265
+ if (wantName) {
266
+ const name = await prompter.question("节点名称");
267
+ if (name) {
268
+ config.instanceName = name;
269
+ }
270
+ }
271
+
272
+ // Final preview and confirm (only if advanced config changed)
273
+ if (wantFixedPort || wantRelay || wantName || !config.enableNATTraversal) {
274
+ const finalPreview = formatConfigPreview(config);
275
+ prompter.displayBox("高级配置已追加,最终预览:", finalPreview);
276
+ const finalConfirmed = await prompter.confirm("确认写入?", true);
277
+ if (!finalConfirmed) {
278
+ prompter.displayWarning("已取消高级配置,核心配置已保存。");
279
+ }
280
+ }
281
+ }
282
+
283
+ prompter.displaySuccess("配置完成。运行 openclaw gateway restart 使配置生效。");
284
+ return config;
285
+ }
286
+
287
+ export class WizardCancelledError extends Error {
288
+ constructor() {
289
+ super("Wizard cancelled by user");
290
+ this.name = "WizardCancelledError";
291
+ }
292
+ }
293
+
294
+ // --- Helpers ---
295
+
296
+ function formatConfigPreview(config: Record<string, unknown>): string[] {
297
+ const lines: string[] = [];
298
+ if (config.discovery) {
299
+ lines.push(`discovery: ${config.discovery}`);
300
+ }
301
+ if (Array.isArray(config.bootstrapList) && config.bootstrapList.length > 0) {
302
+ lines.push(`bootstrapList: ${config.bootstrapList.length} 个节点`);
303
+ for (const addr of config.bootstrapList) {
304
+ lines.push(` ${addr}`);
305
+ }
306
+ }
307
+ if (Array.isArray(config.inboundTargets) && config.inboundTargets.length > 0) {
308
+ lines.push("inboundTargets:");
309
+ for (const t of config.inboundTargets as Array<{ id?: string; channel: string; target: string }>) {
310
+ lines.push(` - ${t.channel} / ${t.target}`);
311
+ }
312
+ } else if (config.inboundChannel && config.inboundTarget) {
313
+ lines.push(`inboundChannel: ${config.inboundChannel}`);
314
+ lines.push(`inboundTarget: ${config.inboundTarget}`);
315
+ }
316
+ if (Array.isArray(config.listenAddrs) && config.listenAddrs.length > 0) {
317
+ lines.push(`listenAddrs: ${(config.listenAddrs as string[]).join(", ")}`);
318
+ }
319
+ if (config.enableNATTraversal !== undefined) {
320
+ lines.push(`NAT 穿透: ${config.enableNATTraversal ? "开启" : "关闭"}`);
321
+ }
322
+ if (Array.isArray(config.relayList) && config.relayList.length > 0) {
323
+ lines.push(`relayList: ${config.relayList.length} 个节点`);
324
+ }
325
+ if (config.instanceName) {
326
+ lines.push(`instanceName: ${config.instanceName}`);
327
+ }
328
+ if (lines.length === 0) {
329
+ lines.push("(无配置更改)");
330
+ }
331
+ return lines;
332
+ }