@aiyiran/myclaw 1.0.29 → 1.0.31

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/index.js CHANGED
@@ -236,6 +236,15 @@ function runNew() {
236
236
  }
237
237
  }
238
238
 
239
+ // ============================================================================
240
+ // 向导系统(统一入口)
241
+ // ============================================================================
242
+
243
+ function runWeixin() {
244
+ const { runWeixin } = require('./wizards/index');
245
+ runWeixin();
246
+ }
247
+
239
248
  // ============================================================================
240
249
  // WSL2 安装 (独立模块)
241
250
  // ============================================================================
@@ -519,6 +528,9 @@ function showHelp() {
519
528
  console.log('用法:');
520
529
  console.log(' myclaw <command>');
521
530
  console.log('');
531
+ console.log('向导 (交互式):');
532
+ console.log(' weixin 微信接入向导(步骤引导 + 教学说明)');
533
+ console.log('');
522
534
  console.log('命令:');
523
535
  console.log(' install 安装 OpenClaw 服务');
524
536
  console.log(' status 简化版状态查看(学生友好)');
@@ -532,11 +544,11 @@ function showHelp() {
532
544
  console.log(' help 显示帮助信息');
533
545
  console.log('');
534
546
  console.log('示例:');
547
+ console.log(' myclaw weixin # 打开微信接入向导');
535
548
  console.log(' myclaw install # 安装 OpenClaw');
536
549
  console.log(' myclaw status # 查看状态');
537
550
  console.log(' myclaw new helper # 创建名为 helper 的 Agent');
538
551
  console.log(' myclaw open # 打开默认控制台');
539
- console.log(' myclaw open http://... # 打开指定 URL');
540
552
  console.log(' myclaw patch # 注入 UI 扩展');
541
553
  console.log(' myclaw restart # 重启 Gateway');
542
554
  console.log('');
@@ -552,6 +564,8 @@ console.log(colors.blue + 'MyClaw v' + version + colors.nc);
552
564
 
553
565
  if (!command || command === 'help' || command === '--help' || command === '-h') {
554
566
  showHelp();
567
+ } else if (command === 'weixin') {
568
+ runWeixin();
555
569
  } else if (command === 'install') {
556
570
  runInstall();
557
571
  } else if (command === 'status') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiyiran/myclaw",
3
- "version": "1.0.29",
3
+ "version": "1.0.31",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -13,5 +13,11 @@
13
13
  },
14
14
  "keywords": [],
15
15
  "author": "",
16
- "license": "ISC"
16
+ "license": "ISC",
17
+ "dependencies": {
18
+ "chalk": "^4.1.2"
19
+ },
20
+ "engines": {
21
+ "node": ">=16"
22
+ }
17
23
  }
@@ -0,0 +1,52 @@
1
+ {
2
+ "restart": {
3
+ "id": "restart-gateway",
4
+ "icon": "🔄",
5
+ "label": "重启 Gateway",
6
+ "type": "action",
7
+ "command": {
8
+ "exec": "openclaw gateway restart",
9
+ "display": "openclaw gateway restart"
10
+ },
11
+ "teach": {
12
+ "title": "重启 Gateway 使配置生效",
13
+ "description": "重启 OpenClaw 核心服务,让之前的所有配置变更生效。\n\nGateway 是 OpenClaw 的「大脑」,它在启动时读取配置。\n修改配置(如安装插件、启用插件)后,\n需要重启让 Gateway 加载最新配置。\n\n类比:改了系统设置后,有时候需要重启才能生效。",
14
+ "look_for": "重启后 Gateway 运行中,控制台地址 http://127.0.0.1:18789",
15
+ "tip": "⏳ 重启期间短暂不可用,请等待几秒钟再继续。"
16
+ }
17
+ },
18
+
19
+ "check-health": {
20
+ "id": "check-gateway-health",
21
+ "icon": "🔍",
22
+ "label": "检查 Gateway 运行状态",
23
+ "type": "check",
24
+ "command": {
25
+ "exec": "openclaw health",
26
+ "display": "openclaw health"
27
+ },
28
+ "teach": {
29
+ "title": "检查 Gateway 是否正在运行",
30
+ "description": "检查 OpenClaw Gateway 的健康状态。\n\n返回 OK 说明 Gateway 正常运行,\n返回错误说明 Gateway 未启动或出现问题。",
31
+ "look_for": "输出 OK 或类似健康状态提示",
32
+ "tip": "🔒 只读检查,可以随时运行。"
33
+ }
34
+ },
35
+
36
+ "check-status": {
37
+ "id": "check-openclaw-status",
38
+ "icon": "📊",
39
+ "label": "查看 OpenClaw 完整状态",
40
+ "type": "check",
41
+ "command": {
42
+ "exec": "openclaw status",
43
+ "display": "openclaw status"
44
+ },
45
+ "teach": {
46
+ "title": "查看 OpenClaw 完整运行状态",
47
+ "description": "显示 OpenClaw 所有组件的运行状态,\n包括 Gateway、已加载插件、已连接渠道等。",
48
+ "look_for": "查看各组件状态是否正常",
49
+ "tip": "🔒 只读命令,可以随时查看。"
50
+ }
51
+ }
52
+ }
@@ -0,0 +1,153 @@
1
+ {
2
+ "id": "weixin-bind",
3
+ "title": "微信多账号绑定",
4
+ "subtitle": "为每个微信账号绑定专属的 Agent",
5
+
6
+ "steps": [
7
+ {
8
+ "id": "check-before-bind",
9
+ "icon": "🔍",
10
+ "label": "查看当前账号列表",
11
+ "type": "check",
12
+ "command": {
13
+ "exec": "openclaw channels status",
14
+ "display": "openclaw channels status"
15
+ },
16
+ "teach": {
17
+ "title": "查看当前已登录的账号列表",
18
+ "description": "在开始绑定之前,先看看当前哪些微信账号已经登录。\n\n这条命令会列出所有已登录的渠道账号,以及它们当前\n绑定到哪个 Agent(如果已绑定的话)。\n\n从这里你可以确认账号ID(--account 参数的值),\n后续绑定步骤会用到。",
19
+ "look_for": "每行显示一个账号,记下账号ID(通常是一串标识符)",
20
+ "tip": "🔒 只读操作,可以随时运行查看最新状态。"
21
+ }
22
+ },
23
+ {
24
+ "id": "login-account-1",
25
+ "icon": "📱",
26
+ "label": "扫码登录第一个微信账号",
27
+ "type": "interactive",
28
+ "command": {
29
+ "exec": "openclaw channels login --channel openclaw-weixin",
30
+ "display": "openclaw channels login --channel openclaw-weixin"
31
+ },
32
+ "teach": {
33
+ "title": "扫码登录第一个微信账号",
34
+ "description": "每个微信账号需要单独扫码登录,系统会为它创建一个独立的账号条目。\n\n这一步登录「第一个微信账号」(比如用于课堂答疑的账号)。\n\n操作步骤:\n 1. 打开手机微信(第一个账号)\n 2. 扫描终端里的二维码\n 3. 手机上点击「确认授权」",
35
+ "look_for": "登录成功后,系统会显示新的账号条目(记下账号ID)",
36
+ "tip": "📱 确保手机是你想绑定的第一个账号,不要用错手机。"
37
+ }
38
+ },
39
+ {
40
+ "id": "login-account-2",
41
+ "icon": "📱",
42
+ "label": "扫码登录第二个微信账号",
43
+ "type": "interactive",
44
+ "command": {
45
+ "exec": "openclaw channels login --channel openclaw-weixin",
46
+ "display": "openclaw channels login --channel openclaw-weixin"
47
+ },
48
+ "teach": {
49
+ "title": "扫码登录第二个微信账号",
50
+ "description": "现在登录「第二个微信账号」(比如用于生活助手的账号)。\n\n这条命令和第一步相同,但登录的是不同的微信账号,\n系统会自动识别并创建新的独立账号条目。\n\n操作步骤:\n 1. 打开手机微信(第二个账号)\n 2. 扫描终端里的二维码\n 3. 手机上点击「确认授权」",
51
+ "look_for": "看到第二个账号条目出现在列表中",
52
+ "tip": "📱 换另一部手机(或另一个微信账号)来扫码。"
53
+ }
54
+ },
55
+ {
56
+ "id": "check-accounts",
57
+ "icon": "🔍",
58
+ "label": "查看账号列表(确认登录成功)",
59
+ "type": "check",
60
+ "capture": "accounts_output",
61
+ "command": {
62
+ "exec": "openclaw channels status",
63
+ "display": "openclaw channels status"
64
+ },
65
+ "teach": {
66
+ "title": "确认两个账号都已登录",
67
+ "description": "再次查看账号列表,确认两个微信账号都已成功登录。\n\n在这一步,你需要从输出中:\n 1. 找到第一个账号的「账号ID」\n 2. 找到第二个账号的「账号ID」\n\n后面的绑定步骤(第 5、6 步)的 --account 参数就填这些ID。\n\n✅ 这一步的输出会被记录,绑定时可直接参考。",
68
+ "look_for": "两个账号都出现在列表中,状态为 connected",
69
+ "tip": "📋 详细看输出,记录账号ID,避免后续填写出错。"
70
+ }
71
+ },
72
+ {
73
+ "id": "bind-account-1",
74
+ "icon": "🔗",
75
+ "label": "绑定账号1 → Agent",
76
+ "type": "input",
77
+ "requires": ["check-accounts"],
78
+ "show_capture": "accounts_output",
79
+ "command": {
80
+ "template": "openclaw channels bind --channel openclaw-weixin --account {{account}} --agent {{agent}}",
81
+ "display": "openclaw channels bind --channel openclaw-weixin --account {{account}} --agent {{agent}}"
82
+ },
83
+ "inputs": [
84
+ {
85
+ "key": "account",
86
+ "label": "账号1 的账号ID",
87
+ "hint": "从上一步(步骤4)的输出中找到第一个账号的标识符",
88
+ "placeholder": "例如:wx_123456789"
89
+ },
90
+ {
91
+ "key": "agent",
92
+ "label": "要绑定到的 Agent 名称",
93
+ "hint": "填写你已创建的 Agent 名称(使用 myclaw status 可查看)",
94
+ "placeholder": "例如:blog、helper、assistant"
95
+ }
96
+ ],
97
+ "teach": {
98
+ "title": "将账号1绑定到指定 Agent",
99
+ "description": "这条命令将第一个微信账号与指定的 Agent 绑定。\n\n绑定后,从这个微信账号发来的消息,\n都会由对应的 Agent 来处理和回复。\n\n命令中的两个参数:\n --account 指定要绑定的微信账号ID\n --agent 指定负责处理消息的 Agent 名称",
100
+ "look_for": "看到绑定成功的提示",
101
+ "tip": "💡 绑定是永久的,除非你重新执行绑定命令修改。"
102
+ }
103
+ },
104
+ {
105
+ "id": "bind-account-2",
106
+ "icon": "🔗",
107
+ "label": "绑定账号2 → Agent",
108
+ "type": "input",
109
+ "requires": ["check-accounts"],
110
+ "show_capture": "accounts_output",
111
+ "command": {
112
+ "template": "openclaw channels bind --channel openclaw-weixin --account {{account}} --agent {{agent}}",
113
+ "display": "openclaw channels bind --channel openclaw-weixin --account {{account}} --agent {{agent}}"
114
+ },
115
+ "inputs": [
116
+ {
117
+ "key": "account",
118
+ "label": "账号2 的账号ID",
119
+ "hint": "从步骤4的输出中找到第二个账号的标识符",
120
+ "placeholder": "例如:wx_987654321"
121
+ },
122
+ {
123
+ "key": "agent",
124
+ "label": "要绑定到的 Agent 名称",
125
+ "hint": "填写另一个 Agent 的名称(与账号1绑定的不同)",
126
+ "placeholder": "例如:classroom、assistant"
127
+ }
128
+ ],
129
+ "teach": {
130
+ "title": "将账号2绑定到另一个 Agent",
131
+ "description": "这条命令将第二个微信账号绑定到另一个 Agent。\n\n多账号绑定的典型用法:\n - 账号1 → Agent「blog」 ← 处理博客读者的问题\n - 账号2 → Agent「classroom」← 处理学生课堂问题\n\n每个微信账号有自己专属的对话上下文和 Agent 配置。",
132
+ "look_for": "看到绑定成功的提示",
133
+ "tip": "💡 两个账号绑定的 Agent 不同,这正是多账号的意义所在。"
134
+ }
135
+ },
136
+ {
137
+ "id": "verify-bindings",
138
+ "icon": "✅",
139
+ "label": "验证绑定结果",
140
+ "type": "check",
141
+ "command": {
142
+ "exec": "openclaw channels status",
143
+ "display": "openclaw channels status"
144
+ },
145
+ "teach": {
146
+ "title": "验证所有账号的绑定情况",
147
+ "description": "最后查看账号状态,确认绑定配置正确。\n\n如果配置成功,你应该能看到:\n - 账号1 → Agent 名称A\n - 账号2 → Agent 名称B\n\n如果某个账号绑定不对,重新执行对应的绑定步骤(第5或第6步)。",
148
+ "look_for": "每个账号后面显示了正确的 Agent 名称",
149
+ "tip": "🎉 绑定完成后,从不同微信账号发消息,对应 Agent 会自动回复!"
150
+ }
151
+ }
152
+ ]
153
+ }
@@ -0,0 +1,117 @@
1
+ {
2
+ "id": "weixin",
3
+ "title": "微信接入向导",
4
+ "subtitle": "引导你一步步完成微信通道的接入配置",
5
+
6
+ "steps": [
7
+ {
8
+ "id": "check-plugins",
9
+ "icon": "🔍",
10
+ "label": "查看微信插件状态",
11
+ "type": "check",
12
+ "command": {
13
+ "exec": "openclaw plugins list",
14
+ "display": "openclaw plugins list"
15
+ },
16
+ "teach": {
17
+ "title": "查看微信插件状态",
18
+ "description": "列出系统中当前已安装的所有 OpenClaw 插件。\n\nOpenClaw 通过「插件」来支持不同平台(微信、飞书、钉钉等),\n每个插件负责一个平台的通信协议。",
19
+ "look_for": "在输出中查找 openclaw-weixin,有就说明插件已安装",
20
+ "tip": "🔒 只读操作,不修改任何配置,可以随时查看。"
21
+ }
22
+ },
23
+ {
24
+ "id": "check-channels",
25
+ "icon": "🔍",
26
+ "label": "查看渠道连接状态",
27
+ "type": "check",
28
+ "command": {
29
+ "exec": "openclaw channels list",
30
+ "display": "openclaw channels list"
31
+ },
32
+ "teach": {
33
+ "title": "查看所有渠道的连接状态",
34
+ "description": "列出系统中所有已配置的「渠道(Channel)」。\n\n渠道是平台账号与 OpenClaw 的实际连接通道:\n - 插件 = 能力(知道怎么对接微信)\n - 渠道 = 连接(你的微信账号已登录并在线)\n\n两者都需要就绪,微信功能才能正常运行。",
35
+ "look_for": "查找 openclaw-weixin,状态是否为 connected",
36
+ "tip": "🔒 只读操作,不修改任何配置,可以随时查看。"
37
+ }
38
+ },
39
+ {
40
+ "id": "install-plugin",
41
+ "icon": "📦",
42
+ "label": "安装微信插件",
43
+ "type": "action",
44
+ "command": {
45
+ "exec": "openclaw plugins install \"@tencent-weixin/openclaw-weixin\"",
46
+ "display": "openclaw plugins install \"@tencent-weixin/openclaw-weixin\""
47
+ },
48
+ "teach": {
49
+ "title": "安装微信插件",
50
+ "description": "从官方源下载并安装微信通道插件。\n\n这条命令做了三件事:\n 1. 找到 @tencent-weixin/openclaw-weixin 插件包\n 2. 从官方源下载插件代码\n 3. 安装到 OpenClaw 插件目录\n\n整个接入流程只需安装一次,重新登录时不需要重复执行。",
51
+ "look_for": "看到 installed successfully 或类似成功提示",
52
+ "tip": "⚠️ 需要网络连接,首次下载可能需要一点时间,请耐心等待。"
53
+ }
54
+ },
55
+ {
56
+ "id": "enable-plugin",
57
+ "icon": "⚙️",
58
+ "label": "启用微信插件",
59
+ "type": "action",
60
+ "command": {
61
+ "exec": "openclaw config set plugins.entries.openclaw-weixin.enabled true",
62
+ "display": "openclaw config set plugins.entries.openclaw-weixin.enabled true"
63
+ },
64
+ "teach": {
65
+ "title": "启用微信插件",
66
+ "description": "将微信插件的「启用状态」写入 OpenClaw 配置文件。\n\n安装插件 ≠ 启用插件。这类似于:\n - 安装 = 把软件下载到电脑\n - 启用 = 打开这个软件的开关\n\n这条命令修改了配置文件中的一个开关:\n plugins.entries.openclaw-weixin.enabled = true",
67
+ "look_for": "命令无报错即为成功,配置通常立即生效",
68
+ "tip": "💡 安装和启用是两个独立步骤,缺一不可。"
69
+ }
70
+ },
71
+ {
72
+ "id": "login-weixin",
73
+ "icon": "📱",
74
+ "label": "扫码登录微信",
75
+ "type": "interactive",
76
+ "command": {
77
+ "exec": "openclaw channels login --channel openclaw-weixin",
78
+ "display": "openclaw channels login --channel openclaw-weixin"
79
+ },
80
+ "teach": {
81
+ "title": "扫码登录微信账号",
82
+ "description": "将你的微信账号与 OpenClaw 渠道绑定。\n\n执行后终端会显示一个二维码,操作步骤:\n 1. 打开手机微信\n 2. 点击右上角「+」→「扫一扫」\n 3. 扫描终端里的二维码\n 4. 手机上点击「确认授权」\n\n重新连接时,只需重复这一步,不需要重装插件。",
83
+ "look_for": "终端显示「登录成功」或类似提示",
84
+ "tip": "📱 准备好手机,扫码后在手机上点击确认才算完成。"
85
+ }
86
+ },
87
+ { "$ref": "../../commons/gateway.steps.json#restart" },
88
+ {
89
+ "id": "verify",
90
+ "icon": "✅",
91
+ "label": "验证微信接入是否成功",
92
+ "type": "check",
93
+ "command": {
94
+ "exec": "openclaw channels list",
95
+ "display": "openclaw channels list"
96
+ },
97
+ "teach": {
98
+ "title": "验证微信是否成功接入",
99
+ "description": "再次查看渠道列表,确认微信已正常连接。\n\n这和第 2 步用的是同一条命令,但现在应该能看到:\n - openclaw-weixin 出现在列表中\n - 状态显示为 connected(已连接)\n\n如果状态还是 disconnected,可能需要重新扫码(步骤 5)。",
100
+ "look_for": "openclaw-weixin 的状态为 connected ✅",
101
+ "tip": "🎉 如果看到 connected,恭喜完成微信接入!"
102
+ }
103
+ },
104
+ {
105
+ "id": "multi-account",
106
+ "icon": "👥",
107
+ "label": "多账号绑定(进阶)",
108
+ "type": "submenu",
109
+ "submenu": "./bind.config.json",
110
+ "teach": {
111
+ "title": "微信多账号绑定",
112
+ "description": "如果你需要让多个微信账号分别对接不同的 Agent,\n可以进入这个子向导完成配置。\n\n适用场景:\n - 一个 Agent 负责课堂答疑(账号A)\n - 另一个 Agent 负责生活助手(账号B)\n\n普通接入不需要这一步,完成步骤 1-7 即可。",
113
+ "tip": "💡 按 Enter 进入子向导,按 q 随时返回此页面。"
114
+ }
115
+ }
116
+ ]
117
+ }
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * wizards/index.js
5
+ * ────────────────────────────────────────────────────────
6
+ * 所有向导的统一入口
7
+ *
8
+ * 新增向导只需:
9
+ * 1. 在 configs/ 下建目录,写 index.config.json
10
+ * 2. 在这里 `startWizard(path.join(...))` 注册一个入口函数
11
+ */
12
+
13
+ const path = require('path');
14
+ const { WizardRunner } = require('./runner/wizard-runner');
15
+ const { loadConfig } = require('./runner/config-loader');
16
+
17
+ function startWizard(configPath) {
18
+ const config = loadConfig(configPath);
19
+ new WizardRunner(config).run();
20
+ }
21
+
22
+ // ── 各向导入口 ────────────────────────────────────────────────
23
+
24
+ function runWeixin() {
25
+ startWizard(path.join(__dirname, 'configs/weixin/index.config.json'));
26
+ }
27
+
28
+ // 未来扩展示例:
29
+ // function runGateway() { startWizard(path.join(__dirname, 'configs/gateway/index.config.json')); }
30
+ // function runAgent() { startWizard(path.join(__dirname, 'configs/agent/index.config.json')); }
31
+
32
+ module.exports = { runWeixin };
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * config-loader.js
5
+ * ──────────────────────────────────────────────────────────
6
+ * Wizard config 加载器
7
+ *
8
+ * 功能:
9
+ * 1. 加载 JSON config 文件并注入 _dir(用于解析相对路径)
10
+ * 2. 展开 steps 中的 $ref 引用(指向公共步骤片段)
11
+ *
12
+ * $ref 格式:
13
+ * { "$ref": "../../commons/gateway.steps.json#restart" }
14
+ * ↑ 文件路径(相对于当前 config 所在目录) ↑ 步骤 key
15
+ *
16
+ * 如果不加 #key,则将整个文件的值视为步骤(需是数组或对象)。
17
+ *
18
+ * 公共步骤文件格式(commons/xxx.steps.json):
19
+ * {
20
+ * "restart": { ...step对象... },
21
+ * "check-health": { ...step对象... }
22
+ * }
23
+ */
24
+
25
+ const path = require('path');
26
+
27
+ /**
28
+ * 加载并解析 wizard config
29
+ * @param {string} configPath - 绝对路径
30
+ * @returns {Object} 解析后的 config(steps 已展开 $ref)
31
+ */
32
+ function loadConfig(configPath) {
33
+ // delete require cache 确保每次都重新读取(方便开发调试)
34
+ delete require.cache[require.resolve(configPath)];
35
+
36
+ const raw = require(configPath);
37
+ const baseDir = path.dirname(configPath);
38
+
39
+ const steps = _expandSteps(raw.steps || [], baseDir);
40
+
41
+ return { ...raw, steps, _dir: baseDir };
42
+ }
43
+
44
+ /**
45
+ * 展开 steps 数组中的 $ref 项
46
+ */
47
+ function _expandSteps(steps, baseDir) {
48
+ const result = [];
49
+
50
+ for (const step of steps) {
51
+ if (step.$ref) {
52
+ const expanded = _resolveRef(step.$ref, baseDir);
53
+ if (Array.isArray(expanded)) {
54
+ result.push(...expanded);
55
+ } else {
56
+ result.push(expanded);
57
+ }
58
+ } else {
59
+ result.push(step);
60
+ }
61
+ }
62
+
63
+ return result;
64
+ }
65
+
66
+ /**
67
+ * 解析单个 $ref 字符串
68
+ * @param {string} ref - "path/to/file.json#key"
69
+ * @param {string} baseDir - 当前 config 所在目录
70
+ */
71
+ function _resolveRef(ref, baseDir) {
72
+ const [filePart, key] = ref.split('#');
73
+ const absPath = path.resolve(baseDir, filePart);
74
+
75
+ // 每次都清缓存,保持公共步骤文件可热更新
76
+ delete require.cache[require.resolve(absPath)];
77
+ const file = require(absPath);
78
+
79
+ if (key) {
80
+ if (!file[key]) {
81
+ throw new Error(`[config-loader] $ref "${ref}" 中找不到 key "${key}"`);
82
+ }
83
+ return file[key];
84
+ }
85
+
86
+ return file;
87
+ }
88
+
89
+ module.exports = { loadConfig };
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * input-prompt.js
5
+ * ─────────────────────────────────────────────────────
6
+ * 通用「参数填写」交互模块
7
+ *
8
+ * 用于 input 类型的步骤:执行命令前需要用户填写动态参数。
9
+ * 暂时退出 raw mode → 用 readline 逐个收集参数 → 恢复 raw mode。
10
+ *
11
+ * 可复用于任何需要在 TUI 会话中收集用户输入的场景。
12
+ */
13
+
14
+ const readline = require('readline');
15
+ const chalk = require('chalk');
16
+ const { hr, W } = require('./render');
17
+
18
+ /**
19
+ * 收集一组用户输入参数
20
+ *
21
+ * @param {Array<InputDef>} inputs
22
+ * InputDef: { key, label, hint?, placeholder? }
23
+ * @returns {Promise<Object>} - { [key]: value }
24
+ */
25
+ async function collectInputs(inputs) {
26
+ // 退出 raw mode,使 readline 能正常接收输入
27
+ process.stdin.setRawMode(false);
28
+
29
+ const values = {};
30
+
31
+ for (const input of inputs) {
32
+ values[input.key] = await _ask(input);
33
+ }
34
+
35
+ // 恢复 raw mode
36
+ // readline.close() 内部会 pause stdin,必须显式 resume 否则后续监听失效
37
+ process.stdin.setRawMode(true);
38
+ process.stdin.resume();
39
+
40
+ return values;
41
+ }
42
+
43
+ /**
44
+ * 显示单个参数的输入提示并等待用户输入
45
+ */
46
+ function _ask(input) {
47
+ return new Promise(resolve => {
48
+ const rl = readline.createInterface({
49
+ input: process.stdin,
50
+ output: process.stdout,
51
+ terminal: true,
52
+ });
53
+
54
+ // Ctrl+C 优雅处理:恢复 raw mode 后退出
55
+ rl.on('SIGINT', () => {
56
+ rl.close();
57
+ // 恢复 raw mode,然后退出程序
58
+ try { process.stdin.setRawMode(true); } catch (_) {}
59
+ console.log('');
60
+ console.log(' ' + chalk.cyan('已取消。再见!👋'));
61
+ console.log('');
62
+ process.exit(0);
63
+ });
64
+
65
+ // 显示 hint
66
+ if (input.hint) {
67
+ console.log(' ' + chalk.dim('💡 ' + input.hint));
68
+ }
69
+ if (input.placeholder) {
70
+ console.log(' ' + chalk.dim(' 例如:' + input.placeholder));
71
+ }
72
+
73
+ rl.question(
74
+ ' ' + chalk.bold.white(input.label) + chalk.dim(':'),
75
+ answer => {
76
+ rl.close();
77
+ resolve(answer.trim() || '');
78
+ }
79
+ );
80
+ });
81
+ }
82
+
83
+ /**
84
+ * 用参数值替换命令模板中的 {{key}} 占位符
85
+ *
86
+ * @param {string} template - 含 {{key}} 的模板字符串
87
+ * @param {Object} values - { key: value }
88
+ * @returns {string}
89
+ */
90
+ function fillTemplate(template, values) {
91
+ return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
92
+ return values[key] !== undefined ? values[key] : `<${key}>`;
93
+ });
94
+ }
95
+
96
+ module.exports = { collectInputs, fillTemplate };
@@ -0,0 +1,87 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * render.js
5
+ * ─────────────────────────────────────────
6
+ * 通用渲染工具
7
+ *
8
+ * W 改为动态读取终端宽度(getW),每次 render 时实时计算,
9
+ * 支持终端窗口 resize 后自动适配。
10
+ */
11
+
12
+ const chalk = require('chalk');
13
+
14
+ /**
15
+ * 获取当前终端宽度(限制在 50~80 之间)
16
+ */
17
+ function getW() {
18
+ const cols = process.stdout.columns;
19
+ if (!cols || cols < 50) return 66; // 无法获取或过窄时使用默认值
20
+ return Math.min(cols - 2, 80);
21
+ }
22
+
23
+ function hr(char = '─', w) {
24
+ return char.repeat(w || getW());
25
+ }
26
+
27
+ function cls() {
28
+ process.stdout.write('\x1b[2J\x1b[H');
29
+ }
30
+
31
+ /**
32
+ * 多行文本缩进
33
+ */
34
+ function indent(text, prefix = ' ') {
35
+ return text.split('\n').map(l => prefix + l).join('\n');
36
+ }
37
+
38
+ /**
39
+ * 步骤状态标签
40
+ */
41
+ const BADGE = {
42
+ check: () => chalk.bgCyan.black(' 查看 '),
43
+ pending: () => chalk.bgGray.white(' 未执行 '),
44
+ done: () => chalk.bgGreen.black(' ✓ 完成 '),
45
+ error: () => chalk.bgRed.white(' ✗ 失败 '),
46
+ submenu: () => chalk.bgMagenta.white(' ▸ 子向导 '),
47
+ input: () => chalk.bgYellow.black(' 填参数 '),
48
+ };
49
+
50
+ /**
51
+ * 渲染命令行(自动折行)
52
+ */
53
+ function renderCommand(display) {
54
+ if (!display) return;
55
+ const W = getW();
56
+ const maxLen = W - 10;
57
+
58
+ if (display.length <= maxLen) {
59
+ console.log(' ' + chalk.bgBlack.cyan(' $ ') + chalk.bgBlack.white(' ' + display + ' '));
60
+ return;
61
+ }
62
+
63
+ // 按空格拆词后贪心折行
64
+ const words = display.split(' ');
65
+ const lines = [];
66
+ let cur = '';
67
+
68
+ for (const word of words) {
69
+ const next = cur ? cur + ' ' + word : word;
70
+ if (next.length > maxLen && cur) {
71
+ lines.push(cur);
72
+ cur = word;
73
+ } else {
74
+ cur = next;
75
+ }
76
+ }
77
+ if (cur) lines.push(cur);
78
+
79
+ lines.forEach((line, i) => {
80
+ const prefix = i === 0
81
+ ? chalk.bgBlack.cyan(' $ ')
82
+ : chalk.bgBlack.dim(' ');
83
+ console.log(' ' + prefix + chalk.bgBlack.white(' ' + line + ' '));
84
+ });
85
+ }
86
+
87
+ module.exports = { getW, hr, cls, indent, BADGE, renderCommand };
@@ -0,0 +1,541 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * wizard-runner.js
5
+ * ──────────────────────────────────────────────────────────────
6
+ * 通用 TUI 向导引擎
7
+ *
8
+ * ── 支持的 step 类型 ──────────────────────────────────────────
9
+ * check 只读查询(execSync,可反复运行)
10
+ * action 有副作用的操作(execSync)
11
+ * interactive 需要终端交互(spawnSync + inherit stdio,如扫码)
12
+ * input 执行前需填写动态参数(collectInputs + fillTemplate)
13
+ * submenu 进入子向导(栈式导航,q 键返回上一级)
14
+ *
15
+ * ── step 扩展字段 ────────────────────────────────────────────
16
+ * requires: ["step-id"] 执行前检查前置步骤是否已完成
17
+ * capture: "var_name" 执行后把输出存入 context.captures
18
+ * show_capture: "var_name" input 步骤填参前展示指定 capture 作为参考
19
+ *
20
+ * ── 交互快捷键 ───────────────────────────────────────────────
21
+ * ↑ ↓ 上下移动光标
22
+ * 1-9 数字快速跳转到对应步骤
23
+ * Enter 执行当前步骤
24
+ * q / Ctrl+C 退出(子向导返回上一级)
25
+ *
26
+ * ── 共享 Context ─────────────────────────────────────────────
27
+ * 父子向导共用同一个 context 对象:
28
+ * context.captures: { [var_name]: string } 输出捕获
29
+ *
30
+ * ── 栈式导航 ─────────────────────────────────────────────────
31
+ * 顶级向导:q → 退出程序
32
+ * 子向导:opts.onExit 回调父级,键盘控制权归还父级
33
+ */
34
+
35
+ const path = require('path');
36
+ const chalk = require('chalk');
37
+ const { execSync, spawnSync } = require('child_process');
38
+
39
+ const { getW, hr, cls, indent, BADGE, renderCommand } = require('./render');
40
+ const { collectInputs, fillTemplate } = require('./input-prompt');
41
+ const { loadConfig } = require('./config-loader');
42
+
43
+ // ─────────────────────────────────────────────────────────────
44
+
45
+ class WizardRunner {
46
+ /**
47
+ * @param {Object} config - wizard config(含 steps[],已由 config-loader 处理)
48
+ * @param {Object} [opts]
49
+ * @param {Function} [opts.onExit] - 有值 = 子向导模式,退出时回调父级
50
+ * @param {Object} [opts.context] - 共享 context(父子向导共用)
51
+ */
52
+ constructor(config, opts = {}) {
53
+ this.config = config;
54
+ this.steps = config.steps;
55
+ this.cursor = 0;
56
+ this._busy = false;
57
+ this.onExit = opts.onExit || null;
58
+ this._handler = null;
59
+ this._resizeHandler = null;
60
+
61
+ // 共享 context(父子向导共用同一对象)
62
+ this.context = opts.context || { captures: {} };
63
+
64
+ // 初始化每步状态
65
+ this.statuses = {};
66
+ this.steps.forEach(s => {
67
+ if (s.type === 'check') this.statuses[s.id] = 'check';
68
+ else if (s.type === 'submenu') this.statuses[s.id] = 'submenu';
69
+ else this.statuses[s.id] = 'pending';
70
+ });
71
+ }
72
+
73
+ // ── 渲染:主界面 ────────────────────────────────────────────
74
+
75
+ render() {
76
+ cls();
77
+ const W = getW();
78
+ const { config, steps, cursor, statuses } = this;
79
+ const isChild = !!this.onExit;
80
+
81
+ console.log('');
82
+
83
+ if (isChild) {
84
+ console.log(
85
+ ' ' + chalk.dim('← q 返回') + ' ' +
86
+ chalk.bold.cyan('⚡ ' + config.title)
87
+ );
88
+ } else {
89
+ console.log(' ' + chalk.bold.cyan('⚡ ' + config.title));
90
+ }
91
+ console.log(' ' + chalk.gray(config.subtitle || ''));
92
+ console.log('');
93
+
94
+ // 步骤列表
95
+ const navHint = isChild
96
+ ? chalk.dim(' ( ↑↓/数字 移动 Enter 执行 q 返回 )')
97
+ : chalk.dim(' ( ↑↓/数字 移动 Enter 执行 q 退出 )');
98
+
99
+ console.log(' ' + chalk.bold('步骤列表') + navHint);
100
+ console.log(' ' + chalk.gray(hr('─', W - 4)));
101
+
102
+ steps.forEach((step, i) => {
103
+ const sel = i === cursor;
104
+ const arrow = sel ? chalk.cyan.bold('▶') : ' ';
105
+ const num = chalk.dim(String(i + 1).padStart(2) + '.');
106
+ const label = sel ? chalk.bold.white(step.label) : chalk.white(step.label);
107
+ const badge = (BADGE[statuses[step.id]] || BADGE.pending)();
108
+
109
+ // requires 未满足时显示锁定标记
110
+ const locked = this._isLocked(step);
111
+ const lockMark = locked ? chalk.dim(' 🔒') : '';
112
+
113
+ console.log(` ${arrow} ${num} ${step.icon} ${label}${lockMark} ${badge}`);
114
+ });
115
+
116
+ console.log(' ' + chalk.gray(hr('─', W - 4)));
117
+ console.log('');
118
+
119
+ // 说明区
120
+ const step = steps[cursor];
121
+ const teach = step.teach;
122
+
123
+ console.log(' ' + chalk.bold.yellow('📖 步骤说明'));
124
+ console.log('');
125
+ console.log(' ' + chalk.bold.white('【' + teach.title + '】'));
126
+ console.log('');
127
+ console.log(indent(teach.description, ' '));
128
+ console.log('');
129
+
130
+ // 命令预览 or 子向导提示
131
+ if (step.type === 'submenu') {
132
+ console.log(' ' + chalk.magenta('▸ 按 Enter 进入子向导'));
133
+ } else if (step.type === 'input') {
134
+ console.log(' ' + chalk.dim('命令模板(参数待填写):'));
135
+ renderCommand(step.command.template);
136
+ } else if (step.command) {
137
+ console.log(' ' + chalk.dim('执行命令:'));
138
+ renderCommand(step.command.display || step.command.exec);
139
+ }
140
+ console.log('');
141
+
142
+ if (teach.look_for) {
143
+ console.log(' ' + chalk.dim('👀 执行后观察:') + chalk.yellow(teach.look_for));
144
+ console.log('');
145
+ }
146
+ if (teach.tip) {
147
+ console.log(' ' + chalk.dim('💡 ') + chalk.gray(teach.tip));
148
+ console.log('');
149
+ }
150
+
151
+ // requires 提示(主界面也显示)
152
+ if (this._isLocked(step)) {
153
+ const missing = this._getMissingRequires(step);
154
+ console.log(' ' + chalk.yellow('🔒 建议先完成:'));
155
+ missing.forEach(id => {
156
+ const idx = this.steps.findIndex(s => s.id === id);
157
+ const s = this.steps[idx];
158
+ console.log(' ' + chalk.dim(' → 步骤 ' + (idx + 1) + ':' + (s ? s.label : id)));
159
+ });
160
+ console.log('');
161
+ }
162
+ }
163
+
164
+ // ── 执行步骤(分发) ─────────────────────────────────────────
165
+
166
+ async executeStep(idx) {
167
+ const step = this.steps[idx];
168
+
169
+ // requires 前置检查
170
+ if (this._isLocked(step)) {
171
+ const ok = await this._showRequiresWarning(step);
172
+ if (!ok) { this.render(); return; }
173
+ }
174
+
175
+ switch (step.type) {
176
+ case 'submenu': return this._openSubmenu(step);
177
+ case 'input': return this._executeInputStep(step);
178
+ default: return this._executeCommandStep(step);
179
+ }
180
+ }
181
+
182
+ // ── requires 相关 ────────────────────────────────────────────
183
+
184
+ _isLocked(step) {
185
+ return this._getMissingRequires(step).length > 0;
186
+ }
187
+
188
+ _getMissingRequires(step) {
189
+ if (!step.requires || !step.requires.length) return [];
190
+ return step.requires.filter(id => this.statuses[id] !== 'done');
191
+ }
192
+
193
+ async _showRequiresWarning(step) {
194
+ const W = getW();
195
+ const missing = this._getMissingRequires(step);
196
+
197
+ cls();
198
+ console.log('');
199
+ console.log(' ' + chalk.bold.yellow('🔒 前置步骤未完成'));
200
+ console.log(' ' + chalk.gray(hr('─', W - 4)));
201
+ console.log('');
202
+ console.log(' 建议在执行「' + chalk.bold(step.label) + '」之前先完成:');
203
+ console.log('');
204
+
205
+ missing.forEach(id => {
206
+ const idx = this.steps.findIndex(s => s.id === id);
207
+ const s = this.steps[idx];
208
+ console.log(' ' + chalk.yellow(' → 步骤 ' + (idx + 1) + ':' + (s ? s.label : id)));
209
+ });
210
+
211
+ console.log('');
212
+ console.log(' ' + chalk.gray(hr('─', W - 4)));
213
+ console.log(
214
+ ' ' + chalk.dim('按 Enter 仍然继续') +
215
+ chalk.dim(' | 按 q 返回先完成前置步骤')
216
+ );
217
+
218
+ return this._waitConfirm();
219
+ }
220
+
221
+ // ── submenu:进入子向导 ──────────────────────────────────────
222
+
223
+ async _openSubmenu(step) {
224
+ const baseDir = this.config._dir || __dirname;
225
+ const configPath = path.resolve(baseDir, step.submenu);
226
+ const subConfig = loadConfig(configPath);
227
+
228
+ this._removeKeyboard();
229
+
230
+ const sub = new WizardRunner(subConfig, {
231
+ context: this.context, // 共享同一个 context
232
+ onExit: () => {
233
+ this._setupKeyboard();
234
+ this.render();
235
+ },
236
+ });
237
+
238
+ sub.run();
239
+ }
240
+
241
+ // ── 普通命令步骤 ─────────────────────────────────────────────
242
+
243
+ async _executeCommandStep(step) {
244
+ const W = getW();
245
+ const teach = step.teach;
246
+
247
+ cls();
248
+ console.log('');
249
+ console.log(' ' + chalk.bold.cyan('▶ 执行:' + step.icon + ' ' + step.label));
250
+ console.log(' ' + chalk.gray(hr('─', W - 4)));
251
+ console.log('');
252
+ console.log(' ' + chalk.bold.yellow('📋 执行前说明'));
253
+ console.log('');
254
+ console.log(indent(teach.description, ' '));
255
+ console.log('');
256
+ console.log(' ' + chalk.bold('将要执行的命令:'));
257
+ console.log('');
258
+ renderCommand(step.command.display || step.command.exec);
259
+ console.log('');
260
+
261
+ if (step.type === 'interactive') {
262
+ console.log(' ' + chalk.yellow('⚠️ 此步骤是交互式操作(如显示二维码),将接管终端'));
263
+ console.log('');
264
+ }
265
+
266
+ // capture 提示
267
+ if (step.capture) {
268
+ console.log(' ' + chalk.cyan('📥 执行结果将被记录,供后续步骤参考使用'));
269
+ console.log('');
270
+ }
271
+
272
+ if (teach.tip) {
273
+ console.log(' ' + chalk.dim('💡 ') + chalk.gray(teach.tip));
274
+ console.log('');
275
+ }
276
+
277
+ console.log(' ' + chalk.gray(hr('─', W - 4)));
278
+ console.log(' ' + chalk.dim('按 Enter 开始执行 | 按 q 取消'));
279
+
280
+ const ok = await this._waitConfirm();
281
+ if (!ok) { this.render(); return; }
282
+
283
+ await this._runCommand(step, step.command.exec || step.command.display);
284
+ }
285
+
286
+ // ── input 步骤:填参数后执行 ─────────────────────────────────
287
+
288
+ async _executeInputStep(step) {
289
+ const W = getW();
290
+ const teach = step.teach;
291
+
292
+ // 第一页:说明 + 模板
293
+ cls();
294
+ console.log('');
295
+ console.log(' ' + chalk.bold.cyan('▶ 执行:' + step.icon + ' ' + step.label));
296
+ console.log(' ' + chalk.gray(hr('─', W - 4)));
297
+ console.log('');
298
+ console.log(' ' + chalk.bold.yellow('📋 执行前说明'));
299
+ console.log('');
300
+ console.log(indent(teach.description, ' '));
301
+ console.log('');
302
+ console.log(' ' + chalk.bold('命令模板(需要填写参数):'));
303
+ console.log('');
304
+ renderCommand(step.command.template);
305
+ console.log('');
306
+
307
+ if (teach.tip) {
308
+ console.log(' ' + chalk.dim('💡 ') + chalk.gray(teach.tip));
309
+ console.log('');
310
+ }
311
+
312
+ console.log(' ' + chalk.gray(hr('─', W - 4)));
313
+ console.log(' ' + chalk.dim('按 Enter 开始填写参数 | 按 q 取消'));
314
+
315
+ const ok = await this._waitConfirm();
316
+ if (!ok) { this.render(); return; }
317
+
318
+ // 第二页:参考信息 + 填写参数
319
+ cls();
320
+ console.log('');
321
+ console.log(' ' + chalk.bold.cyan('📝 填写参数:' + step.label));
322
+ console.log(' ' + chalk.gray(hr('─', W - 4)));
323
+
324
+ // 如果有 show_capture,展示参考信息(教学重点)
325
+ if (step.show_capture) {
326
+ const captured = this.context.captures[step.show_capture];
327
+ if (captured && captured.trim()) {
328
+ console.log('');
329
+ console.log(' ' + chalk.bold.yellow('📋 参考信息(来自上一步执行结果):'));
330
+ console.log(' ' + chalk.gray(hr('─', W - 4)));
331
+ // 显示捕获的输出(限制 30 行,避免超出屏幕)
332
+ const lines = captured.split('\n').filter(l => l.trim());
333
+ const shown = lines.slice(0, 30);
334
+ shown.forEach(l => console.log(' ' + chalk.dim(l)));
335
+ if (lines.length > 30) {
336
+ console.log(' ' + chalk.dim('... 输出已截断,共 ' + lines.length + ' 行'));
337
+ }
338
+ console.log(' ' + chalk.gray(hr('─', W - 4)));
339
+ } else {
340
+ console.log('');
341
+ console.log(' ' + chalk.yellow('⚠️ 暂无参考信息(建议先执行对应的查看步骤)'));
342
+ }
343
+ }
344
+
345
+ console.log('');
346
+
347
+ const values = await collectInputs(step.inputs);
348
+ const actualCmd = fillTemplate(step.command.template, values);
349
+
350
+ // 第三页:确认最终命令
351
+ cls();
352
+ console.log('');
353
+ console.log(' ' + chalk.bold.cyan('✅ 确认执行:' + step.icon + ' ' + step.label));
354
+ console.log(' ' + chalk.gray(hr('─', W - 4)));
355
+ console.log('');
356
+ console.log(' ' + chalk.bold('已填入参数,将要执行:'));
357
+ console.log('');
358
+ renderCommand(actualCmd);
359
+ console.log('');
360
+ console.log(' ' + chalk.gray(hr('─', W - 4)));
361
+ console.log(
362
+ ' ' + chalk.dim('按 ') + chalk.bold.green('y') +
363
+ chalk.dim(' 确认执行 | 按其他键取消(防止误触)')
364
+ );
365
+
366
+ const finalOk = await this._waitYesConfirm();
367
+ if (!finalOk) { this.render(); return; }
368
+
369
+ await this._runCommand({ ...step, type: 'action' }, actualCmd);
370
+ }
371
+
372
+ // ── 实际运行命令 ─────────────────────────────────────────────
373
+
374
+ async _runCommand(step, cmdStr) {
375
+ const W = getW();
376
+ cls();
377
+ console.log('');
378
+ console.log(' ' + chalk.bold.cyan('⚡ 正在执行:' + step.label));
379
+ console.log(' ' + chalk.dim('$ ' + cmdStr));
380
+ console.log(' ' + chalk.gray(hr('─', W - 4)));
381
+ console.log('');
382
+ console.log(' ' + chalk.dim('↓ 命令输出 ↓'));
383
+ console.log('');
384
+
385
+ process.stdin.setRawMode(false);
386
+
387
+ let success = false;
388
+
389
+ try {
390
+ if (step.capture) {
391
+ // 捕获 stdout,同时透传 stdin/stderr
392
+ const result = spawnSync(cmdStr, {
393
+ shell: true,
394
+ stdio: ['inherit', 'pipe', 'inherit'],
395
+ encoding: 'utf8',
396
+ });
397
+ const output = result.stdout || '';
398
+ process.stdout.write(output); // 显示给学生看
399
+ this.context.captures[step.capture] = output; // 同时存起来
400
+ success = result.status === 0;
401
+ } else if (step.type === 'interactive') {
402
+ const r = spawnSync(cmdStr, { shell: true, stdio: 'inherit' });
403
+ success = r.status === 0;
404
+ } else {
405
+ execSync(cmdStr, { shell: true, stdio: 'inherit' });
406
+ success = true;
407
+ }
408
+ } catch { success = false; }
409
+
410
+ process.stdin.setRawMode(true);
411
+ process.stdin.resume(); // 命令执行完毕后确保 stdin 处于 flowing 状态
412
+ this.statuses[step.id] = success ? 'done' : 'error';
413
+
414
+
415
+ // ── 成功后光标自动推进到下一步 ──
416
+ if (success && this.cursor < this.steps.length - 1) {
417
+ this.cursor++;
418
+ }
419
+
420
+ console.log('');
421
+ console.log(' ' + chalk.gray(hr('─', W - 4)));
422
+
423
+ if (success) {
424
+ console.log(' ' + chalk.bold.green('✓ 执行完成!'));
425
+ if (step.teach?.look_for) {
426
+ console.log(' ' + chalk.yellow('👆 观察上方输出:' + step.teach.look_for));
427
+ }
428
+ if (step.capture) {
429
+ console.log(' ' + chalk.cyan('📥 输出已记录,后续步骤可作为参考'));
430
+ }
431
+ if (this.cursor > 0) {
432
+ const next = this.steps[this.cursor];
433
+ console.log(' ' + chalk.dim('→ 下一步:' + next.icon + ' ' + next.label));
434
+ }
435
+ } else {
436
+ console.log(' ' + chalk.bold.red('✗ 执行遇到问题,请查看上方错误信息'));
437
+ }
438
+
439
+ console.log('');
440
+ console.log(' ' + chalk.dim('按任意键返回步骤列表...'));
441
+
442
+ await this._waitAnyKey();
443
+ this.render();
444
+ }
445
+
446
+ // ── 键盘管理 ─────────────────────────────────────────────────
447
+
448
+ _onData(buf) {
449
+ if (this._busy) return;
450
+ const k = buf.toString();
451
+
452
+ // 数字快速跳转(1-9)
453
+ const num = parseInt(k, 10);
454
+ if (!isNaN(num) && num >= 1 && num <= this.steps.length) {
455
+ this.cursor = num - 1;
456
+ this.render();
457
+ return;
458
+ }
459
+
460
+ if (k === '\x1b[A') { this.cursor = Math.max(0, this.cursor - 1); this.render(); }
461
+ else if (k === '\x1b[B') { this.cursor = Math.min(this.steps.length - 1, this.cursor + 1); this.render(); }
462
+ else if (k === '\r' || k === '\n') { this._busy = true; this.executeStep(this.cursor).finally(() => { this._busy = false; }); }
463
+ else if (k === 'q' || k === 'Q' || k === '\x03') { this.exit(); }
464
+ }
465
+
466
+ _setupKeyboard() {
467
+ this._handler = buf => this._onData(buf);
468
+ process.stdin.on('data', this._handler);
469
+ }
470
+
471
+ _removeKeyboard() {
472
+ if (this._handler) {
473
+ process.stdin.removeListener('data', this._handler);
474
+ this._handler = null;
475
+ }
476
+ }
477
+
478
+ // Enter/空格/任意非 q 键 → 确认
479
+ _waitConfirm() {
480
+ return new Promise(resolve => {
481
+ process.stdin.once('data', buf => {
482
+ const k = buf.toString();
483
+ resolve(k !== 'q' && k !== 'Q' && k !== '\x03');
484
+ });
485
+ });
486
+ }
487
+
488
+ // 严格确认:只接受 y/Y 键(用于 input 步骤的最终执行确认)
489
+ // 规避 readline 关闭后残留 Enter 误触发的问题
490
+ _waitYesConfirm() {
491
+ return new Promise(resolve => {
492
+ const handler = (buf) => {
493
+ const k = buf.toString().toLowerCase();
494
+ // 过滤掉残留的 Enter/换行(来自 readline)
495
+ if (k === '\r' || k === '\n' || k === '\r\n') return;
496
+ process.stdin.removeListener('data', handler);
497
+ resolve(k === 'y');
498
+ };
499
+ process.stdin.on('data', handler);
500
+ });
501
+ }
502
+
503
+ _waitAnyKey() {
504
+ return new Promise(resolve => process.stdin.once('data', resolve));
505
+ }
506
+
507
+ // ── 入口 & 退出 ──────────────────────────────────────────────
508
+
509
+ run() {
510
+ process.stdin.setRawMode(true);
511
+ process.stdin.resume();
512
+ this._setupKeyboard();
513
+
514
+ // 终端 resize 时自动重绘
515
+ this._resizeHandler = () => this.render();
516
+ process.stdout.on('resize', this._resizeHandler);
517
+
518
+ this.render();
519
+ }
520
+
521
+ exit() {
522
+ this._removeKeyboard();
523
+
524
+ // 清理 resize 监听
525
+ if (this._resizeHandler) {
526
+ process.stdout.removeListener('resize', this._resizeHandler);
527
+ this._resizeHandler = null;
528
+ }
529
+
530
+ if (this.onExit) {
531
+ this.onExit();
532
+ } else {
533
+ process.stdin.setRawMode(false);
534
+ process.stdin.pause();
535
+ console.log('\n ' + chalk.cyan('再见!👋') + '\n');
536
+ process.exit(0);
537
+ }
538
+ }
539
+ }
540
+
541
+ module.exports = { WizardRunner };