@unlinearity/cli-wechat-bridge 1.1.0 → 1.1.2

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,48 +1,70 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { execSync } from "node:child_process";
4
3
  import { CHANNEL_DATA_DIR, ensureChannelDataDir } from "../wechat/channel-config.js";
5
4
  const UPDATE_CHECK_FILE = path.join(CHANNEL_DATA_DIR, "update-check.json");
6
5
  const CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24小时
7
- /**
8
- * 使用 git 命令获取远程仓库最新版本号
9
- */
10
- export async function fetchLatestVersion() {
6
+ // npm registry 是权威更新源——它是用户实际安装的渠道;GitHub tags 作为回退,
7
+ // 以便在 registry 暂时不可达或包尚未发布时仍能给出最新版本。
8
+ const NPM_PACKAGE_NAME = "cli-wechat-bridge";
9
+ const DEFAULT_NPM_REGISTRY_URL = `https://registry.npmjs.org/${NPM_PACKAGE_NAME}/latest`;
10
+ const DEFAULT_GITHUB_TAGS_URL = "https://api.github.com/repos/UNLINEARITY/CLI-WeChat-Bridge/tags?per_page=20";
11
+ const FETCH_TIMEOUT_MS = 10_000;
12
+ function resolveFetchDeps(options) {
13
+ return {
14
+ fetch: options.fetchImpl ?? globalThis.fetch,
15
+ npmRegistryUrl: options.npmRegistryUrl ?? DEFAULT_NPM_REGISTRY_URL,
16
+ githubTagsUrl: options.githubTagsUrl ?? DEFAULT_GITHUB_TAGS_URL,
17
+ timeoutMs: options.timeoutMs ?? FETCH_TIMEOUT_MS,
18
+ };
19
+ }
20
+ async function fetchJsonWithTimeout(url, deps) {
21
+ const controller = new AbortController();
22
+ const timer = setTimeout(() => controller.abort(), deps.timeoutMs);
11
23
  try {
12
- // 尝试使用 git ls-remote 获取所有 tags
13
- const tagsOutput = execSync('git ls-remote --tags origin', {
14
- cwd: path.resolve(import.meta.dirname, "..", ".."),
15
- encoding: 'utf-8',
16
- stdio: ['ignore', 'pipe', 'ignore']
24
+ const res = await deps.fetch(url, {
25
+ method: "GET",
26
+ signal: controller.signal,
27
+ headers: { Accept: "application/json" },
17
28
  });
18
- if (!tagsOutput) {
19
- return null;
20
- }
21
- // 解析 tags,找到版本号格式(如 0.1.0, 0.2.0 等)
22
- const versionTags = tagsOutput
23
- .split('\n')
24
- .filter(line => {
25
- // 匹配 refs/tags/0.5.0 格式(不是 v0.5.0)
26
- return line.includes('refs/tags/') &&
27
- !line.includes('^{}') &&
28
- /^\w+\s+refs\/tags\/\d+\.\d+\.\d+$/.test(line);
29
- })
30
- .map(line => {
31
- const match = line.match(/refs\/tags\/(\d+\.\d+\.\d+)$/);
32
- return match ? match[1] : null;
33
- })
34
- .filter((v) => v !== null);
35
- if (versionTags.length === 0) {
29
+ if (!res.ok) {
36
30
  return null;
37
31
  }
38
- // 按版本号排序,返回最新的
39
- versionTags.sort((a, b) => compareVersions(b, a));
40
- return versionTags[0] ?? null;
32
+ return await res.json();
41
33
  }
42
- catch (error) {
43
- // 如果 git 命令失败,静默返回 null
34
+ catch {
35
+ // 网络/超时/解析失败一律视为该源不可用,交由调用方回退。
36
+ return null;
37
+ }
38
+ finally {
39
+ clearTimeout(timer);
40
+ }
41
+ }
42
+ async function fetchVersionFromNpm(deps) {
43
+ const data = await fetchJsonWithTimeout(deps.npmRegistryUrl, deps);
44
+ if (!data || typeof data !== "object") {
44
45
  return null;
45
46
  }
47
+ const version = data.version;
48
+ return typeof version === "string" ? parseVersion(version) : null;
49
+ }
50
+ async function fetchVersionFromGithub(deps) {
51
+ const data = await fetchJsonWithTimeout(deps.githubTagsUrl, deps);
52
+ if (!Array.isArray(data)) {
53
+ return null;
54
+ }
55
+ const versions = data
56
+ .map((tag) => parseVersion(tag.name))
57
+ .filter((v) => v !== null)
58
+ .sort((a, b) => compareVersions(b, a));
59
+ return versions[0] ?? null;
60
+ }
61
+ /**
62
+ * 从 npm registry 获取最新版本号(回退到 GitHub tags)。
63
+ * 通过 HTTPS 请求而非本地 git 命令,因此对全局安装的命令(wechat-check-update)同样可用。
64
+ */
65
+ export async function fetchLatestVersion(options = {}) {
66
+ const deps = resolveFetchDeps(options);
67
+ return (await fetchVersionFromNpm(deps)) ?? (await fetchVersionFromGithub(deps));
46
68
  }
47
69
  /**
48
70
  * 从本地 package.json 读取当前版本
@@ -85,6 +107,17 @@ function writeUpdateCache(cache) {
85
107
  // 静默失败,不影响正常使用
86
108
  }
87
109
  }
110
+ const VERSION_PATTERN = /(\d+\.\d+\.\d+)/;
111
+ /**
112
+ * 从任意字符串中提取版本号,兼容带 "v" 前缀的 tag(如 "v1.1.1")。
113
+ */
114
+ export function parseVersion(raw) {
115
+ if (typeof raw !== "string") {
116
+ return null;
117
+ }
118
+ const match = VERSION_PATTERN.exec(raw.trim());
119
+ return match ? (match[1] ?? null) : null;
120
+ }
88
121
  /**
89
122
  * 比较两个版本号
90
123
  * @returns 返回值 > 0 表示 version1 更新,< 0 表示 version2 更新,= 0 表示相等
@@ -146,16 +179,16 @@ export async function checkForUpdate(forceCheck = false) {
146
179
  */
147
180
  export function formatUpdateMessage(versionInfo) {
148
181
  const { current, latest } = versionInfo;
149
- return `
150
- [Update Available] Version ${latest} is available (current: ${current})
151
-
152
- Update instructions:
153
- cd CLI-WeChat-Bridge
154
- git pull
155
- bun install
156
- npm install -g .
157
-
158
- For more information:
159
- https://github.com/UNLINEARITY/CLI-WeChat-Bridge/releases
182
+ return `
183
+ [Update Available] Version ${latest} is available (current: ${current})
184
+
185
+ Update instructions:
186
+ cd CLI-WeChat-Bridge
187
+ git pull
188
+ bun install
189
+ npm install -g .
190
+
191
+ For more information:
192
+ https://github.com/UNLINEARITY/CLI-WeChat-Bridge/releases
160
193
  `;
161
194
  }
@@ -18,6 +18,34 @@ export const SYNC_BUF_FILE = path.join(CHANNEL_DATA_DIR, "sync_buf.txt");
18
18
  export const CONTEXT_CACHE_FILE = path.join(CHANNEL_DATA_DIR, "context_tokens.json");
19
19
  export const BRIDGE_STATE_FILE = path.join(CHANNEL_DATA_DIR, "bridge-state.json");
20
20
  export const BRIDGE_LOG_FILE = path.join(CHANNEL_DATA_DIR, "bridge.log");
21
+ // Hard cap for bridge.log. Long-running daemons otherwise grow it without bound.
22
+ export const BRIDGE_LOG_MAX_BYTES = 5 * 1024 * 1024; // 5 MiB
23
+ /**
24
+ * Append a line to a log file, trimming it down to the tail half whenever it
25
+ * exceeds BRIDGE_LOG_MAX_BYTES. Trimming is best-effort under concurrent
26
+ * writers (a few lines may be lost during a trim) but keeps the file bounded.
27
+ */
28
+ export function appendBoundedLog(filePath, line) {
29
+ try {
30
+ const stat = fs.statSync(filePath);
31
+ if (stat.size > BRIDGE_LOG_MAX_BYTES) {
32
+ const keepSize = Math.floor(BRIDGE_LOG_MAX_BYTES / 2);
33
+ const fd = fs.openSync(filePath, "r");
34
+ try {
35
+ const tail = Buffer.alloc(keepSize);
36
+ const bytesRead = fs.readSync(fd, tail, 0, keepSize, stat.size - keepSize);
37
+ fs.writeFileSync(filePath, tail.subarray(0, bytesRead));
38
+ }
39
+ finally {
40
+ fs.closeSync(fd);
41
+ }
42
+ }
43
+ }
44
+ catch {
45
+ // File missing or unreadable: fall through to a plain append.
46
+ }
47
+ fs.appendFileSync(filePath, line);
48
+ }
21
49
  export const BRIDGE_LOCK_FILE = path.join(CHANNEL_DATA_DIR, "bridge.lock.json");
22
50
  export const DAEMON_ENDPOINT_FILE = path.join(CHANNEL_DATA_DIR, "daemon-endpoint.json");
23
51
  export const CODEX_PANEL_ENDPOINT_FILE = path.join(CHANNEL_DATA_DIR, "codex-panel-endpoint.json");
@@ -306,7 +306,12 @@ export function classifyWechatTransportError(error) {
306
306
  }
307
307
  function writeJsonFile(filePath, value) {
308
308
  ensureChannelDataDir();
309
- fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf-8");
309
+ const data = JSON.stringify(value, null, 2);
310
+ // Atomic write via temp file + rename, so a crash mid-write cannot leave a
311
+ // truncated/half-written JSON that would break readers on the next launch.
312
+ const tempPath = `${filePath}.${process.pid}.tmp`;
313
+ fs.writeFileSync(tempPath, data, "utf-8");
314
+ fs.renameSync(tempPath, filePath);
310
315
  }
311
316
  function randomWechatUin() {
312
317
  const uint32 = crypto.randomBytes(4).readUInt32BE(0);
@@ -445,7 +450,15 @@ async function getUploadUrl(account, params) {
445
450
  token: account.token,
446
451
  timeoutMs: SEND_TIMEOUT_MS,
447
452
  });
448
- return JSON.parse(raw);
453
+ try {
454
+ return JSON.parse(raw);
455
+ }
456
+ catch (error) {
457
+ // A 200-OK but non-JSON body (e.g. an HTML error page, empty body) would
458
+ // otherwise throw an unclassified SyntaxError that bypasses transport error
459
+ // classification/retry. Surface it as a meaningful error instead.
460
+ throw new Error(`getUploadUrl returned a non-JSON response (${raw.length} bytes): ${String(error)}`, { cause: error });
461
+ }
449
462
  }
450
463
  function buildCdnUploadUrl(cdnBaseUrl, uploadParam, filekey) {
451
464
  return `${cdnBaseUrl}/upload?encrypted_query_param=${encodeURIComponent(uploadParam)}&filekey=${encodeURIComponent(filekey)}`;
@@ -678,11 +691,25 @@ export function buildInboundMessageClaimPath(messageKey, claimsDir = INBOUND_MES
678
691
  return path.join(claimsDir, fileName);
679
692
  }
680
693
  export function clearInboundMessageClaims(claimsDir = INBOUND_MESSAGE_CLAIMS_DIR) {
694
+ // Delete claim files individually from a directory snapshot rather than
695
+ // `rmSync(claimsDir, { recursive })`. A recursive rmdir can remove a file a
696
+ // concurrent process just created via tryClaimInboundMessage, silently
697
+ // invalidating its claim and letting the same message be double-processed.
698
+ // Per-file removal only deletes files present at snapshot time.
699
+ let entries;
681
700
  try {
682
- fs.rmSync(claimsDir, { recursive: true, force: true });
701
+ entries = fs.readdirSync(claimsDir);
683
702
  }
684
703
  catch {
685
- // Best effort cleanup.
704
+ return; // directory does not exist; nothing to clear
705
+ }
706
+ for (const entry of entries) {
707
+ try {
708
+ fs.rmSync(path.join(claimsDir, entry), { force: true });
709
+ }
710
+ catch {
711
+ // best effort
712
+ }
686
713
  }
687
714
  }
688
715
  export function tryClaimInboundMessage(messageKey, options = {}) {
@@ -719,7 +746,11 @@ export function tryClaimInboundMessage(messageKey, options = {}) {
719
746
  ? error.code
720
747
  : "";
721
748
  if (code !== "EEXIST") {
722
- return true;
749
+ // Real persistence failure (permissions, disk full, missing dir, ...).
750
+ // Returning true here would claim success WITHOUT writing the claim file,
751
+ // letting concurrent processes all think they own this message and
752
+ // double-process it. Fail closed so the message is retried instead.
753
+ return false;
723
754
  }
724
755
  }
725
756
  try {
@@ -849,23 +880,28 @@ export class WeChatTransport {
849
880
  if (!extracted.text && extracted.attachments.length === 0) {
850
881
  continue;
851
882
  }
852
- const messageKey = buildMessageKey(rawMessage);
853
- if (!this.rememberMessage(messageKey)) {
854
- continue;
855
- }
856
- if (!tryClaimInboundMessage(buildScopedMessageClaimKey(account.accountId, messageKey))) {
857
- continue;
858
- }
859
883
  const senderId = rawMessage.from_user_id ?? "unknown";
860
884
  if (rawMessage.context_token) {
861
885
  this.cacheContextToken(senderId, rawMessage.context_token);
862
886
  }
887
+ // Filter pre-start backlog BEFORE claiming/remembering the message so a
888
+ // skipped message is not permanently locked away from other processes.
889
+ // A missing create_time_ms is treated as fresh rather than dropped.
863
890
  const createdAtMs = rawMessage.create_time_ms ?? 0;
864
891
  if (typeof options.minCreatedAtMs === "number" &&
865
- (!Number.isFinite(createdAtMs) || createdAtMs < options.minCreatedAtMs)) {
892
+ Number.isFinite(createdAtMs) &&
893
+ createdAtMs > 0 &&
894
+ createdAtMs < options.minCreatedAtMs) {
866
895
  ignoredBacklogCount += 1;
867
896
  continue;
868
897
  }
898
+ const messageKey = buildMessageKey(rawMessage);
899
+ if (!this.rememberMessage(messageKey)) {
900
+ continue;
901
+ }
902
+ if (!tryClaimInboundMessage(buildScopedMessageClaimKey(account.accountId, messageKey))) {
903
+ continue;
904
+ }
869
905
  const { attachments, failureLines } = await this.downloadInboundAttachments(extracted.attachments, rawMessage);
870
906
  const text = appendInboundAttachmentFailureText(extracted.text, failureLines);
871
907
  messages.push({
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@unlinearity/cli-wechat-bridge",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "CLI WeChat Bridge for Codex, Claude Code, OpenCode, and PowerShell over WeChat",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "bin/",
8
8
  "dist/",
9
+ "scripts/ensure-node-pty-permissions.mjs",
9
10
  "README.md",
10
11
  "LICENSE.txt"
11
12
  ],