libp2p-mesh 2026.6.4 → 2026.6.6

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.
@@ -1,5 +1,8 @@
1
- import * as readline from "node:readline/promises";
1
+ import * as readline from "node:readline";
2
2
  import { MULTIADDR_PATTERN } from "./config-io.js";
3
+ // --- Constants ---
4
+ const SENTINEL_SKIP = "__wizard_skip__";
5
+ const SENTINEL_FINISH = "__wizard_finish__";
3
6
  // --- Validation ---
4
7
  export function validateMultiaddr(raw) {
5
8
  const trimmed = raw.trim();
@@ -10,12 +13,163 @@ export function validateMultiaddr(raw) {
10
13
  }
11
14
  return null;
12
15
  }
13
- // --- Readline Prompter ---
14
- export function createReadlinePrompter() {
15
- const rl = readline.createInterface({
16
- input: process.stdin,
17
- output: process.stdout,
16
+ // ---------------------------------------------------------------------------
17
+ // Interactive arrow-key selector (with non-TTY fallback)
18
+ // ---------------------------------------------------------------------------
19
+ function isTTY() {
20
+ return Boolean(process.stdout.isTTY && process.stdin.isTTY);
21
+ }
22
+ /**
23
+ * Move cursor up `n` lines using ANSI escape (no-op on dumb terminals).
24
+ */
25
+ function ansiUp(n) {
26
+ if (n <= 0)
27
+ return;
28
+ process.stdout.write(`\x1b[${n}A`);
29
+ }
30
+ /**
31
+ * Clear current line from cursor to end.
32
+ */
33
+ function ansiClearLine() {
34
+ process.stdout.write("\x1b[K");
35
+ }
36
+ function renderChoices(choices, selectedIdx) {
37
+ for (let i = 0; i < choices.length; i++) {
38
+ const c = choices[i];
39
+ const pointer = i === selectedIdx ? "❯" : " ";
40
+ const hint = c.hint ? `(${c.hint})` : "";
41
+ const line = `${pointer} ${c.label} ${hint}`.trimEnd();
42
+ if (i === selectedIdx) {
43
+ process.stdout.write(`\x1b[7m${line}\x1b[0m\n`);
44
+ }
45
+ else {
46
+ process.stdout.write(`${line}\n`);
47
+ }
48
+ }
49
+ }
50
+ function reRenderChoices(choices, selectedIdx) {
51
+ const n = choices.length;
52
+ ansiUp(n);
53
+ for (let i = 0; i < n; i++) {
54
+ ansiClearLine();
55
+ const c = choices[i];
56
+ const pointer = i === selectedIdx ? "❯" : " ";
57
+ const hint = c.hint ? `(${c.hint})` : "";
58
+ const line = `${pointer} ${c.label} ${hint}`.trimEnd();
59
+ if (i === selectedIdx) {
60
+ process.stdout.write(`\x1b[7m${line}\x1b[0m\n`);
61
+ }
62
+ else {
63
+ process.stdout.write(`${line}\n`);
64
+ }
65
+ }
66
+ }
67
+ function clearChoices(count) {
68
+ ansiUp(count);
69
+ for (let i = 0; i < count; i++) {
70
+ ansiClearLine();
71
+ process.stdout.write("\n");
72
+ }
73
+ // Now we're back where the first choice was rendered
74
+ ansiUp(count);
75
+ }
76
+ function interactiveSelect(prompt, choices) {
77
+ let selectedIdx = 0;
78
+ // Print prompt + blank line
79
+ process.stdout.write(`\n${prompt}\n`);
80
+ // Initial render
81
+ renderChoices(choices, selectedIdx);
82
+ return new Promise((resolve) => {
83
+ const wasRaw = process.stdin.isRaw;
84
+ process.stdin.setRawMode(true);
85
+ readline.emitKeypressEvents(process.stdin);
86
+ const onKeypress = (_str, key) => {
87
+ if (!key)
88
+ return;
89
+ if (key.name === "up" || key.name === "k") {
90
+ selectedIdx =
91
+ (selectedIdx - 1 + choices.length) % choices.length;
92
+ reRenderChoices(choices, selectedIdx);
93
+ }
94
+ else if (key.name === "down" || key.name === "j") {
95
+ selectedIdx = (selectedIdx + 1) % choices.length;
96
+ reRenderChoices(choices, selectedIdx);
97
+ }
98
+ else if (key.name === "return" || key.name === "space") {
99
+ const chosen = choices[selectedIdx];
100
+ cleanup();
101
+ clearChoices(choices.length);
102
+ process.stdout.write(` → ${chosen.label}\n`);
103
+ resolve(chosen.value);
104
+ }
105
+ else if (key.ctrl && key.name === "c") {
106
+ cleanup();
107
+ process.stdout.write("\n");
108
+ process.exit(0);
109
+ }
110
+ };
111
+ const cleanup = () => {
112
+ process.stdin.setRawMode(wasRaw ?? false);
113
+ process.stdin.removeListener("keypress", onKeypress);
114
+ };
115
+ process.stdin.on("keypress", onKeypress);
18
116
  });
117
+ }
118
+ /**
119
+ * Plain numbered-list fallback when stdin is not a TTY (e.g. piped input).
120
+ */
121
+ function fallbackNumberedSelect(prompt, choices) {
122
+ return new Promise((resolve) => {
123
+ const rl = readline.createInterface({
124
+ input: process.stdin,
125
+ output: process.stdout,
126
+ });
127
+ console.log(`\n${prompt}`);
128
+ const formatted = choices
129
+ .map((c, i) => {
130
+ const hint = c.hint ? `(${c.hint})` : "";
131
+ return ` ${i + 1}. ${c.label} ${hint}`.trimEnd();
132
+ })
133
+ .join("\n");
134
+ console.log(formatted);
135
+ const ask = () => {
136
+ rl.question(` → `, (answer) => {
137
+ const num = parseInt(answer.trim(), 10);
138
+ if (num >= 1 && num <= choices.length) {
139
+ rl.close();
140
+ resolve(choices[num - 1].value);
141
+ }
142
+ else {
143
+ console.log(` 请输入 1-${choices.length} 之间的数字。`);
144
+ ask();
145
+ }
146
+ });
147
+ };
148
+ ask();
149
+ });
150
+ }
151
+ // ---------------------------------------------------------------------------
152
+ // Readline Prompter factory
153
+ // ---------------------------------------------------------------------------
154
+ export function createReadlinePrompter() {
155
+ let rl = null;
156
+ /** Lazy-create a readline interface (used by question / confirm). */
157
+ function ensureRL() {
158
+ if (!rl) {
159
+ rl = readline.createInterface({
160
+ input: process.stdin,
161
+ output: process.stdout,
162
+ });
163
+ }
164
+ return rl;
165
+ }
166
+ /** Dispose the active readline interface so it doesn't conflict with raw-mode selection. */
167
+ function disposeRL() {
168
+ if (rl) {
169
+ rl.close();
170
+ rl = null;
171
+ }
172
+ }
19
173
  const displayWidth = 60;
20
174
  function boxify(title, lines) {
21
175
  const top = "┌" + "─".repeat(displayWidth - 2) + "┐";
@@ -31,69 +185,113 @@ export function createReadlinePrompter() {
31
185
  }
32
186
  console.log(bottom);
33
187
  }
34
- function formatChoices(choices) {
35
- return choices
36
- .map((c, i) => {
37
- const hint = c.hint ? `(${c.hint})` : "";
38
- return ` ${i + 1}. ${c.label} ${hint}`.trimEnd();
39
- })
40
- .join("\n");
41
- }
42
- async function question(prompt, defaultValue) {
43
- const suffix = defaultValue ? `(${defaultValue})` : "";
44
- const answer = await rl.question(`${prompt}${suffix} → `);
45
- return answer.trim() || defaultValue || "";
188
+ async function question(prompt, defaultValue, options) {
189
+ const hints = [];
190
+ if (options?.allowSkip)
191
+ hints.push("回车跳过,! 完成配置");
192
+ if (defaultValue)
193
+ hints.push(`${defaultValue}`);
194
+ const suffix = hints.length > 0 ? `(${hints.join("")})` : "";
195
+ const rli = ensureRL();
196
+ return new Promise((resolve) => {
197
+ rli.question(`${prompt}${suffix} `, (answer) => {
198
+ const trimmed = answer.trim();
199
+ if (options?.allowSkip && trimmed === "!") {
200
+ resolve(SENTINEL_FINISH);
201
+ }
202
+ else if (!trimmed && options?.allowSkip) {
203
+ resolve(SENTINEL_SKIP);
204
+ }
205
+ else {
206
+ resolve(trimmed || defaultValue || "");
207
+ }
208
+ });
209
+ });
46
210
  }
47
- async function confirm(prompt, defaultValue) {
211
+ async function confirm(prompt, defaultValue, options) {
48
212
  const def = defaultValue === undefined ? true : defaultValue;
49
213
  const yn = def ? "Y/n" : "y/N";
50
- const answer = await rl.question(`${prompt}(${yn})→ `);
51
- const trimmed = answer.trim().toLowerCase();
52
- if (trimmed === "y" || trimmed === "yes")
53
- return true;
54
- if (trimmed === "n" || trimmed === "no")
55
- return false;
56
- return def;
214
+ const extra = options?.allowSkip ? ",F=完成,S=跳过" : "";
215
+ const rli = ensureRL();
216
+ return new Promise((resolve) => {
217
+ rli.question(`${prompt}(${yn}${extra})→ `, (answer) => {
218
+ const trimmed = answer.trim().toLowerCase();
219
+ if (options?.allowSkip && (trimmed === "f" || trimmed === "finish")) {
220
+ resolve(SENTINEL_FINISH);
221
+ }
222
+ else if (options?.allowSkip && (trimmed === "s" || trimmed === "skip")) {
223
+ resolve(null);
224
+ }
225
+ else if (trimmed === "y" || trimmed === "yes") {
226
+ resolve(true);
227
+ }
228
+ else if (trimmed === "n" || trimmed === "no") {
229
+ resolve(false);
230
+ }
231
+ else {
232
+ resolve(def);
233
+ }
234
+ });
235
+ });
57
236
  }
58
- async function select(prompt, choices) {
59
- console.log(`\n${prompt}`);
60
- console.log(formatChoices(choices));
61
- let answer = "";
62
- while (true) {
63
- const raw = await rl.question(` → `);
64
- const num = parseInt(raw.trim(), 10);
65
- if (num >= 1 && num <= choices.length) {
66
- answer = choices[num - 1].value;
67
- break;
68
- }
69
- console.log(` 请输入 1-${choices.length} 之间的数字。`);
70
- }
71
- return answer;
237
+ async function select(prompt, choices, options) {
238
+ const allChoices = options?.includeSkip
239
+ ? [
240
+ ...choices,
241
+ {
242
+ value: SENTINEL_FINISH,
243
+ label: "✓ Finish — 保存并退出",
244
+ hint: "",
245
+ },
246
+ {
247
+ value: SENTINEL_SKIP,
248
+ label: "⊘ Skip — 先不配置",
249
+ hint: "",
250
+ },
251
+ ]
252
+ : choices;
253
+ // Release readline so it doesn't fight over stdin in raw mode
254
+ disposeRL();
255
+ const result = isTTY()
256
+ ? await interactiveSelect(prompt, allChoices)
257
+ : await fallbackNumberedSelect(prompt, allChoices);
258
+ if (result === SENTINEL_FINISH)
259
+ throw new WizardFinishError();
260
+ return result;
72
261
  }
73
262
  async function multiline(prompt, helpText) {
74
263
  console.log(`\n${prompt}`);
75
264
  if (helpText)
76
265
  console.log(helpText);
77
266
  const lines = [];
78
- while (true) {
79
- const line = await rl.question(" ");
80
- if (!line.trim())
81
- break;
82
- const err = validateMultiaddr(line);
83
- if (err) {
84
- console.log(` ${err}`);
85
- continue;
86
- }
87
- if (lines.includes(line.trim())) {
88
- console.log(" ⚠ 该地址已存在");
89
- continue;
90
- }
91
- lines.push(line.trim());
92
- }
93
- if (lines.length > 0) {
94
- console.log(` ✓ 已添加 ${lines.length} 个地址`);
95
- }
96
- return lines;
267
+ const rli = ensureRL();
268
+ return new Promise((resolve) => {
269
+ const ask = () => {
270
+ rli.question(" ", (line) => {
271
+ if (!line.trim()) {
272
+ if (lines.length > 0) {
273
+ console.log(` 已添加 ${lines.length} 个地址`);
274
+ }
275
+ resolve(lines);
276
+ return;
277
+ }
278
+ const err = validateMultiaddr(line);
279
+ if (err) {
280
+ console.log(` ⚠ ${err}`);
281
+ ask();
282
+ return;
283
+ }
284
+ if (lines.includes(line.trim())) {
285
+ console.log(" ⚠ 该地址已存在");
286
+ ask();
287
+ return;
288
+ }
289
+ lines.push(line.trim());
290
+ ask();
291
+ });
292
+ };
293
+ ask();
294
+ });
97
295
  }
98
296
  function displayBox(title, lines) {
99
297
  boxify(title, lines);
@@ -116,118 +314,189 @@ export function createReadlinePrompter() {
116
314
  displaySuccess,
117
315
  displayError,
118
316
  displayWarning,
119
- close: () => rl.close(),
317
+ close: () => disposeRL(),
120
318
  };
121
319
  }
122
- // --- Setup Wizard Logic (pure — takes prompter, returns config object) ---
320
+ // ---------------------------------------------------------------------------
321
+ // Setup Wizard Logic (pure — takes prompter, returns config object)
322
+ // ---------------------------------------------------------------------------
123
323
  export async function runSetupWizard(prompter, currentConfig, availableChannels) {
124
324
  const config = { ...currentConfig };
125
- // --- Welcome ---
126
- prompter.displayBox("🕸️ libp2p-mesh 配置向导", [
127
- "我们将引导你完成 P2P Mesh 网络的基础配置。",
128
- "任何时候按 Ctrl+C 可退出,配置不会被保存。",
129
- ]);
130
- // =================================================================
131
- // Layer 1: Core Path
132
- // =================================================================
133
- // Step 1: Discovery mode
134
- const discovery = await prompter.select("选择节点发现方式:", [
135
- { value: "mdns", label: "mDNS — 局域网自动发现", hint: "默认,同一 WiFi 下推荐" },
136
- { value: "bootstrap", label: "Bootstrap — 手动指定已知节点地址", hint: "跨网络场景" },
137
- { value: "dht", label: "DHT — Kademlia 分布式发现", hint: "需要至少一个 bootstrap 入口" },
138
- ]);
139
- config.discovery = discovery;
140
- // Step 2: Bootstrap addresses (only if discovery=bootstrap or dht)
141
- if (discovery === "bootstrap" || discovery === "dht") {
142
- const addrs = await prompter.multiline("输入 Bootstrap 节点地址(每行一个,空行结束):", " 格式: /ip4/<IP>/tcp/<端口>/p2p/<PeerID>");
143
- if (addrs.length > 0) {
144
- config.bootstrapList = addrs;
145
- }
146
- }
147
- // Step 3: Inbound targets
148
- if (availableChannels.length === 0) {
149
- prompter.displayWarning("未检测到已安装的聊天频道插件。你可以稍后在 openclaw.json 中手动配置 inboundTargets。");
150
- }
151
- else {
152
- const targets = [];
153
- let addMore = true;
154
- while (addMore) {
155
- const channelChoices = availableChannels.map((ch) => ({
156
- value: ch,
157
- label: ch,
158
- }));
159
- const channel = await prompter.select("选择接收 P2P 消息的聊天频道:", channelChoices);
160
- const target = await prompter.question(`输入 ${channel} 的接收目标(如 user:ou_xxx 或 chat:oc_xxx):`);
161
- if (target) {
162
- targets.push({ channel, target });
325
+ const isSkip = (v) => v === SENTINEL_SKIP || v === null;
326
+ const assertNotFinished = (v) => {
327
+ if (v === SENTINEL_FINISH)
328
+ throw new WizardFinishError();
329
+ };
330
+ try {
331
+ // --- Welcome ---
332
+ prompter.displayBox("🕸️ libp2p-mesh 配置向导", [
333
+ "我们将引导你完成 P2P Mesh 网络的基础配置。",
334
+ "任何时候按 Ctrl+C 可退出,配置不会被保存。",
335
+ ]);
336
+ // =================================================================
337
+ // Layer 1: Core Path
338
+ // =================================================================
339
+ // Step 1: Discovery mode (select throws WizardFinishError on Finish)
340
+ const discovery = await prompter.select("选择节点发现方式:", [
341
+ {
342
+ value: "mdns",
343
+ label: "mDNS 局域网自动发现",
344
+ hint: "默认,同一 WiFi 下推荐",
345
+ },
346
+ {
347
+ value: "bootstrap",
348
+ label: "Bootstrap 手动指定已知节点地址",
349
+ hint: "跨网络场景",
350
+ },
351
+ {
352
+ value: "dht",
353
+ label: "DHT Kademlia 分布式发现",
354
+ hint: "需要至少一个 bootstrap 入口",
355
+ },
356
+ ], { includeSkip: true });
357
+ if (!isSkip(discovery)) {
358
+ config.discovery = discovery;
359
+ // Step 2: Bootstrap addresses (only if discovery=bootstrap or dht)
360
+ if (discovery === "bootstrap" || discovery === "dht") {
361
+ const addrs = await prompter.multiline("输入 Bootstrap 节点地址(每行一个,空行结束):", " 格式: /ip4/<IP>/tcp/<端口>/p2p/<PeerID>");
362
+ if (addrs.length > 0) {
363
+ config.bootstrapList = addrs;
364
+ }
163
365
  }
164
- addMore = await prompter.confirm("是否添加更多接收目标?", false);
165
366
  }
166
- if (targets.length > 0) {
167
- if (targets.length === 1 && !currentConfig.inboundChannel && !currentConfig.inboundTarget) {
168
- // Single target: also set legacy inboundChannel/inboundTarget for backwards compat
169
- config.inboundChannel = targets[0].channel;
170
- config.inboundTarget = targets[0].target;
367
+ // Step 3: Inbound targets
368
+ if (availableChannels.length === 0) {
369
+ prompter.displayWarning("未检测到已安装的聊天频道插件。你可以稍后在 openclaw.json 中手动配置 inboundTargets。");
370
+ }
371
+ else {
372
+ const wantInbound = await prompter.confirm("是否配置 P2P 消息的接收目标?", false, { allowSkip: true });
373
+ assertNotFinished(wantInbound);
374
+ if (wantInbound === true) {
375
+ const targets = [];
376
+ let addMore = true;
377
+ while (addMore) {
378
+ const channelChoices = availableChannels.map((ch) => ({
379
+ value: ch,
380
+ label: ch,
381
+ }));
382
+ // select throws WizardFinishError on Finish
383
+ const channel = await prompter.select("选择接收 P2P 消息的聊天频道:", channelChoices, { includeSkip: true });
384
+ if (isSkip(channel))
385
+ break;
386
+ const target = await prompter.question(`输入 ${channel} 的接收目标(如 user:ou_xxx 或 chat:oc_xxx):`, undefined, { allowSkip: true });
387
+ assertNotFinished(target);
388
+ if (isSkip(target))
389
+ break;
390
+ if (target) {
391
+ targets.push({ channel, target });
392
+ }
393
+ const more = await prompter.confirm("是否添加更多接收目标?", false);
394
+ assertNotFinished(more);
395
+ addMore = more ?? false;
396
+ }
397
+ if (targets.length > 0) {
398
+ if (targets.length === 1 &&
399
+ !currentConfig.inboundChannel &&
400
+ !currentConfig.inboundTarget) {
401
+ // Single target: also set legacy inboundChannel/inboundTarget for backwards compat
402
+ config.inboundChannel = targets[0].channel;
403
+ config.inboundTarget = targets[0].target;
404
+ }
405
+ config.inboundTargets = targets;
406
+ }
171
407
  }
172
- config.inboundTargets = targets;
408
+ // wantInbound === null (skip) or false (no) → skip inbound config
173
409
  }
174
- }
175
- // Step 4: Preview core config and confirm
176
- const corePreview = formatConfigPreview(config);
177
- prompter.displayBox("即将写入以下配置:", corePreview);
178
- const coreConfirmed = await prompter.confirm("确认写入?", true);
179
- if (!coreConfirmed) {
180
- prompter.displayWarning("已取消,配置未保存。");
181
- throw new WizardCancelledError();
182
- }
183
- // =================================================================
184
- // Layer 2: Advanced (optional)
185
- // =================================================================
186
- const wantsAdvanced = await prompter.confirm("需要在不同网络之间使用吗(跨 WiFi / 跨城市)?", false);
187
- if (wantsAdvanced) {
188
- // Fixed port
189
- const wantFixedPort = await prompter.confirm("是否使用固定监听端口?(推荐跨网络场景)", false);
190
- if (wantFixedPort) {
191
- const port = await prompter.question("端口号", "4001");
192
- const portNum = parseInt(port, 10);
193
- if (!isNaN(portNum) && portNum > 0 && portNum < 65536) {
194
- config.listenAddrs = [`/ip4/0.0.0.0/tcp/${portNum}`];
410
+ // Step 4: Preview core config and confirm
411
+ const corePreview = formatConfigPreview(config);
412
+ prompter.displayBox("即将写入以下配置:", corePreview);
413
+ const coreConfirmed = await prompter.confirm("确认写入?", true);
414
+ if (!coreConfirmed) {
415
+ prompter.displayWarning("已取消,配置未保存。");
416
+ throw new WizardCancelledError();
417
+ }
418
+ // =================================================================
419
+ // Layer 2: Advanced (optional)
420
+ // =================================================================
421
+ const wantsAdvanced = await prompter.confirm("需要在不同网络之间使用吗(跨 WiFi / 跨城市)?", false, { allowSkip: true });
422
+ assertNotFinished(wantsAdvanced);
423
+ if (wantsAdvanced === true) {
424
+ // Fixed port
425
+ const wantFixedPort = await prompter.confirm("是否使用固定监听端口?(推荐跨网络场景)", false, { allowSkip: true });
426
+ assertNotFinished(wantFixedPort);
427
+ if (wantFixedPort === true) {
428
+ const port = await prompter.question("端口号", "4001", {
429
+ allowSkip: true,
430
+ });
431
+ assertNotFinished(port);
432
+ if (!isSkip(port)) {
433
+ const portNum = parseInt(port, 10);
434
+ if (!isNaN(portNum) && portNum > 0 && portNum < 65536) {
435
+ config.listenAddrs = [`/ip4/0.0.0.0/tcp/${portNum}`];
436
+ }
437
+ else {
438
+ prompter.displayWarning("端口号无效,使用默认动态端口。");
439
+ }
440
+ }
195
441
  }
196
- else {
197
- prompter.displayWarning("端口号无效,使用默认动态端口。");
442
+ // NAT traversal
443
+ const wantNAT = await prompter.confirm("是否启用 NAT 穿透?(默认开启,推荐保留)", true, { allowSkip: true });
444
+ assertNotFinished(wantNAT);
445
+ if (wantNAT !== null) {
446
+ config.enableNATTraversal = wantNAT;
198
447
  }
199
- }
200
- // NAT traversal
201
- const wantNAT = await prompter.confirm("是否启用 NAT 穿透?(默认开启,推荐保留)", true);
202
- config.enableNATTraversal = wantNAT;
203
- // Circuit Relay
204
- const wantRelay = await prompter.confirm("需要配置 Circuit Relay 中继节点吗?", false);
205
- if (wantRelay) {
206
- const relays = await prompter.multiline("输入 Relay 节点地址(每行一个,空行结束):", " 格式: /ip4/<IP>/tcp/<端口>/p2p/<PeerID>");
207
- if (relays.length > 0) {
208
- config.relayList = relays;
448
+ // Circuit Relay
449
+ const wantRelay = await prompter.confirm("需要配置 Circuit Relay 中继节点吗?", false, { allowSkip: true });
450
+ assertNotFinished(wantRelay);
451
+ if (wantRelay === true) {
452
+ const relays = await prompter.multiline("输入 Relay 节点地址(每行一个,空行结束):", " 格式: /ip4/<IP>/tcp/<端口>/p2p/<PeerID>");
453
+ if (relays.length > 0) {
454
+ config.relayList = relays;
455
+ }
209
456
  }
210
- }
211
- // Custom instance name
212
- const wantName = await prompter.confirm("为此节点设置一个自定义名称吗?(用于 P2P 网络中的身份显示)", false);
213
- if (wantName) {
214
- const name = await prompter.question("节点名称");
215
- if (name) {
216
- config.instanceName = name;
457
+ // Custom instance name
458
+ const wantName = await prompter.confirm("为此节点设置一个自定义名称吗?(用于 P2P 网络中的身份显示)", false, { allowSkip: true });
459
+ assertNotFinished(wantName);
460
+ if (wantName === true) {
461
+ const name = await prompter.question("节点名称", undefined, {
462
+ allowSkip: true,
463
+ });
464
+ assertNotFinished(name);
465
+ if (!isSkip(name) && name) {
466
+ config.instanceName = name;
467
+ }
468
+ }
469
+ // Final preview and confirm (only if advanced config changed)
470
+ if (wantFixedPort === true ||
471
+ wantRelay === true ||
472
+ wantName === true ||
473
+ (wantNAT !== null && !config.enableNATTraversal)) {
474
+ const finalPreview = formatConfigPreview(config);
475
+ prompter.displayBox("高级配置已追加,最终预览:", finalPreview);
476
+ const finalConfirmed = await prompter.confirm("确认写入?", true);
477
+ if (!finalConfirmed) {
478
+ prompter.displayWarning("已取消高级配置,核心配置已保存。");
479
+ }
217
480
  }
218
481
  }
219
- // Final preview and confirm (only if advanced config changed)
220
- if (wantFixedPort || wantRelay || wantName || !config.enableNATTraversal) {
221
- const finalPreview = formatConfigPreview(config);
222
- prompter.displayBox("高级配置已追加,最终预览:", finalPreview);
223
- const finalConfirmed = await prompter.confirm("确认写入?", true);
224
- if (!finalConfirmed) {
225
- prompter.displayWarning("已取消高级配置,核心配置已保存。");
482
+ prompter.displaySuccess("配置完成。运行 openclaw gateway restart 使配置生效。");
483
+ return config;
484
+ }
485
+ catch (err) {
486
+ if (err instanceof WizardFinishError) {
487
+ // User chose Finish — show accumulated config and save
488
+ const finishPreview = formatConfigPreview(config);
489
+ prompter.displayBox("配置完成,即将写入:", finishPreview);
490
+ const confirmed = await prompter.confirm("确认写入?", true);
491
+ if (!confirmed) {
492
+ prompter.displayWarning("已取消,配置未保存。");
493
+ throw new WizardCancelledError();
226
494
  }
495
+ prompter.displaySuccess("配置完成。运行 openclaw gateway restart 使配置生效。");
496
+ return config;
227
497
  }
498
+ throw err;
228
499
  }
229
- prompter.displaySuccess("配置完成。运行 openclaw gateway restart 使配置生效。");
230
- return config;
231
500
  }
232
501
  export class WizardCancelledError extends Error {
233
502
  constructor() {
@@ -235,19 +504,27 @@ export class WizardCancelledError extends Error {
235
504
  this.name = "WizardCancelledError";
236
505
  }
237
506
  }
507
+ export class WizardFinishError extends Error {
508
+ constructor() {
509
+ super("Wizard finished early by user");
510
+ this.name = "WizardFinishError";
511
+ }
512
+ }
238
513
  // --- Helpers ---
239
514
  function formatConfigPreview(config) {
240
515
  const lines = [];
241
516
  if (config.discovery) {
242
517
  lines.push(`discovery: ${config.discovery}`);
243
518
  }
244
- if (Array.isArray(config.bootstrapList) && config.bootstrapList.length > 0) {
519
+ if (Array.isArray(config.bootstrapList) &&
520
+ config.bootstrapList.length > 0) {
245
521
  lines.push(`bootstrapList: ${config.bootstrapList.length} 个节点`);
246
522
  for (const addr of config.bootstrapList) {
247
523
  lines.push(` ${addr}`);
248
524
  }
249
525
  }
250
- if (Array.isArray(config.inboundTargets) && config.inboundTargets.length > 0) {
526
+ if (Array.isArray(config.inboundTargets) &&
527
+ config.inboundTargets.length > 0) {
251
528
  lines.push("inboundTargets:");
252
529
  for (const t of config.inboundTargets) {
253
530
  lines.push(` - ${t.channel} / ${t.target}`);
@@ -257,13 +534,15 @@ function formatConfigPreview(config) {
257
534
  lines.push(`inboundChannel: ${config.inboundChannel}`);
258
535
  lines.push(`inboundTarget: ${config.inboundTarget}`);
259
536
  }
260
- if (Array.isArray(config.listenAddrs) && config.listenAddrs.length > 0) {
537
+ if (Array.isArray(config.listenAddrs) &&
538
+ config.listenAddrs.length > 0) {
261
539
  lines.push(`listenAddrs: ${config.listenAddrs.join(", ")}`);
262
540
  }
263
541
  if (config.enableNATTraversal !== undefined) {
264
542
  lines.push(`NAT 穿透: ${config.enableNATTraversal ? "开启" : "关闭"}`);
265
543
  }
266
- if (Array.isArray(config.relayList) && config.relayList.length > 0) {
544
+ if (Array.isArray(config.relayList) &&
545
+ config.relayList.length > 0) {
267
546
  lines.push(`relayList: ${config.relayList.length} 个节点`);
268
547
  }
269
548
  if (config.instanceName) {