@wemind/cli 1.1.0 → 1.1.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
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
4
5
  import crypto, { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
5
6
  import os from "node:os";
6
7
  import { spawn, spawnSync } from "node:child_process";
7
- import { fileURLToPath } from "node:url";
8
8
  //#region ../wechat-ilink-core/dist/config.js
9
9
  /**
10
10
  * iLink 应用标识。
@@ -13,6 +13,54 @@ import { fileURLToPath } from "node:url";
13
13
  */
14
14
  const ILINK_APP_ID = process.env.ILINK_APP_ID?.trim() || "bot";
15
15
  //#endregion
16
+ //#region ../wechat-ilink-core/dist/pkg-version.js
17
+ /**
18
+ * 读取「调用方所在包」自己的版本号(从它的 package.json)。
19
+ *
20
+ * ## 为什么放在 core
21
+ * `@wemind/wechat-mcp` 与 `@wemind/cli` 都需要上报自己的版本,两边的「向上查找 package.json」
22
+ * 逻辑完全一样。放 core 里只维护一份;两个消费方各留一个 3 行的薄适配层。
23
+ *
24
+ * ## 为什么必须由调用方传入位置(`callerUrl`)
25
+ * `import.meta.url` 指向**当前文件**,而当前文件在 core 里——用它去向上找,命中的会是 core
26
+ * 自己的 `package.json`(或更上层的仓库根),**永远拿不到调用方的版本**。
27
+ * 实测过:在 daemon 语境里直接复用 mcp 的同类实现,得到的是 mcp 的版本(1.0.4)而不是
28
+ * daemon 的(1.1.0),且不会报错——静默给错值是最坏的一类 bug。
29
+ * 所以这里强制由调用方传 `import.meta.url`,函数从**那个位置**向上找。
30
+ *
31
+ * ## 为什么还要 expectedName
32
+ * 只按「最近的 package.json」查找并不可靠:在 monorepo 里向上找,可能先撞到 core 的、
33
+ * 也可能撞到仓库根的。用 `name` 精确匹配,命中不了就返回 `undefined`(安全失败),
34
+ * 由调用方决定兜底值,绝不静默返回别人的版本。
35
+ *
36
+ * ## 打包后仍然有效
37
+ * 两个消费方都用 tsdown 把 core **内联**进单文件产物。由于查找起点是调用方的
38
+ * `import.meta.url`(打包后是 `dist/cli.js` / `dist/index.js`),向上第一个带
39
+ * `name` 匹配的 `package.json` 正是该消费方自己的——dev(`src/`)与产物(`dist/`)两种布局都成立。
40
+ *
41
+ * @param callerUrl 调用方模块的 `import.meta.url`
42
+ * @param expectedName 期望的包名(即调用方自己 package.json 的 `name`)
43
+ * @returns 匹配到的 `version`;找不到返回 `undefined`
44
+ */
45
+ function readOwnVersion(callerUrl, expectedName) {
46
+ if (!callerUrl || !expectedName) return void 0;
47
+ let dir;
48
+ try {
49
+ dir = path.dirname(fileURLToPath(callerUrl));
50
+ } catch {
51
+ return;
52
+ }
53
+ const { root } = path.parse(dir);
54
+ while (dir && dir !== root) {
55
+ const candidate = path.join(dir, "package.json");
56
+ try {
57
+ const p = JSON.parse(fs.readFileSync(candidate, "utf-8"));
58
+ if (p.name === expectedName && p.version) return p.version;
59
+ } catch {}
60
+ dir = path.dirname(dir);
61
+ }
62
+ }
63
+ //#endregion
16
64
  //#region ../wechat-ilink-core/dist/api/types.js
17
65
  /**
18
66
  * iLink Bot API 协议类型(镜像服务端 proto:GetUpdatesReq/Resp、WeixinMessage、SendMessageReq 等)。
@@ -144,8 +192,11 @@ var ILinkError = class extends Error {
144
192
  };
145
193
  //#endregion
146
194
  //#region ../wechat-ilink-core/dist/api/client.js
147
- const DEFAULT_LONG_POLL_TIMEOUT_MS = 35e3;
195
+ /** 普通 RPC 的超时(send/typing/config/upload/notify)。 */
148
196
  const DEFAULT_API_TIMEOUT_MS = 15e3;
197
+ /** 消息长轮询(getupdates)超时。 */
198
+ const DEFAULT_LONG_POLL_TIMEOUT_MS = 35e3;
199
+ /** 扫码状态长轮询(get_qrcode_status)超时。与消息长轮询语义不同,故分开命名。 */
149
200
  const QR_LONG_POLL_TIMEOUT_MS = 35e3;
150
201
  const ILINK_CLIENT_VERSION = process.env.ILINK_CLIENT_VERSION?.trim() || "2.4.3";
151
202
  const CHANNEL_VERSION = ILINK_CLIENT_VERSION;
@@ -183,6 +234,7 @@ function buildHeaders(opts) {
183
234
  if (opts.token?.trim()) headers.Authorization = `Bearer ${opts.token.trim()}`;
184
235
  return headers;
185
236
  }
237
+ /** POST 一个 iLink CGI,返回原始响应体。HTTP 非 2xx 抛错;响应体是否是 JSON 由调用方决定。 */
186
238
  async function apiFetch(params) {
187
239
  const base = ensureTrailingSlash(params.baseUrl);
188
240
  const url = new URL(params.endpoint, base);
@@ -191,7 +243,7 @@ async function apiFetch(params) {
191
243
  body: params.body
192
244
  });
193
245
  const controller = new AbortController();
194
- const t = setTimeout(() => controller.abort(), params.timeoutMs);
246
+ const timer = setTimeout(() => controller.abort(), params.timeoutMs);
195
247
  try {
196
248
  const res = await fetch(url.toString(), {
197
249
  method: "POST",
@@ -200,12 +252,19 @@ async function apiFetch(params) {
200
252
  signal: controller.signal
201
253
  });
202
254
  const rawText = await res.text();
203
- clearTimeout(t);
204
- if (!res.ok) throw new Error(`${params.label} ${res.status}: ${rawText}`);
255
+ if (!res.ok) throw new Error(`${params.label} ${res.status}: ${rawText.slice(0, 500)}`);
205
256
  return rawText;
206
- } catch (err) {
207
- clearTimeout(t);
208
- throw err;
257
+ } finally {
258
+ clearTimeout(timer);
259
+ }
260
+ }
261
+ /** 宽松解析:空响应体或非 JSON 返回 null(部分接口成功时无响应体)。 */
262
+ function tryParseJson(raw) {
263
+ if (!raw) return null;
264
+ try {
265
+ return JSON.parse(raw);
266
+ } catch {
267
+ return null;
209
268
  }
210
269
  }
211
270
  /** 防御性解析 API JSON 响应:失败抛带 label 与响应片段的清晰错误,而非裸 SyntaxError(畸形/代理 HTML/截断响应不致以晦涩错误打断调用方)。 */
@@ -216,6 +275,15 @@ function parseJsonResp(raw, label) {
216
275
  throw new Error(`${label}: response not JSON (${raw.slice(0, 120)})`);
217
276
  }
218
277
  }
278
+ /**
279
+ * iLink 即使 HTTP 200 也可能在响应体返回非 0 ret/errcode 表示失败。语义见 error-codes.ts:
280
+ * ret=-2 参数错误(最常见 context_token 失效/不匹配);errcode=-14 会话过期。
281
+ * 不校验会导致「假成功」——消息其实没送达。仅在明确拿到非 0 错误码时抛 ILinkError(携带码+分类,供上层按类型处理)。
282
+ */
283
+ function throwIfErrorCode(op, resp) {
284
+ const code = resp?.ret ?? resp?.errcode;
285
+ if (code !== void 0 && code !== 0) throw new ILinkError(op, code, resp?.errmsg);
286
+ }
219
287
  function buildBaseInfo() {
220
288
  return { channel_version: CHANNEL_VERSION };
221
289
  }
@@ -243,7 +311,7 @@ async function getUpdates(params) {
243
311
  }
244
312
  }
245
313
  async function sendMessage(params) {
246
- const raw = await apiFetch({
314
+ throwIfErrorCode("sendMessage", tryParseJson(await apiFetch({
247
315
  baseUrl: params.baseUrl,
248
316
  endpoint: "ilink/bot/sendmessage",
249
317
  body: JSON.stringify({
@@ -253,19 +321,11 @@ async function sendMessage(params) {
253
321
  token: params.token,
254
322
  timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
255
323
  label: "sendMessage"
256
- });
257
- let code;
258
- let errmsg;
259
- try {
260
- const resp = raw ? JSON.parse(raw) : null;
261
- code = resp?.ret ?? resp?.errcode;
262
- errmsg = resp?.errmsg;
263
- } catch {}
264
- if (code !== void 0 && code !== 0) throw new ILinkError("sendMessage", code, errmsg);
324
+ })));
265
325
  }
266
326
  /** 取 CDN 上传地址(发图/发文件用)。返回 upload_full_url 或 upload_param。 */
267
327
  async function getUploadUrl(params) {
268
- return parseJsonResp(await apiFetch({
328
+ const resp = parseJsonResp(await apiFetch({
269
329
  baseUrl: params.baseUrl,
270
330
  endpoint: "ilink/bot/getuploadurl",
271
331
  body: JSON.stringify({
@@ -283,6 +343,8 @@ async function getUploadUrl(params) {
283
343
  timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
284
344
  label: "getUploadUrl"
285
345
  }), "getUploadUrl");
346
+ throwIfErrorCode("getUploadUrl", resp);
347
+ return resp;
286
348
  }
287
349
  async function fetchQRCode(apiBaseUrl) {
288
350
  return parseJsonResp(await apiFetch({
@@ -303,26 +365,21 @@ async function pollQRStatus(apiBaseUrl, qrcode) {
303
365
  headers: buildCommonHeaders(),
304
366
  signal: controller.signal
305
367
  });
306
- if (!response.ok) {
307
- const body = await response.text().catch(() => "(unreadable)");
308
- clearTimeout(timer);
309
- throw new Error(`Failed to poll QR status: ${response.status} ${response.statusText} ${body}`);
310
- }
311
368
  const text = await response.text();
312
- clearTimeout(timer);
369
+ if (!response.ok) throw new Error(`pollQRStatus ${response.status} ${response.statusText} ${text.slice(0, 500)}`);
313
370
  return parseJsonResp(text, "pollQRStatus");
314
371
  } catch (err) {
315
- clearTimeout(timer);
316
372
  if (err instanceof Error && err.name === "AbortError") return { status: "wait" };
317
373
  throw err;
374
+ } finally {
375
+ clearTimeout(timer);
318
376
  }
319
377
  }
320
378
  function generateId(prefix) {
321
379
  return `${prefix}:${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
322
380
  }
323
- const DEFAULT_CONFIG_TIMEOUT_MS = 15e3;
324
381
  async function getConfig(params) {
325
- return parseJsonResp(await apiFetch({
382
+ const resp = parseJsonResp(await apiFetch({
326
383
  baseUrl: params.baseUrl,
327
384
  endpoint: "ilink/bot/getconfig",
328
385
  body: JSON.stringify({
@@ -331,12 +388,14 @@ async function getConfig(params) {
331
388
  base_info: buildBaseInfo()
332
389
  }),
333
390
  token: params.token,
334
- timeoutMs: DEFAULT_CONFIG_TIMEOUT_MS,
391
+ timeoutMs: DEFAULT_API_TIMEOUT_MS,
335
392
  label: "getConfig"
336
393
  }), "getConfig");
394
+ throwIfErrorCode("getConfig", resp);
395
+ return resp;
337
396
  }
338
397
  async function sendTyping(params) {
339
- await apiFetch({
398
+ throwIfErrorCode("sendTyping", tryParseJson(await apiFetch({
340
399
  baseUrl: params.baseUrl,
341
400
  endpoint: "ilink/bot/sendtyping",
342
401
  body: JSON.stringify({
@@ -344,39 +403,36 @@ async function sendTyping(params) {
344
403
  base_info: buildBaseInfo()
345
404
  }),
346
405
  token: params.token,
347
- timeoutMs: DEFAULT_CONFIG_TIMEOUT_MS,
406
+ timeoutMs: DEFAULT_API_TIMEOUT_MS,
348
407
  label: "sendTyping"
349
- });
408
+ })));
350
409
  }
351
- /** 通知服务端该 bot 客户端上线(best-effort,失败不阻断)。 */
352
- async function notifyStart(params) {
410
+ /**
411
+ * 上线/下线通知的共同实现。
412
+ * best-effort:失败只记日志、绝不抛出——通知失败不应阻断登录/登出主流程。
413
+ * 也正因为要吞错,这里刻意**不校验**响应体的 ret/errcode。
414
+ */
415
+ async function notifyLifecycle(op, endpoint, params) {
353
416
  try {
354
417
  await apiFetch({
355
418
  baseUrl: params.baseUrl,
356
- endpoint: "ilink/bot/msg/notifystart",
419
+ endpoint,
357
420
  body: JSON.stringify({ base_info: buildBaseInfo() }),
358
421
  token: params.token,
359
- timeoutMs: DEFAULT_CONFIG_TIMEOUT_MS,
360
- label: "notifyStart"
422
+ timeoutMs: DEFAULT_API_TIMEOUT_MS,
423
+ label: op
361
424
  });
362
425
  } catch (err) {
363
- console.error(`[wemind/wechat-ilink-core/ilink] notifyStart failed (ignored, best-effort): ${String(err)}`);
426
+ console.error(`[wemind/wechat-ilink-core/ilink] ${op} failed (ignored, best-effort): ${String(err)}`);
364
427
  }
365
428
  }
429
+ /** 通知服务端该 bot 客户端上线(best-effort,失败不阻断)。 */
430
+ async function notifyStart(params) {
431
+ await notifyLifecycle("notifyStart", "ilink/bot/msg/notifystart", params);
432
+ }
366
433
  /** 通知服务端该 bot 客户端下线(best-effort,失败不阻断)。 */
367
434
  async function notifyStop(params) {
368
- try {
369
- await apiFetch({
370
- baseUrl: params.baseUrl,
371
- endpoint: "ilink/bot/msg/notifystop",
372
- body: JSON.stringify({ base_info: buildBaseInfo() }),
373
- token: params.token,
374
- timeoutMs: DEFAULT_CONFIG_TIMEOUT_MS,
375
- label: "notifyStop"
376
- });
377
- } catch (err) {
378
- console.error(`[wemind/wechat-ilink-core/ilink] notifyStop failed (ignored, best-effort): ${String(err)}`);
379
- }
435
+ await notifyLifecycle("notifyStop", "ilink/bot/msg/notifystop", params);
380
436
  }
381
437
  //#endregion
382
438
  //#region ../wechat-ilink-core/dist/api/transport-errors.js
@@ -559,6 +615,12 @@ function setLabel(key, label) {
559
615
  */
560
616
  const ACCOUNT_KEY = process.env.WECHAT_ACCOUNT?.trim() || "default";
561
617
  const DATA_DIR = process.env.WECHAT_DATA_DIR?.trim() || path.join(WEMIND_ROOT, ACCOUNT_KEY);
618
+ /** 持久化文件名:集中在此,避免各处拼字符串写错(load/save/clear 必须指向同一份)。 */
619
+ const FILES = {
620
+ account: "account.json",
621
+ state: "state.json",
622
+ qrState: "qr_login.json"
623
+ };
562
624
  function ensureDir(dir) {
563
625
  fs.mkdirSync(dir, { recursive: true });
564
626
  }
@@ -566,8 +628,9 @@ function readJson$2(filePath) {
566
628
  let raw;
567
629
  try {
568
630
  raw = fs.readFileSync(filePath, "utf-8");
569
- } catch {
570
- return null;
631
+ } catch (e) {
632
+ if (isNotFound(e)) return null;
633
+ throw e;
571
634
  }
572
635
  try {
573
636
  return JSON.parse(raw);
@@ -579,28 +642,63 @@ function readJson$2(filePath) {
579
642
  return null;
580
643
  }
581
644
  }
645
+ function isNotFound(err) {
646
+ return err?.code === "ENOENT";
647
+ }
582
648
  /** 原子写:先写临时文件再 rename 覆盖(同盘 rename 原子),避免写途崩溃/并发产生半截损坏文件。 */
583
649
  function writeJson(filePath, data) {
584
650
  ensureDir(path.dirname(filePath));
585
651
  const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
586
- fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
587
- fs.renameSync(tmp, filePath);
652
+ try {
653
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
654
+ fs.renameSync(tmp, filePath);
655
+ } catch (e) {
656
+ try {
657
+ fs.rmSync(tmp, { force: true });
658
+ } catch {}
659
+ throw e;
660
+ }
588
661
  }
589
- function loadAccount() {
590
- return readJson$2(path.join(DATA_DIR, "account.json"));
662
+ function loadAccount(dir = DATA_DIR) {
663
+ return readJson$2(path.join(dir, FILES.account));
591
664
  }
592
- function loadState() {
593
- return readJson$2(path.join(DATA_DIR, "state.json")) ?? {
665
+ let warnedWinPerms = false;
666
+ /**
667
+ * 把登录凭证写进**指定**目录的 `account.json`(原子写 + POSIX 600 + Windows 一次性明文告警)。
668
+ *
669
+ * 为什么要有 dir 参数:`wemind login` 是先写到暂存目录 `.staging/`、扫码确认后才 rename 成账号目录。
670
+ * 那条路径以前直接 `fs.writeFileSync`,于是「POSIX 600 / Windows 告警」这两条安全承诺在最推荐
671
+ * 的登录入口上不成立(chmod 也漏了)。改成复用本函数,两条路径行为一致。
672
+ * rename 同盘保留权限位,所以先写好再归档不会丢 600。
673
+ */
674
+ function saveAccountTo(dir, account) {
675
+ const file = path.join(dir, FILES.account);
676
+ writeJson(file, account);
677
+ if (process.platform === "win32") {
678
+ if (!warnedWinPerms) {
679
+ warnedWinPerms = true;
680
+ console.error(`[wemind/wechat-ilink-core] warning: ${file} stores the bot token in plaintext; Windows cannot restrict it via chmod. Protect ${dir} with folder permissions or disk encryption.`);
681
+ }
682
+ } else try {
683
+ fs.chmodSync(file, 384);
684
+ } catch {}
685
+ }
686
+ /** 空状态:唯一来源,避免 loadState/resetState 各写一份、将来加字段时漏改。 */
687
+ function emptyState() {
688
+ return {
594
689
  updatesBuf: "",
595
690
  contextTokens: {},
596
691
  lastMessageId: 0
597
692
  };
598
693
  }
599
- function saveState(state) {
600
- writeJson(path.join(DATA_DIR, "state.json"), state);
694
+ function loadState(dir = DATA_DIR) {
695
+ return readJson$2(path.join(dir, FILES.state)) ?? emptyState();
601
696
  }
602
- function saveQrState(state) {
603
- writeJson(path.join(DATA_DIR, "qr_login.json"), state);
697
+ function saveState(state, dir = DATA_DIR) {
698
+ writeJson(path.join(dir, FILES.state), state);
699
+ }
700
+ function saveQrState(state, dir = DATA_DIR) {
701
+ writeJson(path.join(dir, FILES.qrState), state);
604
702
  }
605
703
  //#endregion
606
704
  //#region ../wechat-ilink-core/dist/store/allow-from.js
@@ -741,8 +839,6 @@ async function uploadMediaBytes(params) {
741
839
  no_need_thumb: true,
742
840
  aeskey: aeskey.toString("hex")
743
841
  });
744
- const code = up.ret ?? up.errcode;
745
- if (code !== void 0 && code !== 0) throw new ILinkError("getUploadUrl", code, up.errmsg);
746
842
  return {
747
843
  downloadParam: await uploadBufferToCdn({
748
844
  buf: data,
@@ -3023,6 +3119,18 @@ function sendOrdered(account, to, ctx, text, state = MessageState.FINISH, fallba
3023
3119
  });
3024
3120
  return replyChain;
3025
3121
  }
3122
+ /**
3123
+ * 发一条「必须送达」的答复(控制指令回执、任务回执、任务结束确认、出错提示)。
3124
+ *
3125
+ * 与 sendOrdered 的唯一差别:**先计入连发额度**。服务端按「距用户上一条消息」计连续发送数,
3126
+ * 这类答复确实占额度;只发不计数会让 brain 回合的 budgetOf 低估真实值 → 误判「还能发」→
3127
+ * 撞 ret=-2 限流、结果丢失。计数后仍走同一条 replyChain,保证与进度/答案不乱序、遵守最小间隔。
3128
+ * 与 `setTaskPush` 同一套语义:task 推送也是「必须送达、只计数不被 awaiting 暂停」。
3129
+ */
3130
+ function sendNotice(account, to, ctx, text, fallbackToken) {
3131
+ budgetOf(to).count += 1;
3132
+ return sendOrdered(account, to, ctx, text, MessageState.FINISH, fallbackToken);
3133
+ }
3026
3134
  /** 把长回复作为 .md 文件发送(走同一串行链+节流);发文件失败则兜底回退为文本。 */
3027
3135
  function sendMdOrdered(account, to, ctx, finalText, fallbackToken) {
3028
3136
  replyChain = replyChain.then(async () => {
@@ -3322,21 +3430,21 @@ async function workerLoop(account, state) {
3322
3430
  r = "处理指令出错:" + String(e);
3323
3431
  }
3324
3432
  log("control:", JSON.stringify(ctrl), "->", r.replace(/\n/g, " | "));
3325
- await reply(account, from, token, r, MessageState.FINISH, latestToken()).catch((e) => handleSendError(e, account));
3433
+ await sendNotice(account, from, token, r, latestToken());
3326
3434
  continue;
3327
3435
  }
3328
3436
  const tcmd = item.text ? parseTaskControl(item.text) : null;
3329
3437
  if (tcmd?.kind === "stop") {
3330
3438
  const r = stopTask(from);
3331
3439
  log("task: stop ->", r);
3332
- await reply(account, from, token, r, MessageState.FINISH, latestToken()).catch((e) => handleSendError(e, account));
3440
+ await sendNotice(account, from, token, r, latestToken());
3333
3441
  continue;
3334
3442
  }
3335
3443
  if (tcmd?.kind === "toWorker") {
3336
3444
  if (hasActiveTask(from)) {
3337
3445
  log("task: inject(explicit):", tcmd.text.slice(0, 40));
3338
3446
  injectTask(from, tcmd.text);
3339
- } else await reply(account, from, token, dispatchTask(from, tcmd.text), MessageState.FINISH, latestToken()).catch((e) => handleSendError(e, account));
3447
+ } else await sendNotice(account, from, token, dispatchTask(from, tcmd.text), latestToken());
3340
3448
  continue;
3341
3449
  }
3342
3450
  const brainEscape = item.text ? parseBrainEscape(item.text) : null;
@@ -3350,7 +3458,7 @@ async function workerLoop(account, state) {
3350
3458
  await handleMessage(account, token, from, brainText, item.imageItems, item.createTimeMs, latestToken);
3351
3459
  } catch (e) {
3352
3460
  log("handleMessage error:", e instanceof Error ? e.stack || e.message : String(e));
3353
- await reply(account, from, token, "处理这条消息时出错了,请再发一次试试。", MessageState.FINISH, latestToken()).catch((e2) => handleSendError(e2, account));
3461
+ await sendNotice(account, from, token, "处理这条消息时出错了,请再发一次试试。", latestToken());
3354
3462
  }
3355
3463
  }
3356
3464
  }
@@ -3488,8 +3596,50 @@ function pm2Available() {
3488
3596
  }
3489
3597
  /** 账号的 daemon 日志路径,与 run-daemon.ps1 / ecosystem 方式一致:`~/.wemind/<key>/daemon.log`。 */
3490
3598
  function daemonLogPath(key) {
3491
- const root = process.env.WEMIND_HOME?.trim() || path.join(os.homedir(), ".wemind");
3492
- return path.join(root, key, "daemon.log");
3599
+ return path.join(accountDir(key), "daemon.log");
3600
+ }
3601
+ /**
3602
+ * 生成 pm2 的 app 声明(`{ apps: [decl] }`,写进账号目录的 `pm2.app.json`)。
3603
+ *
3604
+ * **为什么用 JSON 声明而不是把参数堆在 `pm2 start` 命令行上**:
3605
+ * - `restart_delay` / `stop_exit_codes` / `max_restarts` 在命令行上是 `--restart-delay` /
3606
+ * `--stop-exit-codes`(**变参**)/ `--max-restarts`,还要和 `-- daemon` 这个「脚本参数分隔符」
3607
+ * 挤在同一条命令里——解析顺序、变参吞掉后续 token 的风险都只能靠真机试;
3608
+ * - JSON 声明没有解析层:键值原样进 `pm2_env`(`stop_exit_codes` 在 `God.handleExit` 里既接受
3609
+ * 数字数组也接受数字字符串,这里给数字数组),与 `ecosystem.config.cjs` 同源同义;
3610
+ * - 路径全走 JSON 转义,不再依赖 shell 引号。
3611
+ *
3612
+ * 与 `ecosystem.config.cjs` 保持一致的项:app 名 `wemind-<key>`、崩溃 5s 重拉、最多 50 次、
3613
+ * 退出码 78(大脑配置错误)不重启、日志合并到账号 `daemon.log`。导出供单测直接断言(不需要装 pm2)。
3614
+ */
3615
+ function buildAppDeclaration(key, cliPath, logPath) {
3616
+ return {
3617
+ name: `wemind-${key}`,
3618
+ script: cliPath,
3619
+ args: "daemon",
3620
+ interpreter: "node",
3621
+ cwd: os.homedir(),
3622
+ autorestart: true,
3623
+ restart_delay: 5e3,
3624
+ max_restarts: 50,
3625
+ stop_exit_codes: [78],
3626
+ out_file: logPath,
3627
+ error_file: logPath,
3628
+ merge_logs: true,
3629
+ watch: false,
3630
+ env: { WECHAT_ACCOUNT: key }
3631
+ };
3632
+ }
3633
+ /**
3634
+ * 把声明写进账号目录,返回文件路径。单独导出便于测试(不触碰 pm2);
3635
+ * `dir` 可覆盖目标目录——单测传临时目录,避免动真实 `~/.wemind`。
3636
+ */
3637
+ function writeAppDeclaration(key, cliPath, logPath, dir = accountDir(key)) {
3638
+ fs.mkdirSync(dir, { recursive: true });
3639
+ const file = path.join(dir, "pm2.app.json");
3640
+ const apps = [buildAppDeclaration(key, cliPath, logPath)];
3641
+ fs.writeFileSync(file, JSON.stringify({ apps }, null, 2) + "\n", "utf-8");
3642
+ return file;
3493
3643
  }
3494
3644
  /** 用 pm2 启动某账号的 daemon:app 名 `wemind-<key>`,注入 WECHAT_ACCOUNT,日志归一到账号 daemon.log。 */
3495
3645
  function pm2Start(key, cliPath) {
@@ -3497,21 +3647,23 @@ function pm2Start(key, cliPath) {
3497
3647
  ok: false,
3498
3648
  msg: "未检测到 pm2。请先安装:npm i -g pm2,再重试 `wemind start`。"
3499
3649
  };
3500
- const name = `wemind-${key}`;
3501
3650
  const log = daemonLogPath(key);
3651
+ const name = `wemind-${key}`;
3502
3652
  pm2Run(`delete ${name}`, { quiet: true });
3503
- const r = pm2Run(`start "${cliPath}" --name ${name} --interpreter node --output "${log}" --error "${log}" --merge-logs -- daemon`, { env: {
3653
+ const decl = writeAppDeclaration(key, cliPath, log);
3654
+ const r = pm2Run(`start "${decl}"`, { env: {
3504
3655
  ...process.env,
3505
3656
  WECHAT_ACCOUNT: key
3506
3657
  } });
3507
3658
  if (r.status !== 0) return {
3508
3659
  ok: false,
3509
- msg: `pm2 启动失败(exit=${r.status})。`
3660
+ msg: `pm2 启动失败(exit=${r.status}),声明文件:${decl}`
3510
3661
  };
3511
3662
  pm2Run("save", { quiet: true });
3512
3663
  return {
3513
3664
  ok: true,
3514
- msg: `已用 pm2 启动 ${name}(日志:${log})。\n 开机自启(首次):执行 \`pm2 startup\`——Linux/macOS 按提示操作;Windows 用计划任务跑 \`pm2 resurrect\`。`
3665
+ msg: `已用 pm2 启动 ${name}(日志:${log})。\n 崩溃 5s 重拉、退出码 78(大脑配置错误)不再重启,与自带看门狗一致。
3666
+ 开机自启(首次):执行 \`pm2 startup\`——Linux/macOS 按提示操作;Windows 用计划任务跑 \`pm2 resurrect\`。`
3515
3667
  };
3516
3668
  }
3517
3669
  /** 停止某账号的 daemon(pm2 delete)。 */
@@ -3845,17 +3997,23 @@ async function checkAgent(ag, timeoutMs = 6e4) {
3845
3997
  //#endregion
3846
3998
  //#region src/version.ts
3847
3999
  /**
3848
- * CLI 版本号(`wemind version` / `wemind --version` / help 头部)。
3849
- * 与 package.json 的 version 必须一致——单测 version.test.ts 会读 package.json 校验,
3850
- * 避免「发了新版本、CLI 还报旧版本号」这种排查成本极高的小漂移。
4000
+ * CLI 版本号与包身份(`wemind version` / `wemind --version` / help 头部)。
4001
+ *
4002
+ * **版本号唯一来源是 `package.json`**,这里在运行时读它,不再手写常量。
4003
+ * 为什么:以前版本号散落在 package.json 和本文件两处,`npm version` 只会改前者,
4004
+ * 漏改本文件会导致「发了 1.2.0、`wemind version` 还报 1.1.0」这种极难排查的漂移。
4005
+ * 现在 `npm run release:patch|minor|major` 一步到位,本文件自动跟着变。
4006
+ *
4007
+ * 查找逻辑在 `@wemind/wechat-ilink-core` 的 `readOwnVersion()`(与 MCP 包共用一份):
4008
+ * 必须把**本文件的** `import.meta.url` 传进去,否则会命中的是 core 自己的 package.json。
3851
4009
  */
3852
- const CLI_VERSION = "1.1.0";
4010
+ /** 期望的 npm 包名。这是**独立常量**,故意不从 package.json 读——它是校验基准。 */
4011
+ const CLI_PKG_NAME = "@wemind/cli";
3853
4012
  /**
3854
- * npm 包名(与 package.json 的 name 一致,单测校验)。
3855
- * 命令名是 `wemind`,但**包名**是 `@wemind/cli`——两者不同,所以 `npx` 不能靠「唯一 bin」自动解析,
3856
- * 必须写全 `npx @wemind/cli`(见 README 与 docs/发布-wemind-npm.md)。
4013
+ * 包版本。正常情况下等于 `package.json` 的 `version`;
4014
+ * 若查找失败(不该发生),退化为可识别的占位值,单测会把它当失败信号。
3857
4015
  */
3858
- const CLI_PKG_NAME = "@wemind/cli";
4016
+ const CLI_VERSION = readOwnVersion(import.meta.url, "@wemind/cli") ?? "0.0.0-unknown";
3859
4017
  /** 单行版本串(供 --version 输出):命令名 + 版本,包名放括号里便于反馈问题时对上 npm 页面。 */
3860
4018
  function versionLine() {
3861
4019
  return `wemind ${CLI_VERSION} (${CLI_PKG_NAME}, node ${process.versions.node}, ${process.platform}-${process.arch})`;
@@ -3871,13 +4029,6 @@ function versionLine() {
3871
4029
  const BASE = process.env.WEMIND_ILINK_BASE?.trim() || "https://ilinkai.weixin.qq.com";
3872
4030
  const STAGING = path.join(WEMIND_ROOT, ".staging");
3873
4031
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
3874
- function readAccount(dir) {
3875
- try {
3876
- return JSON.parse(fs.readFileSync(path.join(dir, "account.json"), "utf-8"));
3877
- } catch {
3878
- return null;
3879
- }
3880
- }
3881
4032
  function readPid(dir, name) {
3882
4033
  try {
3883
4034
  const n = parseInt(fs.readFileSync(path.join(dir, name), "utf-8").trim(), 10);
@@ -3893,19 +4044,99 @@ function readPid(dir, name) {
3893
4044
  function aliveOurs(pid, markers = CLI_PID_MARKERS) {
3894
4045
  return isOurProcess(pid, markers);
3895
4046
  }
3896
- function killPid(pid) {
3897
- if (process.platform === "win32") spawnSync("taskkill", [
3898
- "/PID",
3899
- String(pid),
3900
- "/T",
3901
- "/F"
3902
- ], {
3903
- stdio: "ignore",
3904
- windowsHide: true
3905
- });
3906
- else try {
3907
- process.kill(pid);
4047
+ /** 阻塞式 sleep(CLI 是同步流程;Atomics.wait 不占 CPU、不比 spawn 一个进程贵)。 */
4048
+ function sleepSync(ms) {
4049
+ try {
4050
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
4051
+ } catch {}
4052
+ }
4053
+ /**
4054
+ * 某 pid 的全部后代(POSIX)。Windows 不需要——`taskkill /T` 自己会走进程树。
4055
+ * 用 `ps -A -o pid=,ppid=` 一次取全表再在内存里建树,比逐层 `pgrep -P` 少起进程。
4056
+ * (导出仅供单测;CLI 内部不对外。)
4057
+ */
4058
+ function descendantPids(pid) {
4059
+ try {
4060
+ const psArgs = [[
4061
+ "-A",
4062
+ "-o",
4063
+ "pid=,ppid="
4064
+ ], [
4065
+ "-e",
4066
+ "-o",
4067
+ "pid=,ppid="
4068
+ ]];
4069
+ let stdout = "";
4070
+ for (const args of psArgs) {
4071
+ const r = spawnSync("ps", args, {
4072
+ encoding: "utf8",
4073
+ timeout: 5e3
4074
+ });
4075
+ if (r.status === 0 && r.stdout) {
4076
+ stdout = r.stdout;
4077
+ break;
4078
+ }
4079
+ }
4080
+ const childrenOf = /* @__PURE__ */ new Map();
4081
+ for (const line of stdout.split(/\r?\n/)) {
4082
+ const m = line.trim().match(/^(\d+)\s+(\d+)$/);
4083
+ if (!m) continue;
4084
+ const child = Number(m[1]);
4085
+ const parent = Number(m[2]);
4086
+ const bucket = childrenOf.get(parent);
4087
+ if (bucket) bucket.push(child);
4088
+ else childrenOf.set(parent, [child]);
4089
+ }
4090
+ const out = [];
4091
+ const walk = (p) => {
4092
+ for (const c of childrenOf.get(p) ?? []) {
4093
+ out.push(c);
4094
+ walk(c);
4095
+ }
4096
+ };
4097
+ walk(pid);
4098
+ return out;
4099
+ } catch {
4100
+ return [];
4101
+ }
4102
+ }
4103
+ /**
4104
+ * 杀「pid 及其整棵进程树」。
4105
+ *
4106
+ * Windows:`taskkill /T /F` 本来就连带子树。
4107
+ * POSIX:`process.kill(pid)` **只**结束该进程,不动它的子进程——daemon 被停后,它拉起的
4108
+ * ACP 大脑 / worker 会留下来继续占着会话与文件句柄(Windows 的 `taskkill /T` 会连带子树,所以这个缺陷只在 POSIX 暴露)。
4109
+ * 所以这里先收集后代、由叶子往根 SIGTERM(daemon 的 SIGTERM 处理器会自己清 pid 文件),
4110
+ * 等一小会儿再对仍存活的补 SIGKILL。
4111
+ */
4112
+ function killProcessTrees(pids) {
4113
+ const unique = [...new Set(pids.filter((p) => isAlive(p)))];
4114
+ if (!unique.length) return;
4115
+ if (process.platform === "win32") {
4116
+ for (const pid of unique) spawnSync("taskkill", [
4117
+ "/PID",
4118
+ String(pid),
4119
+ "/T",
4120
+ "/F"
4121
+ ], {
4122
+ stdio: "ignore",
4123
+ windowsHide: true
4124
+ });
4125
+ return;
4126
+ }
4127
+ const targets = [];
4128
+ for (const pid of unique) targets.push(...descendantPids(pid).reverse(), pid);
4129
+ const all = [...new Set(targets)].filter((p) => isAlive(p));
4130
+ for (const pid of all) try {
4131
+ process.kill(pid, "SIGTERM");
3908
4132
  } catch {}
4133
+ sleepSync(800);
4134
+ for (const pid of all) {
4135
+ if (!isAlive(pid)) continue;
4136
+ try {
4137
+ process.kill(pid, "SIGKILL");
4138
+ } catch {}
4139
+ }
3909
4140
  }
3910
4141
  function openFile(p) {
3911
4142
  try {
@@ -3942,7 +4173,10 @@ function startDaemon(key, runner) {
3942
4173
  const useRunner = (runner || process.env.WEMIND_RUNNER || "builtin").toLowerCase();
3943
4174
  console.log((useRunner === "pm2" ? pm2Start(key, cliPath) : watchdogStart(key, cliPath)).msg);
3944
4175
  }
3945
- /** 停止某账号的常驻:先 pm2 delete(方案 a),再按 pid 兜底杀 wrapper/daemon/brain(兼容直接启动的)。 */
4176
+ /**
4177
+ * 停止某账号的常驻:先 pm2 delete(方案 a),再按 pid 兜底杀 wrapper/daemon/brain/worker
4178
+ * (兼容直接启动的)。停的是**整条链**:除列出的进程外,还连带它们的子进程树。
4179
+ */
3946
4180
  function stopDaemon(key) {
3947
4181
  pm2Stop(key);
3948
4182
  const dir = accountDir(key);
@@ -3962,22 +4196,30 @@ function stopDaemon(key) {
3962
4196
  pid: readPid(dir, "brain.pid"),
3963
4197
  name: "brain",
3964
4198
  markers: brainPidMarkers(ag.bin, ag.args)
4199
+ },
4200
+ {
4201
+ pid: readPid(dir, "worker.pid"),
4202
+ name: "worker",
4203
+ markers: brainPidMarkers(ag.bin, ag.args)
3965
4204
  }
3966
4205
  ];
3967
4206
  const lines = commandLines(cand.map((c) => c.pid));
3968
4207
  const stopped = [];
3969
4208
  const skipped = [];
4209
+ const toKill = [];
3970
4210
  for (const c of cand) {
3971
4211
  if (!c.pid || !isAlive(c.pid)) continue;
3972
4212
  if (matchesMarkers(lines.get(c.pid), c.markers)) {
3973
- killPid(c.pid);
4213
+ toKill.push(c.pid);
3974
4214
  stopped.push(`${c.name} ${c.pid}`);
3975
4215
  } else skipped.push(`${c.name} ${c.pid}`);
3976
4216
  }
4217
+ killProcessTrees(toKill);
3977
4218
  for (const f of [
3978
4219
  "wrapper.pid",
3979
4220
  "daemon.pid",
3980
- "brain.pid"
4221
+ "brain.pid",
4222
+ "worker.pid"
3981
4223
  ]) try {
3982
4224
  fs.unlinkSync(path.join(dir, f));
3983
4225
  } catch {}
@@ -3988,7 +4230,7 @@ function stopDaemon(key) {
3988
4230
  }
3989
4231
  /** 把 STAGING 的登录按 botId 归档 + 登记。 */
3990
4232
  function finalizeStaging(label) {
3991
- const acc = readAccount(STAGING);
4233
+ const acc = loadAccount(STAGING);
3992
4234
  if (!acc?.botId) throw new Error(`staging 无有效登录(${STAGING})`);
3993
4235
  const key = deriveAccountKey(acc.botId);
3994
4236
  const dest = accountDir(key);
@@ -4088,13 +4330,13 @@ async function cmdLogin(label, autoStart, runner) {
4088
4330
  }
4089
4331
  if (s.status === "confirmed") {
4090
4332
  if (!s.bot_token || !s.ilink_bot_id || !s.ilink_user_id) throw new Error("confirmed 但数据不全");
4091
- fs.writeFileSync(path.join(STAGING, "account.json"), JSON.stringify({
4333
+ saveAccountTo(STAGING, {
4092
4334
  botToken: s.bot_token,
4093
4335
  botId: s.ilink_bot_id,
4094
4336
  userId: s.ilink_user_id,
4095
4337
  baseUrl: s.baseurl ?? BASE,
4096
4338
  savedAt: Date.now()
4097
- }, null, 2), "utf-8");
4339
+ });
4098
4340
  clearSessionPause(s.ilink_bot_id);
4099
4341
  await notifyStart({
4100
4342
  baseUrl: s.baseurl ?? BASE,
@@ -4205,10 +4447,18 @@ async function cmdBrainCheck(key) {
4205
4447
  /** brain 子命令派发:show(默认)/ set / unset / check。 */
4206
4448
  async function cmdBrain(rest) {
4207
4449
  const argv = [...rest];
4450
+ const FLAGS = /* @__PURE__ */ new Set([
4451
+ "--bin",
4452
+ "--args",
4453
+ "--shell",
4454
+ "--no-shell",
4455
+ "--restart"
4456
+ ]);
4208
4457
  const takeFlag = (name) => {
4209
4458
  const i = argv.indexOf(name);
4210
4459
  if (i < 0) return void 0;
4211
4460
  const v = argv[i + 1];
4461
+ if (v === void 0 || FLAGS.has(v)) throw new Error(`${name} 缺少参数值`);
4212
4462
  argv.splice(i, 2);
4213
4463
  return v;
4214
4464
  };
@@ -4247,7 +4497,7 @@ async function cmdBrain(rest) {
4247
4497
  }
4248
4498
  function cmdAdopt(key, label) {
4249
4499
  if (!key) throw new Error("用法: adopt <key> [--label 名]");
4250
- const acc = readAccount(accountDir(key));
4500
+ const acc = loadAccount(accountDir(key));
4251
4501
  if (!acc?.botId) throw new Error(`${accountDir(key)} 无有效 account.json`);
4252
4502
  const meta = registerAccount({
4253
4503
  key,
@@ -4262,7 +4512,7 @@ function cmdAdopt(key, label) {
4262
4512
  async function cmdLogout(sel) {
4263
4513
  const key = resolveKey(sel);
4264
4514
  const dir = accountDir(key);
4265
- const acc = readAccount(dir);
4515
+ const acc = loadAccount(dir);
4266
4516
  const stopMsg = stopDaemon(key);
4267
4517
  if (acc?.botToken) try {
4268
4518
  await notifyStop({
@@ -4485,7 +4735,20 @@ async function runManage(argv) {
4485
4735
  console.log(`${k} 授权用户(${list.length}):\n${list.map((u) => " - " + u).join("\n") || " (空)"}`);
4486
4736
  break;
4487
4737
  }
4488
- default: console.log(`WeMind 多账号管理(wemind v${CLI_VERSION})
4738
+ case "help":
4739
+ case "--help":
4740
+ case "-h":
4741
+ case void 0:
4742
+ console.log(helpText());
4743
+ break;
4744
+ default:
4745
+ console.error(`未知命令:${cmd}`);
4746
+ console.error(helpText());
4747
+ process.exitCode = 2;
4748
+ }
4749
+ }
4750
+ function helpText() {
4751
+ return `WeMind 多账号管理(wemind v${CLI_VERSION})
4489
4752
  用法: wemind <command> 其余:wemind version | help
4490
4753
 
4491
4754
  login [--label 名] [--start] 一条命令完成扫码登录:自刷新二维码 + 自动归档(--start 顺带启动 daemon)
@@ -4508,8 +4771,7 @@ async function runManage(argv) {
4508
4771
  allow <key|label> <userId> 授权某用户可驱动该账号 bot
4509
4772
  revoke <key|label> <userId> 撤销某用户的授权
4510
4773
 
4511
- 说明:key = 由微信 botId 派生的唯一标识(目录名);label = 人类可读别名。多数命令可用 key 或 label。`);
4512
- }
4774
+ 说明:key = 由微信 botId 派生的唯一标识(目录名);label = 人类可读别名。多数命令可用 key 或 label。`;
4513
4775
  }
4514
4776
  //#endregion
4515
4777
  //#region src/cli.ts