botmux 2.26.0 → 2.27.1

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/cli.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * botmux autostart enable|disable|status — manage boot-time autostart (launchd / user systemd)
18
18
  */
19
19
  import { execSync, spawnSync, spawn } from 'node:child_process';
20
- import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, readdirSync, readlinkSync, appendFileSync, statSync, unlinkSync } from 'node:fs';
20
+ import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync, renameSync, readdirSync, readlinkSync, appendFileSync, statSync, unlinkSync } from 'node:fs';
21
21
  import { join, dirname } from 'node:path';
22
22
  import { homedir } from 'node:os';
23
23
  import { fileURLToPath } from 'node:url';
@@ -26,6 +26,7 @@ import { createRequire } from 'node:module';
26
26
  import { createHmac, randomBytes } from 'node:crypto';
27
27
  import { enableAutostart, disableAutostart, autostartStatus, refreshAutostart } from './autostart.js';
28
28
  import { tmuxEnv } from './setup/ensure-tmux.js';
29
+ import { writeBotsJsonAtomic as writeBotsAtomic } from './setup/bots-store.js';
29
30
  import { logger } from './utils/logger.js';
30
31
  import { firstPositional } from './cli/arg-utils.js';
31
32
  // CLI subcommands (send/thread/bots/list/etc) print JSON to stdout for
@@ -144,28 +145,209 @@ function ask(rl, question) {
144
145
  return new Promise(resolve => rl.question(question, resolve));
145
146
  }
146
147
  // ─── Setup helpers ──────────────────────────────────────────────────────────
147
- function printLarkPermissions() {
148
- console.log('请先在飞书开放平台创建应用: https://open.feishu.cn/app\n');
149
- console.log('需要的权限:');
150
- console.log(' - im:message (发送/接收消息)');
151
- console.log(' - im:message.group_at_msg (群消息)');
152
- console.log(' - im:resource (文件下载)');
153
- console.log(' - im:chat (群信息)');
154
- console.log(' - contact:user.base:readonly (用户信息)\n');
155
- console.log('启用事件订阅 (WebSocket 模式):');
156
- console.log(' - im.message.receive_v1');
157
- console.log(' - card.action.trigger\n');
148
+ // Thin wrapper around setup/bots-store.writeBotsJsonAtomic so call-sites keep
149
+ // the same name without passing BOTS_JSON_FILE explicitly each time.
150
+ function writeBotsJsonAtomic(bots) {
151
+ writeBotsAtomic(BOTS_JSON_FILE, bots);
158
152
  }
153
+ /**
154
+ * 从 bot 配置里取 brand. 旧的 bots.json (1.0 之前) 没这个字段, default 到 feishu
155
+ * 保留向后兼容. cmdStart 凭证校验 + printRemainingSteps 深链都靠它选 host.
156
+ */
157
+ function botBrand(b) {
158
+ return b?.brand === 'lark' ? 'lark' : 'feishu';
159
+ }
160
+ /**
161
+ * 把 botmux 推荐的完整 scope JSON (从 src/setup/lark-scopes.json) 写到
162
+ * 用户配置目录, 同时给出跨平台一键复制命令. JSON 长 (293 项, 297 行),
163
+ * terminal 直接打印用户也复制不了, 写文件 + pbcopy/xclip 才是顺手的姿势.
164
+ *
165
+ * Returns: 写出的 JSON 文件绝对路径.
166
+ */
167
+ function writeScopesJsonToConfigDir() {
168
+ // build script 会把 src/setup/lark-scopes.json copy 到 dist/setup/.
169
+ // dist 模式下 __dirname 是 dist/, 找 ./setup/lark-scopes.json; dev (tsx)
170
+ // 模式找 src/setup/lark-scopes.json 在源码同目录也成立.
171
+ const here = dirname(fileURLToPath(import.meta.url));
172
+ const srcCandidates = [
173
+ join(here, 'setup', 'lark-scopes.json'),
174
+ join(here, '..', 'src', 'setup', 'lark-scopes.json'),
175
+ ];
176
+ let scopesPath = srcCandidates[0];
177
+ for (const p of srcCandidates) {
178
+ if (existsSync(p)) {
179
+ scopesPath = p;
180
+ break;
181
+ }
182
+ }
183
+ const destPath = join(CONFIG_DIR, 'lark-scopes.json');
184
+ copyFileSync(scopesPath, destPath);
185
+ return destPath;
186
+ }
187
+ function printCopyHint(filePath) {
188
+ // 环境感知: SSH/headless 没有 X server, xclip 一定报 "Can't open display".
189
+ // 这种场景下"剪贴板"在用户本地 (运行 SSH 客户端的那台机器), 远程机上能做的:
190
+ // - 直接 cat, 让用户在本地 terminal 鼠标选中 (SSH 选中即写本地剪贴板)
191
+ // - OSC 52: terminal app 代写本地剪贴板, iTerm2 / kitty / WezTerm /
192
+ // Alacritty / tmux 1.5+ 都支持, gnome-terminal / Terminal.app 不支持
193
+ // 检测 DISPLAY (X11) 或 WAYLAND_DISPLAY 都没有, 或 SSH_* 环境变量存在
194
+ // → 当作 SSH 场景, 不推荐 xclip / pbcopy.
195
+ const isSsh = !!(process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY);
196
+ const hasLocalGui = !!(process.env.DISPLAY || process.env.WAYLAND_DISPLAY) && !isSsh;
197
+ const isMacLocal = process.platform === 'darwin' && !isSsh;
198
+ console.log(' 把 JSON 内容拷到本地剪贴板, 然后到飞书"批量导入/导出权限"页粘贴:');
199
+ if (isMacLocal) {
200
+ console.log(` macOS 本地: cat ${filePath} | pbcopy`);
201
+ }
202
+ else if (hasLocalGui) {
203
+ console.log(` Linux 本地 (X 服务器): cat ${filePath} | xclip -selection clipboard`);
204
+ }
205
+ else {
206
+ // SSH / headless: 鼠标选中是最稳的, OSC 52 作为高级选项
207
+ console.log(` SSH 终端鼠标选中复制: cat ${filePath}`);
208
+ console.log(' (终端把选中的字符直接写到你本地剪贴板, 不依赖远端剪贴板工具)');
209
+ console.log(` 或 OSC 52 (兼容 iTerm2 / kitty / WezTerm / Alacritty / tmux 1.5+):`);
210
+ console.log(` base64 -w0 < ${filePath} | awk 'BEGIN{printf "\\033]52;c;"}{printf "%s",$0}END{printf "\\a"}'`);
211
+ }
212
+ console.log('');
213
+ }
214
+ function printRemainingSteps(appId, brand) {
215
+ // 数据源: 飞书内部 wiki UBOXwH01CixfxfkqxUpcKgvQnsg "[Botmux] 5分钟创建一个
216
+ // 真正好用的飞书助理" 的"权限申请"段, 加 botmux 维护者实测确认 PersonalAgent
217
+ // 应用扫码建出来时已经默认订阅 im.message.receive_v1 / card.action.trigger
218
+ // 事件并开通 bot 能力. 但 lark-channel-bridge README 当前仍要求用户手动补
219
+ // 事件订阅, 跟我们结论不一致 — 不排除飞书最近升级了 PersonalAgent 预配 (那个
220
+ // README 是旧版), 也不排除存在租户/版本差异让某些用户的 PersonalAgent 没预配.
221
+ //
222
+ // 折中: 主线流程只列"必须手动"的两步 (权限 + 重定向 URL), 末尾再给"如果
223
+ // bot 收不到消息" 的兜底 fallback 链接, 让用户能自查事件订阅 / bot 能力.
224
+ const host = brand === 'lark' ? 'open.larksuite.com' : 'open.feishu.cn';
225
+ const home = `https://${host}/app/${appId}`;
226
+ let scopesJsonPath = '';
227
+ try {
228
+ scopesJsonPath = writeScopesJsonToConfigDir();
229
+ }
230
+ catch (err) {
231
+ // 不应阻止 setup 完成, 只 WARN
232
+ console.log(`\n⚠️ 写权限 JSON 失败 (${err.message}), 请手动从仓库源码 src/setup/lark-scopes.json 拷.`);
233
+ }
234
+ console.log('\n⚠️ 扫码 / 粘贴只完成了"建应用 + 拿凭证". 还有这些步骤要在开放平台浏览器里点:\n');
235
+ console.log(' 1. 申请权限 (一次性导入完整 JSON 提交审批)');
236
+ console.log(` 深链: ${home}/auth → 进入「权限管理」→「批量导入/导出权限」→ 粘贴 → 提交`);
237
+ if (scopesJsonPath) {
238
+ console.log(` 权限 JSON: ${scopesJsonPath}`);
239
+ printCopyHint(scopesJsonPath);
240
+ }
241
+ console.log('');
242
+ console.log(' 2. 添加重定向 URL (用于 botmux 内 `/login` 拿用户 UAT 调云文档/日历等)');
243
+ console.log(` 深链: ${home}/safe → 进入「安全设置」→「重定向 URL」`);
244
+ console.log(' 填入: http://127.0.0.1:9768/callback');
245
+ console.log(' 不打算用 `/login` 跨用户调 API 的话, 这一步可以跳过.\n');
246
+ console.log(' 完成后 `botmux start` (或 `botmux restart`),启动检查不会卡住,');
247
+ console.log(' 缺权限只 WARN,去开放平台补齐后 daemon 自动恢复。\n');
248
+ // Fallback 自查清单 — 维护者实测 PersonalAgent 默认配好下面两项, 但飞书
249
+ // 没承诺过这是稳定行为. 收不到消息时让用户能自查.
250
+ console.log(' ─── 如果机器人配置好后收不到消息, 自查下面两点 ───');
251
+ console.log(' a. 事件订阅: PersonalAgent 默认订阅 im.message.receive_v1 + card.action.trigger,');
252
+ console.log(` 如缺失, 请到 ${home}/dev-config/event-sub 手动添加`);
253
+ console.log(' b. 机器人能力: PersonalAgent 默认已开通,');
254
+ console.log(` 如缺失, 请到 ${home}/feature/bot 启用 (应用功能 → 机器人)`);
255
+ console.log('');
256
+ }
257
+ /**
258
+ * 让用户选"扫码建应用"还是"手动粘 AppID/Secret".
259
+ *
260
+ * 默认走扫码: 调 SDK `registerApp` → 拿 client_id/client_secret. 失败 (用户拒绝/
261
+ * 超时/网络/取消) 一律降级到手动, 不阻塞流程.
262
+ *
263
+ * Codex review 边界:
264
+ * - secret 不进 argv / 日志 / 错误链 (registerApp 内部 safeMsg 已做; 手动模式下
265
+ * AppSecret 通过 rl.question 异步读取, 不会出现在 process.argv)
266
+ * - 任何失败都返回结构化对象, 不抛 (调用方根据 ok=false 回退)
267
+ */
268
+ async function obtainCredentials(rl) {
269
+ console.log('── 飞书应用建立 ──\n');
270
+ console.log('1) 扫码建应用(推荐,一步拿到 AppID/Secret,需要飞书 App 扫码)');
271
+ console.log('2) 手动粘 AppID/Secret(已经在开放平台创建好应用了)\n');
272
+ const choice = (await ask(rl, '选择 [1]: ')).trim();
273
+ if (choice !== '2') {
274
+ // 动态导入避免冷启动加载 SDK
275
+ const { tryRegisterApp } = await import('./setup/register-app.js');
276
+ const result = await tryRegisterApp();
277
+ if (result.ok) {
278
+ // Lark 国际版需要 daemon 链路全程走 larksuite.com 域 (Client domain /
279
+ // WSClient / event-dispatcher 的 fetch URL / scope 深链 host). 当前
280
+ // botmux runtime 这几处都硬编码 feishu.cn, 所以即使扫码成功了也无法
281
+ // 真正跑起来. 干净做法是 setup 阶段就拒绝, 让用户用 feishu 租户. 单
282
+ // 独 PR 完整接入 lark 后再去掉这个分支.
283
+ if (result.brand === 'lark') {
284
+ console.log(`\n❌ 检测到 Lark 国际版 (larksuite.com) 租户。`);
285
+ console.log(` botmux 当前 daemon 运行链路仅支持飞书 (feishu.cn) 租户,`);
286
+ console.log(` Lark 国际版完整接入会在单独 PR 跟进 (BotConfig / Client domain /`);
287
+ console.log(` WSClient / event-dispatcher 等需要一并支持).`);
288
+ console.log(` 请用飞书 (feishu.cn) 租户重试 setup。\n`);
289
+ return { ok: false, reason: 'lark_unsupported' };
290
+ }
291
+ console.log(`\n✅ 应用创建成功`);
292
+ console.log(` App ID: ${result.appId}`);
293
+ console.log(` 租户类型: ${result.brand}`);
294
+ return { ok: true, appId: result.appId, appSecret: result.appSecret, brand: result.brand };
295
+ }
296
+ console.log(`\n⚠️ 扫码失败 (${result.error}): ${result.message}`);
297
+ if (result.error === 'aborted') {
298
+ // 用户主动取消整个 setup, 不再问手动 fallback
299
+ return { ok: false, reason: 'cancelled' };
300
+ }
301
+ console.log(' 降级到手动输入 AppID/Secret。\n');
302
+ }
303
+ else {
304
+ console.log('\n请在浏览器打开 https://open.feishu.cn/app 创建应用,然后回来粘 ID/Secret。\n');
305
+ }
306
+ // 手动 fallback. 不再提问租户类型 — 当前 daemon runtime 只支持 feishu,
307
+ // 让用户选 lark 是误导. 等 lark 完整接入再加回来.
308
+ const appId = (await ask(rl, 'AppID (cli_xxx): ')).trim();
309
+ const appSecret = (await ask(rl, 'AppSecret: ')).trim();
310
+ if (!appId || !appSecret) {
311
+ console.log('\n❌ AppID/AppSecret 不能为空,setup 中止。');
312
+ return { ok: false, reason: 'cancelled' };
313
+ }
314
+ return { ok: true, appId, appSecret, brand: 'feishu' };
315
+ }
316
+ /**
317
+ * 收集一个机器人完整配置 (凭证 + CLI/工作目录/allowedUsers).
318
+ *
319
+ * 顺序: 拿凭证 → tenant_access_token 验证 → 通过才返回 bot 对象. 验证失败
320
+ * 直接返回 null, 调用方负责"不写 bots.json". Codex review 边界 #2.
321
+ */
159
322
  async function promptBotConfig(rl) {
160
- const appId = await ask(rl, 'LARK_APP_ID: ');
161
- const appSecret = await ask(rl, 'LARK_APP_SECRET: ');
162
- console.log('\n支持的 CLI: 1) claude-code 2) aiden 3) coco 4) codex 5) gemini 6) opencode');
323
+ const creds = await obtainCredentials(rl);
324
+ if (!creds.ok)
325
+ return null;
326
+ // 凭证立刻验证. 通不过不写 bots.json.
327
+ console.log('\n校验凭证(取 tenant_access_token)…');
328
+ const { validateCredentials } = await import('./setup/verify-permissions.js');
329
+ const v = await validateCredentials(creds.appId, creds.appSecret, creds.brand);
330
+ if (!v.ok) {
331
+ console.log(`\n❌ 凭证校验失败 (${v.error}): ${v.message}`);
332
+ console.log(' 不写 bots.json。请重新运行 botmux setup。');
333
+ return null;
334
+ }
335
+ console.log('✅ 凭证有效(tenant_access_token 已成功获取)\n');
336
+ console.log('支持的 CLI: 1) claude-code 2) aiden 3) coco 4) codex 5) gemini 6) opencode');
163
337
  const cliChoice = await ask(rl, 'CLI 适配器 [1]: ');
164
338
  const cliIdMap = { '1': 'claude-code', '2': 'aiden', '3': 'coco', '4': 'codex', '5': 'gemini', '6': 'opencode' };
165
339
  const cliId = cliIdMap[cliChoice] ?? (cliChoice || 'claude-code');
166
340
  const workingDir = await ask(rl, '默认工作目录 [~]: ');
167
341
  const allowedUsers = await ask(rl, '允许的用户 (邮箱或 open_id,逗号分隔,留空=不限制): ');
168
- const bot = { larkAppId: appId, larkAppSecret: appSecret, cliId };
342
+ // brand 必须持久化: cmdStart validate / event-dispatcher 走的 deep link
343
+ // 都看这个字段; 不写就只能硬编码 feishu, lark 租户用户会被打成凭证无效.
344
+ // 为了向后兼容 (旧 bots.json 没 brand 字段), reader 应当 default 到 'feishu'.
345
+ const bot = {
346
+ larkAppId: creds.appId,
347
+ larkAppSecret: creds.appSecret,
348
+ brand: creds.brand,
349
+ cliId,
350
+ };
169
351
  if (workingDir)
170
352
  bot.workingDir = workingDir;
171
353
  if (allowedUsers)
@@ -203,18 +385,25 @@ function parseDotEnvToBotConfig() {
203
385
  bot.projectScanDir = vars.PROJECT_SCAN_DIR;
204
386
  return bot;
205
387
  }
206
- /** Write single-bot config to bots.json (fresh install or reconfigure) */
388
+ /**
389
+ * 收集一个机器人配置并写盘 (单机器人 fresh install / 重新配置).
390
+ *
391
+ * 失败路径 (扫码取消 / 凭证校验不通过): 不创建任何配置文件, 不动旧 .env.
392
+ * Codex review 边界 #2: 中途失败一律不留半截 JSON.
393
+ */
207
394
  async function writeSingleBotConfig() {
208
- console.log('── 飞书应用配置 ──\n');
209
- printLarkPermissions();
210
395
  const rl = createInterface({ input: process.stdin, output: process.stdout });
211
396
  const bot = await promptBotConfig(rl);
212
397
  rl.close();
213
- writeFileSync(BOTS_JSON_FILE, JSON.stringify([bot], null, 2) + '\n');
398
+ if (!bot)
399
+ return false;
400
+ writeBotsJsonAtomic([bot]);
214
401
  console.log(`\n✅ 配置已写入: ${BOTS_JSON_FILE}`);
215
- console.log(`\n下一步:`);
216
- console.log(` 1. botmux start 启动 daemon(飞书后台配长连接前必须先启动)`);
402
+ printRemainingSteps(bot.larkAppId, botBrand(bot));
403
+ console.log(`下一步:`);
404
+ console.log(` 1. botmux start 启动 daemon`);
217
405
  console.log(` 2. botmux autostart enable 注册开机自启(推荐:${process.platform === 'darwin' ? 'mac launchd' : process.platform === 'linux' ? 'linux user systemd' : '当前平台暂不支持'},无需 sudo)`);
406
+ return true;
218
407
  }
219
408
  // ─── Commands ────────────────────────────────────────────────────────────────
220
409
  async function cmdSetup() {
@@ -235,26 +424,36 @@ async function cmdSetup() {
235
424
  const rl = createInterface({ input: process.stdin, output: process.stdout });
236
425
  const action = await ask(rl, '操作: 1) 添加新机器人 2) 重新配置 (1/2) [1]: ');
237
426
  if (action === '2') {
238
- renameSync(BOTS_JSON_FILE, BOTS_JSON_FILE + '.bak');
239
- console.log(`旧配置已备份: ${BOTS_JSON_FILE}.bak\n`);
240
427
  console.log('\n── 重新配置 ──\n');
241
- printLarkPermissions();
242
428
  const newBot = await promptBotConfig(rl);
243
429
  rl.close();
244
- writeFileSync(BOTS_JSON_FILE, JSON.stringify([newBot], null, 2) + '\n');
245
- console.log(`\n 配置已写入: ${BOTS_JSON_FILE}`);
246
- console.log(`\n下一步: botmux restart`);
430
+ if (!newBot) {
431
+ console.log('\n⚠️ setup 中止,旧配置保留不动。');
432
+ return;
433
+ }
434
+ // Codex review #1: 先 copyFileSync 备份, 再原子写新文件. 之前先 rename
435
+ // 旧文件再 write, 一旦 write 失败 (磁盘/权限/进程被 kill) 用户就丢了
436
+ // bots.json. copy 之后写失败旧文件原地不动, .bak 是无害的同名副本.
437
+ copyFileSync(BOTS_JSON_FILE, BOTS_JSON_FILE + '.bak');
438
+ console.log(`旧配置已备份: ${BOTS_JSON_FILE}.bak`);
439
+ writeBotsJsonAtomic([newBot]);
440
+ console.log(`✅ 配置已写入: ${BOTS_JSON_FILE}`);
441
+ printRemainingSteps(newBot.larkAppId, botBrand(newBot));
442
+ console.log(`下一步: botmux restart\n`);
247
443
  return;
248
444
  }
249
445
  console.log('\n── 添加新机器人 ──\n');
250
- printLarkPermissions();
251
446
  const newBot = await promptBotConfig(rl);
252
447
  rl.close();
253
- bots.push(newBot);
254
- writeFileSync(BOTS_JSON_FILE, JSON.stringify(bots, null, 2) + '\n');
255
- console.log(`\n✅ 已添加机器人 ${newBot.larkAppId},共 ${bots.length} 个`);
448
+ if (!newBot) {
449
+ console.log('\n⚠️ setup 中止,bots.json 不动。');
450
+ return;
451
+ }
452
+ writeBotsJsonAtomic([...bots, newBot]);
453
+ console.log(`\n✅ 已添加机器人 ${newBot.larkAppId},共 ${bots.length + 1} 个`);
256
454
  console.log(` 配置文件: ${BOTS_JSON_FILE}`);
257
- console.log(`\n下一步: botmux restart`);
455
+ printRemainingSteps(newBot.larkAppId, botBrand(newBot));
456
+ console.log(`下一步: botmux restart\n`);
258
457
  }
259
458
  else if (hasEnv) {
260
459
  // --- Single-bot mode (.env exists) ---
@@ -263,9 +462,11 @@ async function cmdSetup() {
263
462
  const action = await ask(rl, '操作: 1) 添加新机器人 2) 覆盖当前配置 (1/2): ');
264
463
  if (action === '2') {
265
464
  rl.close();
266
- await writeSingleBotConfig();
267
- renameSync(ENV_FILE, ENV_FILE + '.bak');
268
- console.log(` 旧 .env 已备份: ${ENV_FILE}.bak`);
465
+ const ok = await writeSingleBotConfig();
466
+ if (ok) {
467
+ renameSync(ENV_FILE, ENV_FILE + '.bak');
468
+ console.log(` 旧 .env 已备份: ${ENV_FILE}.bak`);
469
+ }
269
470
  return;
270
471
  }
271
472
  // Migrate .env → bots.json
@@ -278,16 +479,20 @@ async function cmdSetup() {
278
479
  }
279
480
  console.log(`\n当前机器人: ${existingBot.larkAppId} (${existingBot.cliId ?? 'claude-code'})`);
280
481
  console.log('\n── 添加新机器人 ──\n');
281
- printLarkPermissions();
282
482
  const newBot = await promptBotConfig(rl);
283
483
  rl.close();
284
- const bots = [existingBot, newBot];
285
- writeFileSync(BOTS_JSON_FILE, JSON.stringify(bots, null, 2) + '\n');
484
+ if (!newBot) {
485
+ console.log('\n⚠️ setup 中止,.env bots.json 都不动。');
486
+ return;
487
+ }
488
+ // 写新文件成功后才备份 .env. 失败不动两边.
489
+ writeBotsJsonAtomic([existingBot, newBot]);
286
490
  renameSync(ENV_FILE, ENV_FILE + '.bak');
287
491
  console.log(`\n✅ 已迁移到多机器人配置`);
288
492
  console.log(` 配置文件: ${BOTS_JSON_FILE}`);
289
493
  console.log(` 旧配置已备份: ${ENV_FILE}.bak`);
290
- console.log(`\n下一步: botmux restart`);
494
+ printRemainingSteps(newBot.larkAppId, botBrand(newBot));
495
+ console.log(`下一步: botmux restart\n`);
291
496
  }
292
497
  else {
293
498
  // --- Fresh install ---
@@ -379,6 +584,40 @@ async function cmdStart() {
379
584
  ensureConfigDir();
380
585
  preflightNodeSanity();
381
586
  await ensureSystemDependencies();
587
+ // 启动前快速校验每个 bot 的凭证. Codex review 边界 #5: 凭证无效是
588
+ // 唯一应该阻塞 start 的情况; scope/event 缺失在 daemon 起来后用 WARN
589
+ // + 私信处理 (event-dispatcher.checkRequiredScopes).
590
+ //
591
+ // 失败时打印明确的 appId 前缀和错误码, 不打印 secret, 不 spawn pm2 进程.
592
+ const botsForCheck = loadBotsJson();
593
+ if (botsForCheck.length > 0) {
594
+ const { validateCredentials } = await import('./setup/verify-permissions.js');
595
+ const invalid = [];
596
+ for (const b of botsForCheck) {
597
+ if (!b.larkAppId || !b.larkAppSecret) {
598
+ invalid.push({ appId: b.larkAppId || '(空 appId)', reason: 'larkAppId/larkAppSecret 缺失' });
599
+ continue;
600
+ }
601
+ const v = await validateCredentials(b.larkAppId, b.larkAppSecret, botBrand(b));
602
+ if (!v.ok) {
603
+ if (v.error === 'invalid_credentials') {
604
+ invalid.push({ appId: b.larkAppId, reason: v.message });
605
+ }
606
+ else {
607
+ // network / unknown — 不应该拦下启动, 走 WARN
608
+ console.warn(`⚠️ [${b.larkAppId}] 启动前凭证验证未成功(${v.error}): ${v.message}`);
609
+ console.warn(` daemon 仍会启动;启动后 dispatcher 会自行重试。`);
610
+ }
611
+ }
612
+ }
613
+ if (invalid.length > 0) {
614
+ console.error('\n❌ 以下机器人凭证无效,botmux start 中止:\n');
615
+ for (const e of invalid)
616
+ console.error(` - ${e.appId}: ${e.reason}`);
617
+ console.error('\n 修复方式: 运行 `botmux setup` 选 "重新配置" 重新走扫码/手动流程。');
618
+ process.exit(1);
619
+ }
620
+ }
382
621
  cleanupLegacyPm2();
383
622
  const cfg = ecosystemConfig();
384
623
  runPm2(['start', cfg]);