weflow-cli 1.16.0 → 1.17.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 (97) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +16 -4
  3. package/bin/weflow-cli.ts +348 -6
  4. package/dist/bin/weflow-cli.js +381 -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/qchatImport.d.ts +89 -0
  26. package/dist/src/services/qchatImport.d.ts.map +1 -0
  27. package/dist/src/services/qchatImport.js +273 -0
  28. package/dist/src/services/qchatImport.js.map +1 -0
  29. package/dist/src/services/setupVenv.d.ts +43 -0
  30. package/dist/src/services/setupVenv.d.ts.map +1 -0
  31. package/dist/src/services/setupVenv.js +198 -0
  32. package/dist/src/services/setupVenv.js.map +1 -0
  33. package/dist/src/utils/python.d.ts +10 -0
  34. package/dist/src/utils/python.d.ts.map +1 -1
  35. package/dist/src/utils/python.js +27 -4
  36. package/dist/src/utils/python.js.map +1 -1
  37. package/docs/DATA_CONTRACT.md +25 -0
  38. package/docs/DECISIONS.md +92 -30
  39. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +365 -365
  40. package/docs/HEALTH-CHECK.md +104 -104
  41. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -310
  42. package/docs/NEXT_ACTIONS.md +236 -236
  43. package/docs/NPM-PUBLISH.md +130 -130
  44. package/docs/POSITIONING.md +68 -68
  45. package/docs/PROJECT_STATE.md +6 -3
  46. package/docs/REPLAY.md +169 -0
  47. package/docs/SYNC_CONTRACT.md +197 -180
  48. package/package.json +89 -88
  49. package/requirements-3x.txt +4 -4
  50. package/requirements-voice.txt +15 -15
  51. package/resources/js/graph3d/NOTICE.txt +35 -35
  52. package/resources/js/graph3d/d3-binarytree.min.js +2 -2
  53. package/resources/js/graph3d/d3-dispatch.min.js +2 -2
  54. package/resources/js/graph3d/d3-force-3d.min.js +2 -2
  55. package/resources/js/graph3d/d3-octree.min.js +2 -2
  56. package/resources/js/graph3d/d3-quadtree.min.js +2 -2
  57. package/resources/js/graph3d/d3-timer.min.js +2 -2
  58. package/resources/js/graph3d/three.min.js +6 -6
  59. package/resources/js/graph3d/viewer2d.js +550 -448
  60. package/resources/panel/quick-menu.cjs +197 -197
  61. package/scripts/_utils.py +1338 -1338
  62. package/scripts/article_notes.py +423 -423
  63. package/scripts/chat_notes.py +86 -3
  64. package/scripts/check_readme_commands.py +427 -426
  65. package/scripts/contact_schema.py +339 -339
  66. package/scripts/create_reading_notes.py +11 -11
  67. package/scripts/daily_task.ps1 +213 -213
  68. package/scripts/fav_notes.py +321 -321
  69. package/scripts/graph_2d.py +156 -151
  70. package/scripts/graph_3d.py +672 -646
  71. package/scripts/health_check.py +473 -473
  72. package/scripts/imported_chats.py +68 -0
  73. package/scripts/install_tasks.ps1 +105 -105
  74. package/scripts/leads.py +179 -179
  75. package/scripts/network.py +206 -206
  76. package/scripts/nt_decrypt.py +135 -1
  77. package/scripts/on_this_day.py +178 -178
  78. package/scripts/opinion_timeline.py +138 -138
  79. package/scripts/promises.py +185 -185
  80. package/scripts/replay.py +803 -0
  81. package/scripts/run_hidden.vbs +25 -0
  82. package/scripts/semantic_search.py +73 -5
  83. package/scripts/user_notes.py +315 -315
  84. package/scripts/voice_transcribe.py +139 -139
  85. package/scripts/watch_issues.py +212 -212
  86. package/scripts/watch_mail.py +131 -131
  87. package/src/core/ntCore.ts +59 -0
  88. package/src/services/assistantService.ts +48 -8
  89. package/src/services/assistantTools.ts +73 -10
  90. package/src/services/configService.ts +3 -2
  91. package/src/services/cursorProbe.ts +222 -0
  92. package/src/services/qchatImport.ts +327 -0
  93. package/src/services/setupVenv.ts +238 -0
  94. package/src/services/syncService.ts +363 -363
  95. package/src/services/syncState.ts +275 -275
  96. package/src/services/wechatMedia.ts +166 -166
  97. package/src/utils/python.ts +33 -4
@@ -1,166 +1,166 @@
1
- /**
2
- * 官方通道(ilink)**入站媒体**的落地:把 CDN 上的密文拉下来、解密、写到本地磁盘。
3
- *
4
- * 为什么值得做:入站图片/语音/文件/视频此前只解析出"有这一项",`filePath` 是空串,于是助手拿到消息
5
- * 也不知道图里是什么(那四项的落地当时是显式延后的)。下载与解密的本事本来就有
6
- * (`WechatClient.downloadMedia`:`<cdn>/download?encrypted_query_param=…` + AES-128-ECB + 去 PKCS#7),
7
- * 缺的只是把它接上入站这条路。
8
- *
9
- * 字段名与优先级照**厂商自己的实现**(`third-party/WeKnora/internal/im/wechat/longpoll.go`):
10
- * 图片优先用 `image_item.aeskey`(十六进制字符串,**不走 base64**),退回 `media.aes_key`(base64);
11
- * 语音/文件/视频只有 `media.aes_key`。两种编码都由 `WechatClient.parseMediaAesKey` 认。
12
- *
13
- * 三条边界写在这里,不指望调用方记得:
14
- * 1. **它不是默认行为** —— 打开 `wechatMediaDownload` 才发生网络访问;
15
- * 2. **文件名永远由本地决定** —— 服务端给的名字只取 basename 并过滤字符,`../` 之类必须在这里被吃掉;
16
- * 3. **超过上限就不写**(`WECHAT_MEDIA_MAX_BYTES`),失败不留半个文件 —— 半个文件比没有更难查。
17
- */
18
- import { basename, join } from 'path'
19
- import { mkdir, writeFile } from 'fs/promises'
20
-
21
- /** 单件上限。够大(手机拍的视频也能过),又不至于一次 CDN 事故把磁盘写满 */
22
- export const WECHAT_MEDIA_MAX_BYTES = 25 * 1024 * 1024
23
-
24
- /** 落地目录:默认仓库相对 `output/wechat-media/`(`output/` 已在 .gitignore 里),可用环境变量改(测试用临时目录) */
25
- export function wechatMediaDir(env: NodeJS.ProcessEnv = process.env): string {
26
- const fromEnv = env.WEFLOW_WECHAT_MEDIA_DIR
27
- return fromEnv && fromEnv.trim() ? fromEnv : join('output', 'wechat-media')
28
- }
29
-
30
- export type InboundMediaType = 'image' | 'record' | 'file' | 'video'
31
-
32
- /** item.type 的数字编码(与服务端 item_list 一致) */
33
- export function mediaTypeOf(itemType: number): InboundMediaType | null {
34
- if (itemType === 2) return 'image'
35
- if (itemType === 3) return 'record'
36
- if (itemType === 4) return 'file'
37
- if (itemType === 5) return 'video'
38
- return null
39
- }
40
-
41
- /** 服务端给的名字只当作"一个字符串"用:取 basename、去掉控制字符与路径字符、去掉前导点、限长 */
42
- export function sanitizeFileName(name: string): string {
43
- const base = basename(String(name || '').replace(/\\/g, '/')).trim()
44
- return base
45
- .replace(/[\u0000-\u001f<>:"|?*]/g, '_')
46
- .replace(/^\.+/, '')
47
- .trim()
48
- .slice(0, 80)
49
- }
50
-
51
- /** 按魔数认扩展名。认不出就返回空串(调用方退回按类型给的后缀),不猜 */
52
- export function extFromMagic(buf: Buffer): string {
53
- const b = buf
54
- if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return '.jpg'
55
- if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return '.png'
56
- if (b.length >= 6 && b.subarray(0, 6).toString('latin1').startsWith('GIF8')) return '.gif'
57
- if (b.length >= 12 && b.subarray(0, 4).toString('latin1') === 'RIFF'
58
- && b.subarray(8, 12).toString('latin1') === 'WEBP') return '.webp'
59
- if (b.length >= 2 && b[0] === 0x42 && b[1] === 0x4d) return '.bmp'
60
- if (b.length >= 12 && b.subarray(4, 8).toString('latin1') === 'ftyp') return '.mp4'
61
- if (b.length >= 4 && b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3) return '.webm'
62
- if (b.length >= 5 && b.subarray(0, 5).toString('latin1') === '#!AMR') return '.amr'
63
- if (b.length >= 4 && b.subarray(0, 4).toString('latin1') === 'OggS') return '.ogg'
64
- if (b.length >= 2 && b[0] === 0x49 && b[1] === 0x44 && b[2] === 0x33) return '.mp3'
65
- if (b.length >= 12 && b.subarray(0, 4).toString('latin1') === 'RIFF'
66
- && b.subarray(8, 12).toString('latin1') === 'WAVE') return '.wav'
67
- return ''
68
- }
69
-
70
- /** 该媒体类型在服务端字段树里的节点名 */
71
- function mediaNode(item: any, mediaType: InboundMediaType): any {
72
- if (mediaType === 'image') return item?.image_item
73
- if (mediaType === 'record') return item?.voice_item
74
- if (mediaType === 'file') return item?.file_item
75
- return item?.video_item
76
- }
77
-
78
- /** 下载参数(`encrypt_query_param`)。取不到就没法下载——**这时候不猜、不留空文件** */
79
- export function pickEncryptParam(item: any, mediaType: InboundMediaType): string {
80
- const node = mediaNode(item, mediaType)
81
- return String(node?.media?.encrypt_query_param || '')
82
- }
83
-
84
- /**
85
- * AES key 的取值优先级。**图片这一支必须照厂商实现**:`image_item.aeskey` 是十六进制字符串,
86
- * 比 `media.aes_key`(base64)更靠前;漏了这个优先级,图片会解出一堆乱码却不报错。
87
- */
88
- export function pickAesKeyValue(item: any, mediaType: InboundMediaType): string {
89
- const node = mediaNode(item, mediaType)
90
- if (mediaType === 'image' && node?.aeskey) return String(node.aeskey)
91
- return String(node?.media?.aes_key || '')
92
- }
93
-
94
- export interface SaveMediaOptions {
95
- item: any
96
- mediaType: InboundMediaType
97
- /** 同一条消息里的第几项(一条消息可以带多张图) */
98
- index: number
99
- /** 名字前缀:优先用服务端给的 client_id */
100
- messageId: string
101
- dir?: string
102
- /** 注入的下载器(默认由调用方传 `client.downloadMedia`),测试里换成假的 */
103
- download: (encryptedQueryParam: string, aesKeyValue: string) => Promise<Buffer>
104
- /** 文件类消息里服务端给的文件名 */
105
- serverFileName?: string
106
- maxBytes?: number
107
- }
108
-
109
- export interface SaveMediaResult {
110
- /** 成功是磁盘路径;失败是空串(**绝不返回"看起来成功"的半成品**) */
111
- path: string
112
- /** 失败原因,进日志用 */
113
- reason: string
114
- }
115
-
116
- /**
117
- * 下载一件入站媒体并落盘。失败一律返回 `{ path: '', reason }` 且**不留下文件**:
118
- * 助手与导出拿到空 path 时知道"没有这玩意",比拿到一个 0 字节文件强。
119
- */
120
- export async function saveInboundMedia(opts: SaveMediaOptions): Promise<SaveMediaResult> {
121
- const { item, mediaType, index, messageId, download } = opts
122
- const cap = opts.maxBytes ?? WECHAT_MEDIA_MAX_BYTES
123
-
124
- const encryptParam = pickEncryptParam(item, mediaType)
125
- if (!encryptParam) return { path: '', reason: '服务端没给 encrypt_query_param' }
126
-
127
- const aesKeyValue = pickAesKeyValue(item, mediaType)
128
- if (!aesKeyValue) return { path: '', reason: '服务端没给 aes key' }
129
-
130
- let bytes: Buffer
131
- try {
132
- bytes = await download(encryptParam, aesKeyValue)
133
- } catch (error: any) {
134
- return { path: '', reason: `下载或解密失败:${error?.message || error}` }
135
- }
136
- if (!bytes || bytes.length === 0) return { path: '', reason: '下载结果为空' }
137
- if (bytes.length > cap) {
138
- return { path: '', reason: `超过上限(${bytes.length} > ${cap} 字节)` }
139
- }
140
-
141
- const byMagic = extFromMagic(bytes)
142
- const fallbackExt = mediaType === 'image' ? '.jpg' : ''
143
- const prefix = sanitizeFileName(messageId) || 'msg'
144
- let name: string
145
- if (mediaType === 'file' && opts.serverFileName) {
146
- const safe = sanitizeFileName(opts.serverFileName)
147
- if (safe) {
148
- const hasExt = /\.[A-Za-z0-9]{1,8}$/.test(safe)
149
- name = `${prefix}-${index}-${hasExt || !byMagic ? safe : safe + byMagic}`
150
- } else {
151
- name = `${prefix}-${index}${byMagic}`
152
- }
153
- } else {
154
- name = `${prefix}-${index}${byMagic || fallbackExt}`
155
- }
156
-
157
- const dir = opts.dir || wechatMediaDir()
158
- const full = join(dir, name)
159
- try {
160
- await mkdir(dir, { recursive: true })
161
- await writeFile(full, bytes)
162
- } catch (error: any) {
163
- return { path: '', reason: `写盘失败:${error?.message || error}` }
164
- }
165
- return { path: full, reason: '' }
166
- }
1
+ /**
2
+ * 官方通道(ilink)**入站媒体**的落地:把 CDN 上的密文拉下来、解密、写到本地磁盘。
3
+ *
4
+ * 为什么值得做:入站图片/语音/文件/视频此前只解析出"有这一项",`filePath` 是空串,于是助手拿到消息
5
+ * 也不知道图里是什么(那四项的落地当时是显式延后的)。下载与解密的本事本来就有
6
+ * (`WechatClient.downloadMedia`:`<cdn>/download?encrypted_query_param=…` + AES-128-ECB + 去 PKCS#7),
7
+ * 缺的只是把它接上入站这条路。
8
+ *
9
+ * 字段名与优先级照**厂商自己的实现**(`third-party/WeKnora/internal/im/wechat/longpoll.go`):
10
+ * 图片优先用 `image_item.aeskey`(十六进制字符串,**不走 base64**),退回 `media.aes_key`(base64);
11
+ * 语音/文件/视频只有 `media.aes_key`。两种编码都由 `WechatClient.parseMediaAesKey` 认。
12
+ *
13
+ * 三条边界写在这里,不指望调用方记得:
14
+ * 1. **它不是默认行为** —— 打开 `wechatMediaDownload` 才发生网络访问;
15
+ * 2. **文件名永远由本地决定** —— 服务端给的名字只取 basename 并过滤字符,`../` 之类必须在这里被吃掉;
16
+ * 3. **超过上限就不写**(`WECHAT_MEDIA_MAX_BYTES`),失败不留半个文件 —— 半个文件比没有更难查。
17
+ */
18
+ import { basename, join } from 'path'
19
+ import { mkdir, writeFile } from 'fs/promises'
20
+
21
+ /** 单件上限。够大(手机拍的视频也能过),又不至于一次 CDN 事故把磁盘写满 */
22
+ export const WECHAT_MEDIA_MAX_BYTES = 25 * 1024 * 1024
23
+
24
+ /** 落地目录:默认仓库相对 `output/wechat-media/`(`output/` 已在 .gitignore 里),可用环境变量改(测试用临时目录) */
25
+ export function wechatMediaDir(env: NodeJS.ProcessEnv = process.env): string {
26
+ const fromEnv = env.WEFLOW_WECHAT_MEDIA_DIR
27
+ return fromEnv && fromEnv.trim() ? fromEnv : join('output', 'wechat-media')
28
+ }
29
+
30
+ export type InboundMediaType = 'image' | 'record' | 'file' | 'video'
31
+
32
+ /** item.type 的数字编码(与服务端 item_list 一致) */
33
+ export function mediaTypeOf(itemType: number): InboundMediaType | null {
34
+ if (itemType === 2) return 'image'
35
+ if (itemType === 3) return 'record'
36
+ if (itemType === 4) return 'file'
37
+ if (itemType === 5) return 'video'
38
+ return null
39
+ }
40
+
41
+ /** 服务端给的名字只当作"一个字符串"用:取 basename、去掉控制字符与路径字符、去掉前导点、限长 */
42
+ export function sanitizeFileName(name: string): string {
43
+ const base = basename(String(name || '').replace(/\\/g, '/')).trim()
44
+ return base
45
+ .replace(/[\u0000-\u001f<>:"|?*]/g, '_')
46
+ .replace(/^\.+/, '')
47
+ .trim()
48
+ .slice(0, 80)
49
+ }
50
+
51
+ /** 按魔数认扩展名。认不出就返回空串(调用方退回按类型给的后缀),不猜 */
52
+ export function extFromMagic(buf: Buffer): string {
53
+ const b = buf
54
+ if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return '.jpg'
55
+ if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return '.png'
56
+ if (b.length >= 6 && b.subarray(0, 6).toString('latin1').startsWith('GIF8')) return '.gif'
57
+ if (b.length >= 12 && b.subarray(0, 4).toString('latin1') === 'RIFF'
58
+ && b.subarray(8, 12).toString('latin1') === 'WEBP') return '.webp'
59
+ if (b.length >= 2 && b[0] === 0x42 && b[1] === 0x4d) return '.bmp'
60
+ if (b.length >= 12 && b.subarray(4, 8).toString('latin1') === 'ftyp') return '.mp4'
61
+ if (b.length >= 4 && b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3) return '.webm'
62
+ if (b.length >= 5 && b.subarray(0, 5).toString('latin1') === '#!AMR') return '.amr'
63
+ if (b.length >= 4 && b.subarray(0, 4).toString('latin1') === 'OggS') return '.ogg'
64
+ if (b.length >= 2 && b[0] === 0x49 && b[1] === 0x44 && b[2] === 0x33) return '.mp3'
65
+ if (b.length >= 12 && b.subarray(0, 4).toString('latin1') === 'RIFF'
66
+ && b.subarray(8, 12).toString('latin1') === 'WAVE') return '.wav'
67
+ return ''
68
+ }
69
+
70
+ /** 该媒体类型在服务端字段树里的节点名 */
71
+ function mediaNode(item: any, mediaType: InboundMediaType): any {
72
+ if (mediaType === 'image') return item?.image_item
73
+ if (mediaType === 'record') return item?.voice_item
74
+ if (mediaType === 'file') return item?.file_item
75
+ return item?.video_item
76
+ }
77
+
78
+ /** 下载参数(`encrypt_query_param`)。取不到就没法下载——**这时候不猜、不留空文件** */
79
+ export function pickEncryptParam(item: any, mediaType: InboundMediaType): string {
80
+ const node = mediaNode(item, mediaType)
81
+ return String(node?.media?.encrypt_query_param || '')
82
+ }
83
+
84
+ /**
85
+ * AES key 的取值优先级。**图片这一支必须照厂商实现**:`image_item.aeskey` 是十六进制字符串,
86
+ * 比 `media.aes_key`(base64)更靠前;漏了这个优先级,图片会解出一堆乱码却不报错。
87
+ */
88
+ export function pickAesKeyValue(item: any, mediaType: InboundMediaType): string {
89
+ const node = mediaNode(item, mediaType)
90
+ if (mediaType === 'image' && node?.aeskey) return String(node.aeskey)
91
+ return String(node?.media?.aes_key || '')
92
+ }
93
+
94
+ export interface SaveMediaOptions {
95
+ item: any
96
+ mediaType: InboundMediaType
97
+ /** 同一条消息里的第几项(一条消息可以带多张图) */
98
+ index: number
99
+ /** 名字前缀:优先用服务端给的 client_id */
100
+ messageId: string
101
+ dir?: string
102
+ /** 注入的下载器(默认由调用方传 `client.downloadMedia`),测试里换成假的 */
103
+ download: (encryptedQueryParam: string, aesKeyValue: string) => Promise<Buffer>
104
+ /** 文件类消息里服务端给的文件名 */
105
+ serverFileName?: string
106
+ maxBytes?: number
107
+ }
108
+
109
+ export interface SaveMediaResult {
110
+ /** 成功是磁盘路径;失败是空串(**绝不返回"看起来成功"的半成品**) */
111
+ path: string
112
+ /** 失败原因,进日志用 */
113
+ reason: string
114
+ }
115
+
116
+ /**
117
+ * 下载一件入站媒体并落盘。失败一律返回 `{ path: '', reason }` 且**不留下文件**:
118
+ * 助手与导出拿到空 path 时知道"没有这玩意",比拿到一个 0 字节文件强。
119
+ */
120
+ export async function saveInboundMedia(opts: SaveMediaOptions): Promise<SaveMediaResult> {
121
+ const { item, mediaType, index, messageId, download } = opts
122
+ const cap = opts.maxBytes ?? WECHAT_MEDIA_MAX_BYTES
123
+
124
+ const encryptParam = pickEncryptParam(item, mediaType)
125
+ if (!encryptParam) return { path: '', reason: '服务端没给 encrypt_query_param' }
126
+
127
+ const aesKeyValue = pickAesKeyValue(item, mediaType)
128
+ if (!aesKeyValue) return { path: '', reason: '服务端没给 aes key' }
129
+
130
+ let bytes: Buffer
131
+ try {
132
+ bytes = await download(encryptParam, aesKeyValue)
133
+ } catch (error: any) {
134
+ return { path: '', reason: `下载或解密失败:${error?.message || error}` }
135
+ }
136
+ if (!bytes || bytes.length === 0) return { path: '', reason: '下载结果为空' }
137
+ if (bytes.length > cap) {
138
+ return { path: '', reason: `超过上限(${bytes.length} > ${cap} 字节)` }
139
+ }
140
+
141
+ const byMagic = extFromMagic(bytes)
142
+ const fallbackExt = mediaType === 'image' ? '.jpg' : ''
143
+ const prefix = sanitizeFileName(messageId) || 'msg'
144
+ let name: string
145
+ if (mediaType === 'file' && opts.serverFileName) {
146
+ const safe = sanitizeFileName(opts.serverFileName)
147
+ if (safe) {
148
+ const hasExt = /\.[A-Za-z0-9]{1,8}$/.test(safe)
149
+ name = `${prefix}-${index}-${hasExt || !byMagic ? safe : safe + byMagic}`
150
+ } else {
151
+ name = `${prefix}-${index}${byMagic}`
152
+ }
153
+ } else {
154
+ name = `${prefix}-${index}${byMagic || fallbackExt}`
155
+ }
156
+
157
+ const dir = opts.dir || wechatMediaDir()
158
+ const full = join(dir, name)
159
+ try {
160
+ await mkdir(dir, { recursive: true })
161
+ await writeFile(full, bytes)
162
+ } catch (error: any) {
163
+ return { path: '', reason: `写盘失败:${error?.message || error}` }
164
+ }
165
+ return { path: full, reason: '' }
166
+ }
@@ -1,6 +1,7 @@
1
1
  import { execFileSync } from 'child_process'
2
2
  import { delimiter, join } from 'path'
3
- import { accessSync, constants } from 'fs'
3
+ import { accessSync, constants, existsSync } from 'fs'
4
+ import { homedir } from 'os'
4
5
 
5
6
  let cached: string | null = null
6
7
 
@@ -17,6 +18,26 @@ function isExecutable(path: string): boolean {
17
18
  }
18
19
  }
19
20
 
21
+ /** `weflow-cli setup` 建的自管 venv 的解释器路径;没建过就是 null。 */
22
+ export function managedVenvPython(): string | null {
23
+ const exe = process.platform === 'win32'
24
+ ? join(homedir(), '.weflow-cli', 'venv', 'Scripts', 'python.exe')
25
+ : join(homedir(), '.weflow-cli', 'venv', 'bin', 'python')
26
+ return existsSync(exe) ? exe : null
27
+ }
28
+
29
+ /**
30
+ * 候选序:自管 venv 永远排最前,其余按调用方给的序。纯函数,供测试钉住
31
+ * "建了 venv 就该优先用它"这件事——它坏了的表现是 setup 装好了包、
32
+ * 运行时却仍在用 PATH 上的解释器,两边各自"正常"。
33
+ */
34
+ export function orderCandidates(
35
+ managed: string | null,
36
+ rest: string[],
37
+ ): string[] {
38
+ return managed ? [managed, ...rest.filter(c => c !== managed)] : [...rest]
39
+ }
40
+
20
41
  /** 按顺序返回 PATH 中所有可用的 Python 解释器(含命令名候选) */
21
42
  function candidates(): string[] {
22
43
  const found: string[] = []
@@ -55,13 +76,16 @@ function hasSqlcipher(cmd: string): boolean {
55
76
  }
56
77
 
57
78
  function detect(): string {
79
+ // 自管 venv(`weflow-cli setup`)优先:它就是为了"这台机器有一个能
80
+ // import sqlcipher3 的 Python"而存在的。venv 坏了(装完又被卸了包)会
81
+ // 自然落到下面的 PATH 候选——行为退回 setup 之前,而不是更糟。
82
+ const ordered = orderCandidates(managedVenvPython(), candidates())
58
83
  // 优先选择能 import sqlcipher3 的解释器(NT 解密核心依赖),
59
84
  // 避免命中 PATH 中仅有基础库的裸 Python 环境(如被 IDE 注入的 VM Python 抢占)。
60
- const list = candidates()
61
- for (const cmd of list) {
85
+ for (const cmd of ordered) {
62
86
  if (canRun(cmd) && hasSqlcipher(cmd)) return cmd
63
87
  }
64
- for (const cmd of list) {
88
+ for (const cmd of ordered) {
65
89
  if (canRun(cmd)) return cmd
66
90
  }
67
91
  return 'python'
@@ -71,3 +95,8 @@ export function getPythonCommand(): string {
71
95
  cached ??= detect()
72
96
  return cached
73
97
  }
98
+
99
+ /** setup 装完后清缓存,让同一进程里后续的 getPythonCommand 看见新 venv。 */
100
+ export function clearPythonCommandCache(): void {
101
+ cached = null
102
+ }