weflow-cli 1.16.0 → 1.19.0

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.
Files changed (81) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +19 -4
  3. package/bin/weflow-cli.ts +515 -6
  4. package/dist/bin/weflow-cli.js +580 -8
  5. package/dist/bin/weflow-cli.js.map +1 -1
  6. package/dist/src/core/ntCore.d.ts +47 -0
  7. package/dist/src/core/ntCore.d.ts.map +1 -1
  8. package/dist/src/core/ntCore.js +24 -0
  9. package/dist/src/core/ntCore.js.map +1 -1
  10. package/dist/src/services/assistantService.d.ts +8 -1
  11. package/dist/src/services/assistantService.d.ts.map +1 -1
  12. package/dist/src/services/assistantService.js +47 -8
  13. package/dist/src/services/assistantService.js.map +1 -1
  14. package/dist/src/services/assistantTools.d.ts +8 -0
  15. package/dist/src/services/assistantTools.d.ts.map +1 -1
  16. package/dist/src/services/assistantTools.js +67 -11
  17. package/dist/src/services/assistantTools.js.map +1 -1
  18. package/dist/src/services/configService.d.ts.map +1 -1
  19. package/dist/src/services/configService.js +3 -2
  20. package/dist/src/services/configService.js.map +1 -1
  21. package/dist/src/services/cursorProbe.d.ts +44 -0
  22. package/dist/src/services/cursorProbe.d.ts.map +1 -0
  23. package/dist/src/services/cursorProbe.js +183 -0
  24. package/dist/src/services/cursorProbe.js.map +1 -0
  25. package/dist/src/services/diagnosticReport.d.ts +6 -0
  26. package/dist/src/services/diagnosticReport.d.ts.map +1 -1
  27. package/dist/src/services/diagnosticReport.js +16 -0
  28. package/dist/src/services/diagnosticReport.js.map +1 -1
  29. package/dist/src/services/qchatImport.d.ts +89 -0
  30. package/dist/src/services/qchatImport.d.ts.map +1 -0
  31. package/dist/src/services/qchatImport.js +273 -0
  32. package/dist/src/services/qchatImport.js.map +1 -0
  33. package/dist/src/services/setupVenv.d.ts +43 -0
  34. package/dist/src/services/setupVenv.d.ts.map +1 -0
  35. package/dist/src/services/setupVenv.js +198 -0
  36. package/dist/src/services/setupVenv.js.map +1 -0
  37. package/dist/src/services/wechatCompat.d.ts +29 -0
  38. package/dist/src/services/wechatCompat.d.ts.map +1 -0
  39. package/dist/src/services/wechatCompat.js +65 -0
  40. package/dist/src/services/wechatCompat.js.map +1 -0
  41. package/dist/src/utils/python.d.ts +10 -0
  42. package/dist/src/utils/python.d.ts.map +1 -1
  43. package/dist/src/utils/python.js +27 -4
  44. package/dist/src/utils/python.js.map +1 -1
  45. package/docs/DATA_CONTRACT.md +25 -0
  46. package/docs/DECISIONS.md +118 -30
  47. package/docs/NT_DATABASES.md +47 -0
  48. package/docs/PROJECT_STATE.md +7 -3
  49. package/docs/RELEASING.md +4 -0
  50. package/docs/REPLAY.md +169 -0
  51. package/docs/SYNC_CONTRACT.md +197 -180
  52. package/package.json +89 -88
  53. package/resources/js/graph3d/viewer2d.js +550 -448
  54. package/scripts/annual_report.py +3 -1
  55. package/scripts/chat_notes.py +86 -3
  56. package/scripts/check_readme_commands.py +427 -426
  57. package/scripts/extract_todos.py +3 -1
  58. package/scripts/graph_2d.py +156 -151
  59. package/scripts/graph_3d.py +672 -646
  60. package/scripts/imported_chats.py +68 -0
  61. package/scripts/mcp_bridge.py +3 -1
  62. package/scripts/media_index.py +150 -0
  63. package/scripts/nt_common.py +32 -0
  64. package/scripts/nt_decrypt.py +154 -16
  65. package/scripts/on_this_day.py +178 -178
  66. package/scripts/promises.py +185 -185
  67. package/scripts/replay.py +803 -0
  68. package/scripts/run_hidden.vbs +25 -0
  69. package/scripts/semantic_search.py +78 -6
  70. package/scripts/wechat_avatar.py +185 -0
  71. package/scripts/wechat_ledger.py +190 -0
  72. package/src/core/ntCore.ts +59 -0
  73. package/src/services/assistantService.ts +48 -8
  74. package/src/services/assistantTools.ts +73 -10
  75. package/src/services/configService.ts +3 -2
  76. package/src/services/cursorProbe.ts +222 -0
  77. package/src/services/diagnosticReport.ts +22 -0
  78. package/src/services/qchatImport.ts +327 -0
  79. package/src/services/setupVenv.ts +238 -0
  80. package/src/services/wechatCompat.ts +67 -0
  81. package/src/utils/python.ts +33 -4
@@ -0,0 +1,327 @@
1
+ /**
2
+ * `weflow-cli import qchat` —— 读 qchat-cli 的导出文件,归一化落进本地存储(D-129)。
3
+ *
4
+ * 边界是**文件契约**,不是进程耦合:qchat-cli 一行不用改,我们读它 `qce export --format json`
5
+ * 产出的那份 JSON(形状见 `parseQchatExport`)。落盘后的存储(`output/imports/qq/sessions/`)
6
+ * 是 weflow 这边的私有形状 `weflow-import/qq-v1`,字段语义与 `weflow-message/v1` 对齐
7
+ * (serverId/createTime/parsedContent/rawContent/13 个稳定 messageType)——一套词汇表两边用。
8
+ *
9
+ * QQ 的 `messageId` 是服务端全局唯一(OneBot 语义),所以去重/合并就靠它,不需要微信侧那套
10
+ * 重叠时间窗;这一点对 D-027 也成立:qq 来源天然满足 stable-identity 判据。
11
+ *
12
+ * 隐私边界:导入的是用户本人授权的 QQ 数据(qchat-cli 侧靠 NapCatQQ/OneBot 授权),落盘后
13
+ * 继承 weflow 的纪律——QQ 号与 wxid 同级敏感,不进公开输出;下游管道(搜索/知识卡)各自
14
+ * 已有的隐私门对它照常生效。
15
+ */
16
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'fs'
17
+ import { join } from 'path'
18
+ import { resolvePackageRoot } from '../utils/packageRoot.js'
19
+
20
+ /** 存储根。`WEFLOW_IMPORT_ROOT` 可整体指到别处(测试用,Python 侧读同一个变量)。 */
21
+ export function importRoot(): string {
22
+ if (process.env.WEFLOW_IMPORT_ROOT) return process.env.WEFLOW_IMPORT_ROOT
23
+ return join(resolvePackageRoot(import.meta.url), 'output', 'imports', 'qq')
24
+ }
25
+
26
+ function sessionsDir(): string {
27
+ return join(importRoot(), 'sessions')
28
+ }
29
+
30
+ export const IMPORT_SCHEMA = 'weflow-import/qq-v1'
31
+
32
+ /** 与 weflow-message/v1 的 messageType 同一张表(13 个稳定名)。 */
33
+ export type MessageType =
34
+ | 'text' | 'image' | 'voice' | 'card' | 'video' | 'emoji' | 'location'
35
+ | 'link' | 'call' | 'system' | 'quote' | 'other'
36
+
37
+ export interface ImportedQqMessage {
38
+ /** qchat 的 messageId——全局唯一,去重/合并就靠它 */
39
+ serverId: string
40
+ messageSeq?: number
41
+ /** unix 秒(qchat 的 timestamp 字段) */
42
+ createTime: number
43
+ /** 仅当导入时给了 --own-id 才有;不给就是未知,消费方按"未知"处理而不是猜 */
44
+ isSend?: boolean
45
+ /** 发送者 QQ 号(敏感,同 wxid 级) */
46
+ senderUsername: string
47
+ /** 群名片优先,其次昵称,最后落回 QQ 号 */
48
+ senderName: string
49
+ messageType: MessageType
50
+ /** qchat 的 content(已拍平的文本) */
51
+ parsedContent: string
52
+ /** qchat 的 rawMessage */
53
+ rawContent: string
54
+ }
55
+
56
+ export interface ImportedQqSessionFile {
57
+ schema: typeof IMPORT_SCHEMA
58
+ session: { id: string; name: string; type: string }
59
+ importedAt: string
60
+ sourceFiles: string[]
61
+ messages: ImportedQqMessage[]
62
+ }
63
+
64
+ export interface QchatFilePlan {
65
+ file: string
66
+ ok: boolean
67
+ reason?: string
68
+ sessions: Array<{ id: string; name: string; type: string; messages: number }>
69
+ }
70
+
71
+ export interface ImportPlan {
72
+ files: QchatFilePlan[]
73
+ sessionsFound: number
74
+ messagesTotal: number
75
+ storeRoot: string
76
+ }
77
+
78
+ export interface ImportOutcome {
79
+ success: boolean
80
+ sessions: Array<{ id: string; name: string; added: number; updated: number; unchanged: number; total: number }>
81
+ filesOk: number
82
+ filesSkipped: number
83
+ storeRoot: string
84
+ }
85
+
86
+ /** OneBot 消息段 → weflow 的 13 个稳定类型。纯函数,映射表只有这一份。 */
87
+ export function qqSegmentMessageType(segments: unknown[]): MessageType {
88
+ if (!Array.isArray(segments) || !segments.length) return 'text'
89
+ const priority: Array<[string, MessageType]> = [
90
+ ['record', 'voice'],
91
+ ['video', 'video'],
92
+ ['image', 'image'],
93
+ ['reply', 'quote'],
94
+ ['location', 'location'],
95
+ ['json', 'card'],
96
+ ['xml', 'card'],
97
+ ['contact', 'card'],
98
+ ['file', 'card'],
99
+ ['face', 'emoji'],
100
+ ]
101
+ const kinds = new Set(segments.map(s => String((s as any)?.type || '')))
102
+ for (const [seg, type] of priority) {
103
+ if (kinds.has(seg)) return type
104
+ }
105
+ return 'text'
106
+ }
107
+
108
+ /**
109
+ * qchat 导出 JSON 的形状识别与解析。判据是结构性的:顶层 `session` 对象 + `messages`
110
+ * 数组、且消息带 `messageId` 与 `timestamp`——认不出来就报错并给出原因,绝不猜。
111
+ */
112
+ export function parseQchatExport(text: string, file: string): QchatFilePlan {
113
+ const fail = (reason: string): QchatFilePlan => ({
114
+ file, ok: false, reason, sessions: [],
115
+ })
116
+ let data: any
117
+ try {
118
+ data = JSON.parse(text)
119
+ } catch (e: any) {
120
+ return fail(`不是合法 JSON(${String(e?.message || e).slice(0, 80)})`)
121
+ }
122
+ const session = data?.session
123
+ const messages = data?.messages
124
+ if (!session || typeof session !== 'object' || !Array.isArray(messages)) {
125
+ return fail('不是 qchat 导出形状(缺 session/messages;应为 `qce export --format json` 的产物)')
126
+ }
127
+ const id = String(session.id ?? '')
128
+ if (!id) return fail('session.id 为空')
129
+ let count = 0
130
+ for (const m of messages) {
131
+ if (m && (m.messageId !== undefined) && (m.timestamp !== undefined)) count += 1
132
+ }
133
+ if (!count && messages.length) {
134
+ return fail(`${messages.length} 条消息里没有一条带 messageId+timestamp——大概是别的格式的 JSON`)
135
+ }
136
+ return {
137
+ file, ok: true,
138
+ sessions: [{
139
+ id,
140
+ name: String(session.name || id),
141
+ type: String(session.type || 'private'),
142
+ messages: messages.length,
143
+ }],
144
+ }
145
+ }
146
+
147
+ /** 一条 qchat 消息 → 存储形状。字段映射只有这一处。 */
148
+ export function normalizeQchatMessage(raw: any, ownId?: string): ImportedQqMessage | null {
149
+ if (!raw || raw.messageId === undefined || raw.timestamp === undefined) return null
150
+ const senderId = String(raw?.sender?.id ?? '')
151
+ const senderName = String(raw?.sender?.card || raw?.sender?.name || senderId)
152
+ const msg: ImportedQqMessage = {
153
+ serverId: String(raw.messageId),
154
+ ...(raw.message_seq !== undefined ? { messageSeq: Number(raw.message_seq) } : {}),
155
+ createTime: Number(raw.timestamp),
156
+ ...(ownId ? { isSend: senderId === ownId } : {}),
157
+ senderUsername: senderId,
158
+ senderName,
159
+ messageType: qqSegmentMessageType(raw.message),
160
+ parsedContent: String(raw.content ?? ''),
161
+ rawContent: String(raw.rawMessage ?? ''),
162
+ }
163
+ return msg
164
+ }
165
+
166
+ function safeSessionFile(id: string): string {
167
+ const safe = id.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80)
168
+ return `${safe || 'session'}.json`
169
+ }
170
+
171
+ function readStoreSession(id: string): ImportedQqSessionFile | null {
172
+ const path = join(sessionsDir(), safeSessionFile(id))
173
+ if (!existsSync(path)) return null
174
+ try {
175
+ const data = JSON.parse(readFileSync(path, 'utf-8'))
176
+ if (data?.schema === IMPORT_SCHEMA && Array.isArray(data?.messages)) return data
177
+ } catch { /* 损坏的存储文件按"没有"处理,重导入会覆盖修复 */ }
178
+ return null
179
+ }
180
+
181
+ /** 收集输入路径下的候选文件(单文件或目录里的 *.json)。 */
182
+ function candidateFiles(input: string): string[] {
183
+ if (!existsSync(input)) return []
184
+ // readdirSync 只对目录成立:文件会抛,正好当探测用
185
+ try {
186
+ const entries = readdirSync(input)
187
+ return entries.filter(f => f.toLowerCase().endsWith('.json'))
188
+ .map(f => join(input, f))
189
+ } catch {
190
+ return [input]
191
+ }
192
+ }
193
+
194
+ export function planImportQchat(input: string): ImportPlan {
195
+ const files = candidateFiles(input)
196
+ const plans: QchatFilePlan[] = []
197
+ if (!files.length) {
198
+ return {
199
+ files: [{ file: input, ok: false, reason: '输入不存在,或目录里没有 .json 文件', sessions: [] }],
200
+ sessionsFound: 0, messagesTotal: 0, storeRoot: sessionsDir(),
201
+ }
202
+ }
203
+ for (const file of files) {
204
+ let plan: QchatFilePlan
205
+ try {
206
+ plan = parseQchatExport(readFileSync(file, 'utf-8'), file)
207
+ } catch (e: any) {
208
+ plan = { file, ok: false, reason: `读不了(${String(e?.message || e).slice(0, 80)})`, sessions: [] }
209
+ }
210
+ plans.push(plan)
211
+ }
212
+ const sessionIds = new Set(plans.flatMap(p => p.ok ? p.sessions.map(s => s.id) : []))
213
+ const messagesTotal = plans.flatMap(p => p.sessions).reduce((n, s) => n + s.messages, 0)
214
+ return {
215
+ files: plans,
216
+ sessionsFound: sessionIds.size,
217
+ messagesTotal,
218
+ storeRoot: sessionsDir(),
219
+ }
220
+ }
221
+
222
+ export function runImportQchat(input: string, ownId?: string): ImportOutcome {
223
+ const plan = planImportQchat(input)
224
+ const okFiles = plan.files.filter(f => f.ok)
225
+ if (!okFiles.length) {
226
+ return { success: false, sessions: [], filesOk: 0, filesSkipped: plan.files.length, storeRoot: plan.storeRoot }
227
+ }
228
+ mkdirSync(sessionsDir(), { recursive: true })
229
+ const merged = new Map<string, { existing: ImportedQqSessionFile | null; byId: Map<string, ImportedQqMessage>; sourceFiles: Set<string>; meta: { id: string; name: string; type: string } }>()
230
+ for (const f of okFiles) {
231
+ let data: any
232
+ try {
233
+ data = JSON.parse(readFileSync(f.file, 'utf-8'))
234
+ } catch {
235
+ continue // plan 已验证过可解析;这里的失败按跳过处理(计数在 filesSkipped)
236
+ }
237
+ const id = String(data.session.id)
238
+ let entry = merged.get(id)
239
+ if (!entry) {
240
+ entry = {
241
+ existing: readStoreSession(id),
242
+ byId: new Map((readStoreSession(id)?.messages ?? []).map(m => [m.serverId, m])),
243
+ sourceFiles: new Set(readStoreSession(id)?.sourceFiles ?? []),
244
+ meta: { id, name: String(data.session.name || id), type: String(data.session.type || 'private') },
245
+ }
246
+ merged.set(id, entry)
247
+ }
248
+ entry.sourceFiles.add(f.file)
249
+ for (const raw of data.messages ?? []) {
250
+ const msg = normalizeQchatMessage(raw, ownId)
251
+ if (msg) entry.byId.set(msg.serverId, msg)
252
+ }
253
+ }
254
+
255
+ const sessions = []
256
+ for (const [id, entry] of merged) {
257
+ const existing = entry.existing
258
+ const before = new Map((existing?.messages ?? []).map(m => [m.serverId, m]))
259
+ const messages = [...entry.byId.values()].sort((a, b) => a.createTime - b.createTime || a.serverId.localeCompare(b.serverId))
260
+ const file: ImportedQqSessionFile = {
261
+ schema: IMPORT_SCHEMA,
262
+ session: entry.meta,
263
+ importedAt: new Date().toISOString(),
264
+ sourceFiles: [...entry.sourceFiles],
265
+ messages,
266
+ }
267
+ writeFileSync(join(sessionsDir(), safeSessionFile(id)), JSON.stringify(file, null, 1), 'utf-8')
268
+ let added = 0, updated = 0, unchanged = 0
269
+ for (const m of messages) {
270
+ const old = before.get(m.serverId)
271
+ if (!old) added += 1
272
+ else if (JSON.stringify(old) !== JSON.stringify(m)) updated += 1
273
+ else unchanged += 1
274
+ }
275
+ sessions.push({ id: entry.meta.id, name: entry.meta.name, added, updated, unchanged, total: messages.length })
276
+ }
277
+
278
+ return {
279
+ success: true,
280
+ sessions,
281
+ filesOk: okFiles.length,
282
+ filesSkipped: plan.files.length - okFiles.length,
283
+ storeRoot: plan.storeRoot,
284
+ }
285
+ }
286
+
287
+ export interface ImportListEntry {
288
+ id: string
289
+ name: string
290
+ type: string
291
+ messages: number
292
+ oldestCreateTime: number | null
293
+ newestCreateTime: number | null
294
+ importedAt: string
295
+ }
296
+
297
+ export function listImports(): ImportListEntry[] {
298
+ const dir = sessionsDir()
299
+ if (!existsSync(dir)) return []
300
+ const out: ImportListEntry[] = []
301
+ for (const f of readdirSync(dir)) {
302
+ if (!f.endsWith('.json')) continue
303
+ try {
304
+ const data: any = JSON.parse(readFileSync(join(dir, f), 'utf-8'))
305
+ if (data?.schema !== IMPORT_SCHEMA) continue
306
+ const times = (data.messages ?? []).map((m: any) => Number(m.createTime) || 0).filter(Boolean)
307
+ out.push({
308
+ id: String(data.session?.id ?? ''),
309
+ name: String(data.session?.name ?? ''),
310
+ type: String(data.session?.type ?? ''),
311
+ messages: (data.messages ?? []).length,
312
+ oldestCreateTime: times.length ? Math.min(...times) : null,
313
+ newestCreateTime: times.length ? Math.max(...times) : null,
314
+ importedAt: String(data.importedAt ?? ''),
315
+ })
316
+ } catch { /* 单个损坏文件不挡住清单 */ }
317
+ }
318
+ return out.sort((a, b) => b.newestCreateTime! - a.newestCreateTime!)
319
+ }
320
+
321
+ /** 读回一个会话(验证导入用)。limit=0 是全部。 */
322
+ export function readImportedSession(id: string, limit = 0): ImportedQqSessionFile | null {
323
+ const data = readStoreSession(id)
324
+ if (!data) return null
325
+ if (limit > 0) return { ...data, messages: data.messages.slice(-limit) }
326
+ return data
327
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * `weflow-cli setup` —— 自管 Python venv。
3
+ *
4
+ * Issues 里最大的两类环境噪音:PowerShell 执行策略拦 `.ps1`(.cmd 包装绕开),
5
+ * 和 PATH 上只有微软商店的 python 占位程序(一跑 exit 9009,`getPythonCommand()`
6
+ * 兜底返回裸名 `python` 时正好交给它)。后者真正的根因是"这台机器没有一个能
7
+ * `import sqlcipher3` 的 Python"——本命令一次性解决:在 `~/.weflow-cli/venv`
8
+ * 建一个 venv、装上 requirements,之后 `getPythonCommand()` 优先用它。
9
+ *
10
+ * Windows 上 `sqlcipher3` 被替换为 `sqlcipher3-wheels`(预编译轮,import 名
11
+ * 相同):源码包要 OpenSSL 头文件 + MSVC,正是要消掉的那类支持负担。替换会
12
+ * 写进计划与输出,不藏在背后。
13
+ */
14
+ import { execFile } from 'child_process'
15
+ import { promisify } from 'util'
16
+ import { existsSync, readFileSync, rmSync } from 'fs'
17
+ import { basename, join } from 'path'
18
+ import { homedir } from 'os'
19
+ import { resolvePackageRoot } from '../utils/packageRoot.js'
20
+ import { clearPythonCommandCache, getPythonCommand } from '../utils/python.js'
21
+
22
+ const execFileAsync = promisify(execFile)
23
+
24
+ export const VENV_DIR = join(homedir(), '.weflow-cli', 'venv')
25
+
26
+ export interface SetupPlan {
27
+ venvDir: string
28
+ venvPython: string
29
+ /** 用来建 venv 的解释器;null = 这台机器连可运行的 Python 都没有 */
30
+ creator: string | null
31
+ recreate: boolean
32
+ requirementFiles: string[]
33
+ /** 解析后的安装清单(注释/空行已去掉,win32 的替换已做) */
34
+ packages: string[]
35
+ /** 替换说明,如 `sqlcipher3 -> sqlcipher3-binary (Windows 预编译轮)` */
36
+ substitutions: string[]
37
+ createsUnder: '~/.weflow-cli/venv'
38
+ network: true
39
+ }
40
+
41
+ export interface SetupStep {
42
+ name: string
43
+ ok: boolean
44
+ /** 一步的补充事实(版本号、跳过原因等),不放大段日志 */
45
+ detail?: string
46
+ }
47
+
48
+ export interface SetupResult {
49
+ success: boolean
50
+ steps: SetupStep[]
51
+ venvPython: string
52
+ /** 安装完清缓存后 getPythonCommand() 的实际选择——应等于 venvPython */
53
+ effectivePython: string | null
54
+ error?: string
55
+ }
56
+
57
+ /** requirements 文件 → 去注释/空行的依赖列表。 */
58
+ export function parseRequirements(text: string): string[] {
59
+ return text
60
+ .split('\n')
61
+ .map(line => line.trim())
62
+ .filter(line => line && !line.startsWith('#'))
63
+ }
64
+
65
+ /** win32 下把源码包换成预编译轮;其余平台原样。 */
66
+ export function resolvePackages(names: string[], platform: string): { packages: string[]; substitutions: string[] } {
67
+ if (platform !== 'win32') return { packages: [...names], substitutions: [] }
68
+ // Windows 上的预编译轮是 `sqlcipher3-wheels`(cp310+ 的 win32/amd64/arm64 都有)。
69
+ // `sqlcipher3-binary` 只有 Linux/macOS 轮——CI 的 ubuntu 能装它,Windows 上
70
+ // pip 直接报 "No matching distribution"(2026-10-23 实测)。
71
+ const packages = names.map(n => (n === 'sqlcipher3' ? 'sqlcipher3-wheels' : n))
72
+ const substitutions = packages.some(p => p === 'sqlcipher3-wheels') && names.some(n => n === 'sqlcipher3')
73
+ ? ['sqlcipher3 -> sqlcipher3-wheels (Windows 预编译轮,import 名相同;源码包要 OpenSSL 头文件 + MSVC)']
74
+ : []
75
+ return { packages, substitutions }
76
+ }
77
+
78
+ /**
79
+ * 建一个"能用来创建 venv"的解释器探测:PATH 上的 python/py 逐个 `--version`。
80
+ * 商店占位程序在这一步就会被排掉(`--version` 以 9009 退出)。
81
+ */
82
+ async function findCreator(): Promise<{ creator: string | null; version: string | null }> {
83
+ const names = process.platform === 'win32'
84
+ ? ['python', 'python3', 'py']
85
+ : ['python3', 'python']
86
+ for (const name of names) {
87
+ try {
88
+ const { stdout } = await execFileAsync(name, ['--version'], { windowsHide: true, timeout: 10_000 })
89
+ const version = stdout.trim().split('\n')[0] || null
90
+ return { creator: name, version }
91
+ } catch {
92
+ // 这个名字跑不起来(商店占位程序 9009 也会落到这里),试下一个
93
+ }
94
+ }
95
+ return { creator: null, version: null }
96
+ }
97
+
98
+ export async function planSetup(opts: {
99
+ recreate?: boolean
100
+ withVoice?: boolean
101
+ with3x?: boolean
102
+ } = {}): Promise<SetupPlan> {
103
+ const pkgRoot = resolvePackageRoot(import.meta.url)
104
+ const files = ['requirements.txt']
105
+ if (opts.with3x) files.push('requirements-3x.txt')
106
+ if (opts.withVoice) files.push('requirements-voice.txt')
107
+
108
+ const names: string[] = []
109
+ for (const f of files) {
110
+ names.push(...parseRequirements(readFileSync(join(pkgRoot, f), 'utf-8')))
111
+ }
112
+ const { packages, substitutions } = resolvePackages([...new Set(names)], process.platform)
113
+ const { creator } = await findCreator()
114
+ const venvPython = process.platform === 'win32'
115
+ ? join(VENV_DIR, 'Scripts', 'python.exe')
116
+ : join(VENV_DIR, 'bin', 'python')
117
+
118
+ return {
119
+ venvDir: VENV_DIR,
120
+ venvPython,
121
+ creator,
122
+ recreate: !!opts.recreate,
123
+ requirementFiles: files,
124
+ packages,
125
+ substitutions,
126
+ createsUnder: '~/.weflow-cli/venv',
127
+ network: true,
128
+ }
129
+ }
130
+
131
+ export async function runSetup(plan: SetupPlan, onLine?: (line: string) => void): Promise<SetupResult> {
132
+ const steps: SetupStep[] = []
133
+ const say = (line: string) => onLine?.(line)
134
+
135
+ if (!plan.creator) {
136
+ return {
137
+ success: false,
138
+ steps: [{ name: 'find-python', ok: false, detail: 'PATH 上没有可运行的 Python(商店占位程序不算)——先装一个真正的 Python 3.10+ 再运行 setup' }],
139
+ venvPython: plan.venvPython,
140
+ effectivePython: null,
141
+ error: 'NO_USABLE_PYTHON',
142
+ }
143
+ }
144
+
145
+ // 1. 重建或创建 venv
146
+ if (plan.recreate && existsSync(plan.venvDir)) {
147
+ rmSync(plan.venvDir, { recursive: true, force: true })
148
+ steps.push({ name: 'remove-existing-venv', ok: true, detail: basename(plan.venvDir) })
149
+ say(`已删除旧 venv: ${plan.venvDir}`)
150
+ }
151
+ if (!existsSync(plan.venvDir)) {
152
+ try {
153
+ await execFileAsync(plan.creator, ['-m', 'venv', plan.venvDir], { windowsHide: true, timeout: 180_000 })
154
+ steps.push({ name: 'create-venv', ok: true, detail: `由 ${plan.creator} 创建` })
155
+ say(`已创建 venv: ${plan.venvDir}`)
156
+ } catch (e: any) {
157
+ return {
158
+ success: false,
159
+ steps: [...steps, { name: 'create-venv', ok: false, detail: String(e?.message || e).slice(0, 200) }],
160
+ venvPython: plan.venvPython,
161
+ effectivePython: null,
162
+ error: 'VENV_CREATE_FAILED',
163
+ }
164
+ }
165
+ } else {
166
+ steps.push({ name: 'create-venv', ok: true, detail: '已存在,跳过(--recreate 可重建)' })
167
+ }
168
+
169
+ // 2. 装依赖(走 venv 自己的 pip;PyPI 网络请求就是这一步)。
170
+ // 失败重试一次:PyPI 的瞬时网络错误(本机 2026-10-23 实测过一次整批失败、
171
+ // 逐包重装全过)不值得让用户从头再来——重试前说一声。
172
+ const pipArgs = ['-m', 'pip', 'install', '--disable-pip-version-check', ...plan.packages]
173
+ const pipDetail = (e: unknown): string => {
174
+ const err = e as { stderr?: string; message?: string }
175
+ const tail = (err?.stderr || '').split('\n').map(l => l.trim()).filter(Boolean).slice(-3).join(' | ')
176
+ return (tail || String(err?.message || e)).slice(0, 300)
177
+ }
178
+ let installed = false
179
+ let pipError: unknown = null
180
+ for (let attempt = 1; attempt <= 2 && !installed; attempt++) {
181
+ try {
182
+ say(attempt === 1
183
+ ? `安装 ${plan.packages.length} 个包(来自 ${plan.requirementFiles.join('、')})...`
184
+ : `重试安装(第 1 次失败:${pipDetail(pipError)})...`)
185
+ await execFileAsync(plan.venvPython, pipArgs, {
186
+ windowsHide: true,
187
+ timeout: 600_000,
188
+ maxBuffer: 10 * 1024 * 1024,
189
+ })
190
+ installed = true
191
+ } catch (e) {
192
+ pipError = e
193
+ }
194
+ }
195
+ if (!installed) {
196
+ return {
197
+ success: false,
198
+ steps: [...steps, { name: 'pip-install', ok: false, detail: pipDetail(pipError) }],
199
+ venvPython: plan.venvPython,
200
+ effectivePython: null,
201
+ error: 'PIP_INSTALL_FAILED',
202
+ }
203
+ }
204
+ steps.push({ name: 'pip-install', ok: true, detail: `${plan.packages.length} 个包(${plan.requirementFiles.join('、')})` })
205
+ for (const s of plan.substitutions) say(` 替换: ${s}`)
206
+
207
+ // 3. 验收:venv 里真能 import sqlcipher3(NT 解密的核心依赖)
208
+ let verified = false
209
+ try {
210
+ await execFileAsync(plan.venvPython, ['-c', 'import sqlcipher3'], { windowsHide: true, timeout: 30_000 })
211
+ verified = true
212
+ } catch {
213
+ verified = false
214
+ }
215
+ steps.push({
216
+ name: 'verify-sqlcipher3',
217
+ ok: verified,
218
+ detail: verified ? 'import sqlcipher3 通过' : 'import sqlcipher3 失败——pip 报成功但模块不可用,把上面 pip 的完整输出贴到 issue',
219
+ })
220
+
221
+ // 4. 确认运行时真的会选它(清掉模块级缓存再看选择)
222
+ clearPythonCommandCache()
223
+ const effective = getPythonCommand()
224
+ const wiringOk = existsSync(plan.venvPython) && effective === plan.venvPython
225
+ steps.push({
226
+ name: 'runtime-prefers-venv',
227
+ ok: wiringOk,
228
+ detail: wiringOk ? 'getPythonCommand() 已指向自管 venv' : `getPythonCommand() 选了 ${effective}——venv 未通过探测(sqlcipher3 不可用时按原顺序回退 PATH)`,
229
+ })
230
+
231
+ return {
232
+ success: steps.every(s => s.ok),
233
+ steps,
234
+ venvPython: plan.venvPython,
235
+ effectivePython: effective,
236
+ ...(verified && !wiringOk ? { error: 'VENV_NOT_PREFERRED' } : {}),
237
+ }
238
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * 微信版本兼容:**单一事实源** + 纯比较。
3
+ *
4
+ * 背景(2026-10-25 起立此规矩):微信 4.x 几周一更,而"这版 weflow-cli 在哪个微信版本上
5
+ * 验证过"原来只写在 PROJECT_STATE 的一段散文里——CHANGELOG 不记、机器读不到,用户升级
6
+ * 微信后读不出数据时,第一件事("你的微信比我们验证过的新吗")没人答得上来。
7
+ *
8
+ * 三条接线,都只从这读、别处不许再写一份:
9
+ * - `capabilities --json` 的 `compatibility.wechat`(机器可读);
10
+ * - `check`(含 `--json` / `--report`)拿本机**实际加载**的版本与此比对,给出四种判定之一;
11
+ * - `docs/RELEASING.md` 的发布清单要求每个 CHANGELOG 版本段记录验证版本(来源就是这里)。
12
+ *
13
+ * 更新 `WECHAT_VERIFIED` 的门槛:**必须伴随一次真实数据验证**(不只是"进程起来了"),
14
+ * 验证日期与读了什么写进 `verifiedHow`。没验证就不许改——这正是这个常量存在的意义。
15
+ */
16
+ export const WECHAT_VERIFIED = {
17
+ version: '4.1.15.56',
18
+ verifiedAt: '2026-10-26',
19
+ verifiedHow: '升级后不重新提钥,用旧密钥实读真实数据:sessions / messages / contacts / '
20
+ + 'daily-stats / fav / sns stats 全部返回数据(2026-10-19,记录见 PROJECT_STATE「Initialization」);'
21
+ + '2026-10-26 复核:sessions 实读仍正常、check 判定 verified',
22
+ } as const
23
+
24
+ export type CompatVerdict =
25
+ | 'verified' // 本机加载的就是验证过的版本
26
+ | 'newer-than-verified' // 本机更新:可能不兼容,读不出数据先怀疑这里
27
+ | 'older-than-verified' // 本机更旧:通常是兼容的(向下兼容是常态,但没验证过也说不上保证)
28
+ | 'unknown' // 微信没在跑 / 版本探测失败 / 版本串形状不对——"不知道"不是"没问题"
29
+
30
+ /**
31
+ * 四段数字逐段比较(`4.1.15.56` vs `4.1.15.60`)。缺段按 0 处理(`4.1.15` < `4.1.15.56`);
32
+ * 任一侧不是纯数字点分串就不猜——返回 `unknown`。纯函数。
33
+ */
34
+ export function compareWechatVersions(live: string | null | undefined, verified: string): CompatVerdict {
35
+ const parse = (v: string): number[] | null => {
36
+ const parts = v.split('.')
37
+ if (!parts.length || !parts.every(p => /^\d+$/.test(p))) return null
38
+ return parts.map(Number)
39
+ }
40
+ if (!live) return 'unknown'
41
+ const a = parse(live)
42
+ const b = parse(verified)
43
+ if (!a || !b) return 'unknown'
44
+ const len = Math.max(a.length, b.length)
45
+ for (let i = 0; i < len; i++) {
46
+ const x = a[i] ?? 0
47
+ const y = b[i] ?? 0
48
+ if (x > y) return 'newer-than-verified'
49
+ if (x < y) return 'older-than-verified'
50
+ }
51
+ return 'verified'
52
+ }
53
+
54
+ /** 判定的人类可读一句话(check 人类输出与 --report 共用,措辞只有这一份)。 */
55
+ export function describeCompatVerdict(verdict: CompatVerdict, verifiedVersion: string): string {
56
+ switch (verdict) {
57
+ case 'verified':
58
+ return `本机加载的微信就是本版本验证过的 ${verifiedVersion}`
59
+ case 'newer-than-verified':
60
+ return `本机微信比本版本验证过的 ${verifiedVersion} 更新——如果读不出数据,先怀疑这里`
61
+ + '(去 GitHub 看有没有更新版本)'
62
+ case 'older-than-verified':
63
+ return `本机微信比本版本验证过的 ${verifiedVersion} 更旧——一般兼容,但没有专门验证过`
64
+ default:
65
+ return `无法判定(微信没在运行或版本探测失败);本版本验证过的是 ${verifiedVersion}`
66
+ }
67
+ }