dsh-deepmoyu 2.3.0 → 2.4.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.
Files changed (90) hide show
  1. package/AGENTS.md +93 -153
  2. package/CHANGELOG.md +36 -0
  3. package/CONTRIBUTING.md +51 -0
  4. package/CONTRIBUTORS.md +3 -7
  5. package/NOTICE.md +3 -3
  6. package/README.md +143 -81
  7. package/SECURITY.md +21 -0
  8. package/assets/screenshots/memory.png +0 -0
  9. package/assets/screenshots/menu.png +0 -0
  10. package/assets/screenshots/workspace.png +0 -0
  11. package/client.js +1404 -441
  12. package/cordis.patch.yml +1 -1
  13. package/docs/ASSETS.md +25 -0
  14. package/docs/DEPLOYMENT.md +102 -0
  15. package/docs/PRIVACY.md +32 -0
  16. package/docs/RELEASE_AUDIT.md +70 -0
  17. package/docs/RELEASING.md +94 -0
  18. package/docs/TEST_MATRIX.md +77 -0
  19. package/docs/assets-permissions.json +20 -0
  20. package/docs/tool-evidence.json +196 -0
  21. package/index.js +1095 -285
  22. package/lib/asr.py +41 -35
  23. package/lib/background-output.mjs +64 -17
  24. package/lib/background-tools.mjs +1 -0
  25. package/lib/companion.mjs +43 -0
  26. package/lib/config-policy.mjs +20 -0
  27. package/lib/favops.py +9 -2
  28. package/lib/ids.mjs +41 -0
  29. package/lib/login-session.mjs +34 -0
  30. package/lib/login.mjs +5 -4
  31. package/lib/mcp.mjs +3 -2
  32. package/lib/memory-center.mjs +74 -0
  33. package/lib/mode.mjs +6 -7
  34. package/lib/model-relay.mjs +5 -4
  35. package/lib/open-directory.mjs +93 -0
  36. package/lib/pace.mjs +92 -12
  37. package/lib/persona.mjs +46 -0
  38. package/lib/product-status.mjs +123 -0
  39. package/lib/prompts.mjs +40 -5
  40. package/lib/pv-production.mjs +280 -0
  41. package/lib/pv-session.mjs +316 -0
  42. package/lib/pv.mjs +398 -306
  43. package/lib/pv.py +84 -22
  44. package/lib/pv_cover.py +108 -26
  45. package/lib/pv_editorial.py +336 -0
  46. package/lib/pv_verify.py +267 -0
  47. package/lib/python-env.mjs +61 -0
  48. package/lib/reply.mjs +10 -3
  49. package/lib/run-settings.mjs +6 -0
  50. package/lib/sticker-automation.mjs +5 -4
  51. package/lib/sticker.mjs +135 -25
  52. package/lib/tool-scope.mjs +65 -0
  53. package/lib/update.mjs +265 -0
  54. package/lib/uvfetch.mjs +165 -153
  55. package/lib/verify.mjs +111 -127
  56. package/marketplace/pyyxzi__dsh-deepmoyu.yml +7 -0
  57. package/package.json +23 -12
  58. package/presets/deepmoyu.patch.yml +1 -1
  59. package/prompts/duty.md +4 -3
  60. package/screenshots.json +5 -0
  61. package/selfcheck.mjs +21 -6
  62. package/vendor/bilibili-mcp/requirements.in +9 -0
  63. package/vendor/bilibili-mcp/requirements.txt +1936 -11
  64. package/vendor/bilibili-mcp/runtime_guard.py +35 -10
  65. package/lib/__pycache__/pv.cpython-310.pyc +0 -0
  66. package/lib/__pycache__/pv_cover.cpython-310.pyc +0 -0
  67. package/tests/automation-routes.test.mjs +0 -183
  68. package/tests/automation.test.mjs +0 -79
  69. package/tests/background-tools.test.mjs +0 -182
  70. package/tests/background-window.test.mjs +0 -75
  71. package/tests/dup-cross-channel.test.mjs +0 -158
  72. package/tests/manual-browsing.test.mjs +0 -671
  73. package/tests/mcp-restart.test.mjs +0 -131
  74. package/tests/memory-chain.test.mjs +0 -413
  75. package/tests/model-relay.test.mjs +0 -116
  76. package/tests/persona.test.mjs +0 -67
  77. package/tests/pet-run-routes.test.mjs +0 -127
  78. package/tests/pet-run-settings.test.mjs +0 -38
  79. package/tests/pet-session.test.mjs +0 -125
  80. package/tests/pv-stage1.test.mjs +0 -203
  81. package/tests/sticker-routes.test.mjs +0 -141
  82. package/tests/sticker-upload-routes.test.mjs +0 -97
  83. package/tests/sticker-upload.test.mjs +0 -122
  84. package/tests/sticker.test.mjs +0 -169
  85. package/tests/test_guard_cross_channel.py +0 -141
  86. package/tests/test_pv_cover.py +0 -95
  87. package/tests/test_pv_stage1.py +0 -250
  88. package/tests/test_reply_pictures.py +0 -255
  89. package/tests/token-ledger.test.mjs +0 -78
  90. package/tests/verify_pv_sample.py +0 -91
package/lib/update.mjs ADDED
@@ -0,0 +1,265 @@
1
+ /**
2
+ * 更新通道(2026-10-30 使用者要求「加一个更新通道,方便及时更新」)。
3
+ *
4
+ * ⚠️ 历史教训(必须保留这道闸):2026-10-21 曾经有过一个「一键升级」按钮,它让宿主跑
5
+ * `dsh plugin add dsh-deepmoyu`,把整个包目录重装成 registry 上的版本。当时本机装的是
6
+ * **目录联接(junction)指向源码目录的分叉**,一次点击就把所有改动换掉了(m17605)。
7
+ *
8
+ * 所以这个模块的职责不是「能升级就行」,而是**先证明这次升级不会毁掉用户手里的东西**:
9
+ * 1. 只允许「registry 安装」自更新(包目录必须是 profile 的 node_modules 下真实目录);
10
+ * 目录联接 / git 检出 / 包目录不在 profile 里 ⇒ 一律锁定,只给检查结果和手动升级建议。
11
+ * 2. 有刷站/后台/渲染在跑时拒绝:正在跑的进程读的是旧文件,换包会让下一次 import 半新半旧。
12
+ * 3. 单飞:同一时间只允许一个升级进程。
13
+ * 纯函数 + 依赖注入,测试不需要网络、不需要真 dsh。
14
+ */
15
+ import { existsSync, lstatSync, readdirSync, realpathSync, statSync } from 'node:fs'
16
+ import { join } from 'node:path'
17
+
18
+ export const PACKAGE_NAME = 'dsh-deepmoyu'
19
+ export const DEFAULT_REGISTRY = 'https://registry.npmjs.org'
20
+ export const MIRROR_REGISTRY = 'https://registry.npmmirror.com'
21
+ export const ACCEPT_PACKUMENT = 'application/vnd.npm.install-v1+json'
22
+
23
+ const num = (value, fallback = 0) => {
24
+ const n = Number(value)
25
+ return Number.isFinite(n) ? n : fallback
26
+ }
27
+
28
+ /** `2.4.10` / `2.4.1-beta.2` / `v2.4.1` → 可比较结构;非法返回 null。 */
29
+ export function parseVersion(raw) {
30
+ const text = String(raw ?? '').trim().replace(/^v/i, '')
31
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(text)
32
+ if (!match) return null
33
+ return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), pre: match[4] || '' }
34
+ }
35
+
36
+ /**
37
+ * 比较两个版本:a>b 返回 1,相等 0,a<b 返回 -1。
38
+ * 预发布版本低于同号正式版(`2.4.1-beta` < `2.4.1`),与 semver 一致;
39
+ * 预发布段按点分隔逐段比,数字段按数值(`beta.10` > `beta.2`,不能比字符串)。
40
+ * 无法解析的版本按 0.0.0 处理,绝不因为解析失败就说「有新版」。
41
+ */
42
+ export function compareVersions(a, b) {
43
+ const left = parseVersion(a) || { major: 0, minor: 0, patch: 0, pre: '0' }
44
+ const right = parseVersion(b) || { major: 0, minor: 0, patch: 0, pre: '0' }
45
+ for (const key of ['major', 'minor', 'patch']) {
46
+ if (left[key] !== right[key]) return left[key] > right[key] ? 1 : -1
47
+ }
48
+ if (left.pre === right.pre) return 0
49
+ if (!left.pre) return 1
50
+ if (!right.pre) return -1
51
+ const lseg = left.pre.split('.')
52
+ const rseg = right.pre.split('.')
53
+ for (let i = 0; i < Math.max(lseg.length, rseg.length); i++) {
54
+ const l = lseg[i], r = rseg[i]
55
+ if (l === undefined) return -1
56
+ if (r === undefined) return 1
57
+ if (l === r) continue
58
+ const ln = /^\d+$/.test(l), rn = /^\d+$/.test(r)
59
+ if (ln && rn) return Number(l) > Number(r) ? 1 : -1
60
+ if (ln) return -1
61
+ if (rn) return 1
62
+ return l > r ? 1 : -1
63
+ }
64
+ return 0
65
+ }
66
+
67
+ /** 从 npm packument 里挑出要提示的版本:优先 dist-tags.latest,没有就取最高正式版。 */
68
+ export function pickPublished(packument) {
69
+ if (!packument || typeof packument !== 'object') return null
70
+ const tags = packument['dist-tags'] && typeof packument['dist-tags'] === 'object' ? packument['dist-tags'] : {}
71
+ const latest = String(tags.latest || '').trim()
72
+ if (parseVersion(latest)) {
73
+ const time = packument.time && typeof packument.time === 'object' ? String(packument.time[latest] || '') : ''
74
+ return { version: latest, publishedAt: time, source: 'dist-tags.latest' }
75
+ }
76
+ const versions = packument.versions && typeof packument.versions === 'object' ? Object.keys(packument.versions) : []
77
+ const stable = versions.filter((version) => parseVersion(version) && !parseVersion(version).pre)
78
+ if (!stable.length) return null
79
+ stable.sort(compareVersions)
80
+ const version = stable[stable.length - 1]
81
+ const time = packument.time && typeof packument.time === 'object' ? String(packument.time[version] || '') : ''
82
+ return { version, publishedAt: time, source: 'highest-stable' }
83
+ }
84
+
85
+ /**
86
+ * 判断这份安装能不能自更新。返回 `{ updatable, mode, reason, profile }`。
87
+ *
88
+ * mode:
89
+ * `registry` —— 包目录是 profile/node_modules/<pkg> 下的真实目录(市场或 `dsh plugin add` 装的)⇒ 可自更新
90
+ * `link` —— 目录联接 / 符号链接(本机分叉的接法)⇒ 只检查不升级
91
+ * `source` —— 包目录里带 .git,是源码检出 ⇒ 只检查不升级
92
+ * `unknown` —— 在 DSH_HOME 的 profiles 里找不到对应安装 ⇒ 只检查不升级
93
+ */
94
+ export function detectInstallMode({ pluginDir, dshHome, packageName = PACKAGE_NAME } = {}) {
95
+ const dir = String(pluginDir || '')
96
+ if (!dir) return { updatable: false, mode: 'unknown', reason: '拿不到插件目录,无法判断安装方式', profile: '' }
97
+ let real = dir
98
+ try { real = realpathSync(dir) } catch { /* 目录不可读就按原样比 */ }
99
+ let linked = false
100
+ try { linked = lstatSync(dir).isSymbolicLink() } catch { linked = false }
101
+ if (existsSync(join(dir, '.git'))) {
102
+ return { updatable: false, mode: 'source', profile: '', real,
103
+ reason: '这是源码检出(目录里有 .git),自更新会用 registry 版本覆盖你的改动;请改用 git pull 后重启 DSH' }
104
+ }
105
+ if (linked) {
106
+ return { updatable: false, mode: 'link', profile: '', real,
107
+ reason: '这是目录联接安装(包目录指向别处,常见于本机分叉),自更新会把指向的目录换掉;请手动覆盖或重新 link' }
108
+ }
109
+ const home = String(dshHome || '')
110
+ const profilesDir = home ? join(home, 'profiles') : ''
111
+ let names = []
112
+ try { names = profilesDir ? readdirSync(profilesDir) : [] } catch { names = [] }
113
+ for (const name of names) {
114
+ const candidate = join(profilesDir, name, 'node_modules', packageName)
115
+ let candidateReal = ''
116
+ try { candidateReal = realpathSync(candidate) } catch { continue }
117
+ if (candidateReal !== real) continue
118
+ let isLink = false
119
+ try { isLink = lstatSync(candidate).isSymbolicLink() } catch { isLink = false }
120
+ if (isLink) {
121
+ return { updatable: false, mode: 'link', profile: name, real,
122
+ reason: `profile「${name}」里这个包是链接安装(可能指向本机源码),自更新会替换掉它;请手动处理` }
123
+ }
124
+ return { updatable: true, mode: 'registry', profile: name, real, reason: '' }
125
+ }
126
+ return { updatable: false, mode: 'unknown', profile: '',
127
+ reason: '没在 DSH 的 profiles 里找到这份安装(可能是手动放置或另装了一份),为避免误覆盖,自更新已锁定' }
128
+ }
129
+
130
+ /** npm packument 请求(带超时与体积上限,只读)。 */
131
+ export async function fetchPackument({ registry = DEFAULT_REGISTRY, packageName = PACKAGE_NAME, fetchImpl = globalThis.fetch, timeoutMs = 8000, maxBytes = 2 * 1024 * 1024 } = {}) {
132
+ if (typeof fetchImpl !== 'function') throw new Error('当前 Node 没有 fetch,无法检查更新')
133
+ const base = String(registry || DEFAULT_REGISTRY).replace(/\/+$/, '')
134
+ const url = `${base}/${encodeURIComponent(packageName).replace('%40', '@')}`
135
+ const controller = typeof AbortController === 'function' ? new AbortController() : null
136
+ const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null
137
+ if (timer?.unref) timer.unref()
138
+ try {
139
+ const response = await fetchImpl(url, {
140
+ headers: { accept: ACCEPT_PACKUMENT, 'user-agent': `${packageName}-update-check` },
141
+ redirect: 'follow',
142
+ signal: controller ? controller.signal : undefined,
143
+ })
144
+ if (response.status === 404) return { ok: false, code: 'NOT_PUBLISHED', error: 'registry 上还没有这个包(或名称被占用),暂时无法检查更新', registry: base }
145
+ if (!response.ok) return { ok: false, code: 'HTTP_' + response.status, error: `registry 返回 HTTP ${response.status}`, registry: base }
146
+ const text = await response.text()
147
+ if (text.length > maxBytes) return { ok: false, code: 'TOO_LARGE', error: 'registry 响应过大,已放弃解析', registry: base }
148
+ let body
149
+ try { body = JSON.parse(text) } catch { return { ok: false, code: 'BAD_JSON', error: 'registry 返回的不是合法 JSON', registry: base } }
150
+ return { ok: true, body, registry: base }
151
+ } catch (error) {
152
+ const aborted = error && (error.name === 'AbortError' || error.code === 'ABORT_ERR')
153
+ return { ok: false, code: aborted ? 'TIMEOUT' : 'NETWORK', error: aborted ? `连接 registry 超时(${Math.round(timeoutMs / 1000)} 秒)` : `连不上 registry:${error?.message || error}`, registry: base }
154
+ } finally {
155
+ if (timer) clearTimeout(timer)
156
+ }
157
+ }
158
+
159
+ /**
160
+ * 组装给界面/工具看的检查结果。纯函数:不联网、不抛错。
161
+ * `latest` 拿不到时 `ok:false` 但**不当成「已经最新」**——未知就是未知。
162
+ */
163
+ export function buildUpdateReport({ current, published, install, registry = DEFAULT_REGISTRY, checkedAt = Date.now(), error = '' } = {}) {
164
+ const cur = String(current || '')
165
+ const hasCurrent = Boolean(parseVersion(cur))
166
+ const latest = published && parseVersion(published.version) ? String(published.version) : ''
167
+ const outdated = Boolean(hasCurrent && latest && compareVersions(latest, cur) > 0)
168
+ const ahead = Boolean(hasCurrent && latest && compareVersions(cur, latest) > 0)
169
+ const canUpgrade = Boolean(outdated && install && install.updatable === true)
170
+ let hint = ''
171
+ if (error) hint = error
172
+ else if (!latest) hint = '暂时读不到 registry 上的版本,稍后再试'
173
+ else if (ahead) hint = `当前 ${cur} 比 registry 上的 ${latest} 还新(本地开发版或未发布提交)`
174
+ else if (!outdated) hint = '已是最新版本'
175
+ else if (!install || install.updatable !== true) hint = (install && install.reason) || '当前安装方式不支持自更新,请手动升级'
176
+ else hint = `可以升级到 ${latest};升级后需要重启 DSH 才能加载新代码`
177
+ return {
178
+ ok: !error,
179
+ package: PACKAGE_NAME,
180
+ current: cur,
181
+ latest,
182
+ outdated,
183
+ ahead,
184
+ canUpgrade,
185
+ install: install || { updatable: false, mode: 'unknown', profile: '', reason: '无法判断安装方式' },
186
+ registry: String(registry || DEFAULT_REGISTRY).replace(/\/+$/, ''),
187
+ publishedAt: published ? String(published.publishedAt || '') : '',
188
+ checkedAt: num(checkedAt, 0),
189
+ hint,
190
+ error: error || '',
191
+ }
192
+ }
193
+
194
+ /** 组合:检测安装方式 + 查 registry + 生成结果。检查本身永不抛错。 */
195
+ export async function checkForUpdate({ current, pluginDir, dshHome, registry = DEFAULT_REGISTRY, fetchImpl = globalThis.fetch, packageName = PACKAGE_NAME, timeoutMs = 8000, checkedAt = Date.now() } = {}) {
196
+ const install = detectInstallMode({ pluginDir, dshHome, packageName })
197
+ const fetched = await fetchPackument({ registry, packageName, fetchImpl, timeoutMs })
198
+ if (!fetched.ok) return { ...buildUpdateReport({ current, published: null, install, registry, checkedAt, error: fetched.error }), code: fetched.code }
199
+ const published = pickPublished(fetched.body)
200
+ if (!published) return { ...buildUpdateReport({ current, published: null, install, registry, checkedAt, error: 'registry 上的版本信息不完整,无法判断' }), code: 'NO_VERSIONS' }
201
+ return buildUpdateReport({ current, published, install, registry, checkedAt })
202
+ }
203
+
204
+ /**
205
+ * 生成升级命令。只允许 `plugin add <包名>`,不接受任意参数、不接受本地路径。
206
+ * 返回 `{ command, args, env, profile }` 或 `{ ok:false, code, error }`。
207
+ */
208
+ export function upgradeCommand({ dshCli, profile, packageName = PACKAGE_NAME, electron = false, registry = '' } = {}) {
209
+ const cli = String(dshCli || '')
210
+ if (!cli) return { ok: false, code: 'NO_DSH_CLI', error: '找不到 dsh 命令行,无法自动升级;请在终端手动执行 dsh plugin --profile <名称> add ' + packageName }
211
+ const name = String(profile || '')
212
+ if (!/^[A-Za-z0-9._-]{1,64}$/.test(name)) return { ok: false, code: 'NO_PROFILE', error: '不知道这份安装属于哪个 profile,无法安全升级' }
213
+ const env = { DSH_UPDATE_AT: String(Date.now()) }
214
+ if (electron) env.ELECTRON_RUN_AS_NODE = '1'
215
+ const reg = String(registry || '').trim()
216
+ if (reg) {
217
+ if (!/^https?:\/\/[^\s]+$/i.test(reg)) return { ok: false, code: 'BAD_REGISTRY', error: 'registry 地址无效' }
218
+ env.npm_config_registry = reg
219
+ }
220
+ return { ok: true, command: process.execPath, args: [cli, 'plugin', '--profile', name, 'add', packageName], env, profile: name, packageName }
221
+ }
222
+
223
+ /** 升级前必须满足的条件;返回拒绝原因或空串。 */
224
+ export function upgradeBlocker({ report, busy = {}, upgrading = false, runningSend = false } = {}) {
225
+ if (upgrading) return '已经有一个升级在进行,请等它结束'
226
+ if (!report || report.ok === false) return '还没成功检查到可用版本,先点「检查更新」'
227
+ if (!report.outdated) return report.ahead ? '当前版本已经比 registry 上的新,不需要升级' : '已经是最新版本'
228
+ if (!report.canUpgrade) return (report.install && report.install.reason) || '当前安装方式不支持自更新'
229
+ if (runningSend) return '刷站任务正在运行,等它停下再升级(避免跑着的进程读到半新半旧的代码)'
230
+ const active = Object.entries(busy || {}).filter(([, value]) => value === true).map(([key]) => key)
231
+ if (active.length) return `有任务在运行(${active.join(' / ')}),结束之后再升级`
232
+ return ''
233
+ }
234
+
235
+ /** 从升级进程输出里认出结果,给界面一句人话。 */
236
+ export function summarizeUpgrade({ code, output = '', timedOut = false, latest = '' } = {}) {
237
+ const text = String(output || '')
238
+ if (timedOut) return { ok: false, code: 'TIMEOUT', message: '升级超时(可能网络慢),请查看日志;已安装的版本没有被删除' }
239
+ const clean = text.replace(/\[[-=]{4,}\]/g, '').trim()
240
+ const tail = clean.split(/\r?\n/).filter(Boolean).slice(-6).join(' / ').slice(0, 400)
241
+ if (code === 0) return { ok: true, code: 'DONE', message: `已安装${latest ? ' ' + latest : '新版本'};完全重启 DSH 后生效`, tail }
242
+ const known = [
243
+ [/E404|404 Not Found/i, 'registry 上找不到这个版本(可能还没发布成功)'],
244
+ [/E401|E403|ENEEDAUTH|Unauthorized/i, '没有发布/读取权限,检查 npm 登录状态'],
245
+ [/EBUSY|EPERM|EACCES/i, '文件被占用(通常是 DSH 还在用旧文件);完全退出 DSH 后重试'],
246
+ [/ENOTFOUND|ETIMEDOUT|ECONNREFUSED|network/i, '网络连不上 registry,可换镜像或稍后再试'],
247
+ [/EINTEGRITY|integrity/i, '包完整性校验失败,可能被镜像缓存污染,清缓存后重试'],
248
+ ]
249
+ for (const [pattern, message] of known) if (pattern.test(clean)) return { ok: false, code: 'FAILED', message, tail }
250
+ return { ok: false, code: 'FAILED', message: `升级没有成功(退出码 ${code})${tail ? ':' + tail : ''}`, tail }
251
+ }
252
+
253
+ /** 把 npm 退出码/输出整理成可读日志,供界面与 duty.log 共用。 */
254
+ export function upgradeLogLine({ phase, at = Date.now(), detail = '' } = {}) {
255
+ const stamp = new Date(num(at, Date.now())).toISOString()
256
+ return `${stamp} [update:${phase}] ${String(detail).slice(0, 2000)}`
257
+ }
258
+
259
+ /** 目录是否像一份完整安装(用于升级后自检,避免把半截目录当成功)。 */
260
+ export function looksLikeInstall(dir) {
261
+ try {
262
+ if (!statSync(dir).isDirectory()) return false
263
+ return ['package.json', 'index.js', 'client.js', 'selfcheck.mjs'].every((name) => existsSync(join(dir, name)))
264
+ } catch { return false }
265
+ }
package/lib/uvfetch.mjs CHANGED
@@ -1,179 +1,191 @@
1
- /**
2
- * lib/uvfetch.mjs —— 「机器上连 uv 和 Python 都没有」时,**插件自己把 uv 弄到手**
3
- * ============================================================================
4
- * 为什么值得单独做:uv 是**单个可执行文件**(Windows 上 ~32MB 的 uv.exe ✓),
5
- * **解压就能用、不用管理员、不用进 PATH** ✓,而且它还能自己把 Python 拉下来 ✓
6
- * ⇒ 只要拿到它,"首次安装"就从「先去装个 Python」变成「点一下就好」✓
7
- *
8
- * 两个源,按顺序试:
9
- * ① **GitHub 官方 release**(`uv-x86_64-pc-windows-msvc.zip`)—— 有加速器时最快 ✓
10
- * ② ⭐ **PyPI 清华镜像的 uv wheel** —— wheel 本身就是个 zip,里面装着 `uv.exe` ✓
11
- * (国内速度快、不依赖 GitHub ✓)
12
- *
13
- * 解压:优先用 **系统自带的 `tar`**(Windows 10+ 的 bsdtar 能直接读 zip ✓),
14
- * 再退到 `unzip`;然后**在解压出来的整棵树里按文件名找** ✓
15
- * —— 不管官方把它放在哪一层都能找着 ✓
16
- * ============================================================================
17
- */
18
- import { spawnSync } from 'node:child_process'
19
- import { createWriteStream, existsSync, mkdirSync, readdirSync, statSync, copyFileSync, chmodSync, rmSync, readFileSync, renameSync } from 'node:fs'
20
- import { createHash } from 'node:crypto'
21
- import { join } from 'node:path'
22
- import { Readable } from 'node:stream'
23
- import { pipeline } from 'node:stream/promises'
1
+ /** Verified, bounded uv bootstrap. User data and install artifacts stay outside the package. */
2
+ import { execFile } from 'node:child_process'
3
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, copyFileSync, chmodSync, rmSync, readFileSync, renameSync } from 'node:fs'
4
+ import { randomUUID, createHash } from 'node:crypto'
5
+ import { join, resolve } from 'node:path'
6
+ import { open, unlink } from 'node:fs/promises'
24
7
 
25
- const ASSETS = {
26
- win32: { name: 'uv-x86_64-pc-windows-msvc.zip', bin: 'uv.exe' },
27
- darwin: { name: 'uv-aarch64-apple-darwin.tar.gz', bin: 'uv' },
28
- linux: { name: 'uv-x86_64-unknown-linux-gnu.tar.gz', bin: 'uv' },
29
- }
30
-
31
- /** 算一个文件的 sha256 */
32
- function sha256Of(file) {
33
- return createHash('sha256').update(readFileSync(file)).digest('hex')
34
- }
8
+ const inflight = new Map()
9
+ const MAX_ARCHIVE = 192 * 1024 * 1024
10
+ const SHA = /^[0-9a-f]{64}$/i
11
+ const nativeRun = (command, args, options = {}) => new Promise(resolveRun => {
12
+ execFile(command, args, { timeout: 15_000, maxBuffer: 1024 * 1024, windowsHide: true, ...options }, error => {
13
+ resolveRun({ status: error ? error.code || 1 : 0, error })
14
+ })
15
+ })
35
16
 
36
- /**
37
- * 取"官方期望的 sha256"(2026-10-26 收紧):
38
- * · PyPI wheel:镜像 JSON 里自带的 `digests.sha256`(拿不到 ⇒ 这个源作废,不再"跳过校验")
39
- * · GitHub 资产:**永远从 GitHub 官方地址**取 `<文件名>.sha256` ——
40
- * ⚠️ 不能跟着下载走同一个代理:恶意代理可以把压缩包和校验值一起换掉,
41
- * 那份"校验"只是给使用者虚假的安全感。取不到官方值 ⇒ 拒绝使用这个源。
42
- */
43
- async function expectedSha(source, officialUrl) {
44
- if (source.sha) return String(source.sha).toLowerCase()
45
- try {
46
- const r = await fetch(String(officialUrl || source.url) + '.sha256')
47
- if (!r.ok) return null
48
- const m = /([0-9a-f]{64})/i.exec(await r.text())
49
- return m ? m[1].toLowerCase() : null
50
- } catch { return null }
17
+ export function uvAssetFor(platform = process.platform, arch = process.arch, libc = 'gnu') {
18
+ const cpu = { x64: 'x86_64', arm64: 'aarch64', ia32: 'i686' }[arch]
19
+ if (!cpu) return null
20
+ if (platform === 'win32') return { name: `uv-${cpu}-pc-windows-msvc.zip`, bin: 'uv.exe', wheel: { x64: 'win_amd64', arm64: 'win_arm64', ia32: 'win32' }[arch] }
21
+ if (platform === 'darwin' && arch !== 'ia32') return { name: `uv-${cpu}-apple-darwin.tar.gz`, bin: 'uv' }
22
+ if (platform === 'linux') return { name: `uv-${cpu}-unknown-linux-${libc === 'musl' ? 'musl' : 'gnu'}.tar.gz`, bin: 'uv' }
23
+ return null
51
24
  }
52
25
 
53
- /** 在目录树里按文件名找可执行文件(递归) */
54
26
  export function findBin(name, dir) {
55
- let entries = []
27
+ let entries
56
28
  try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return null }
57
- for (const e of entries) {
58
- const p = join(dir, e.name)
59
- if (e.isDirectory()) { const hit = findBin(name, p); if (hit) return hit }
60
- else if (e.name === name) return p
29
+ for (const entry of entries) {
30
+ if (entry.isSymbolicLink()) continue
31
+ const path = join(dir, entry.name)
32
+ if (entry.isDirectory()) { const hit = findBin(name, path); if (hit) return hit }
33
+ else if (entry.isFile() && entry.name === name) return path
61
34
  }
62
35
  return null
63
36
  }
64
37
 
65
- /** 解压一个压缩包并找出目标文件(tar 优先 —— Windows 自带那份能读 zip) */
66
- export function extractArchive(binName, archive, into) {
38
+ export async function extractArchive(name, archive, into, run = nativeRun) {
67
39
  const attempts = [['tar', ['-xf', archive, '-C', into]], ['unzip', ['-o', archive, '-d', into]]]
68
- for (const [cmd, args] of attempts) {
40
+ for (const [command, args] of attempts) {
69
41
  try {
70
- const r = spawnSync(cmd, args, { stdio: 'ignore' })
71
- if (r.status === 0) { const hit = findBin(binName, into); if (hit) return hit }
72
- } catch { /* 换下一个工具 */ }
42
+ const result = await run(command, args, { timeout: 90_000 })
43
+ if (result.status === 0) { const hit = findBin(name, into); if (hit) return hit }
44
+ } catch { /* An unavailable extractor must not hide the next supported one. */ }
73
45
  }
74
46
  return null
75
47
  }
76
48
 
77
- /**
78
- * 确保家目录里有一份可用的 uv,返回它的路径(拿不到就返回 null)。
79
- * @param {{ dir: string, log?: (m: string) => void }} opts
80
- */
81
- export async function ensureUv({ dir, log = () => {} }) {
82
- const a = ASSETS[process.platform] || ASSETS.linux
83
- const target = join(dir, a.bin)
84
- // ⭐ 2026-10-26:已存在的目标先验一下能不能跑 —— 上次中断/被 AV 拦下的半截文件不该永远被当可用
85
- if (existsSync(target)) {
86
- let runnable = false
87
- try { const r = spawnSync(target, ['--version'], { stdio: 'ignore', timeout: 15_000 }); runnable = r.status === 0 } catch { runnable = false }
88
- if (runnable) return target
89
- log(' → 家里已有的 uv 跑不起来(上次没下完?被安全软件动过?)⇒ 重新弄一份')
90
- try { rmSync(target, { force: true }) } catch { /* 删不掉就走下面的覆盖 */ }
49
+ /** The deadline covers headers and the whole streamed response, including slow bodies. */
50
+ async function consumeUvResponse(url, { fetchImpl = globalThis.fetch, timeoutMs = 120_000, maxBytes = MAX_ARCHIVE } = {}, consume) {
51
+ const controller = new AbortController()
52
+ const timer = setTimeout(() => controller.abort(new Error('UV_DOWNLOAD_TIMEOUT')), timeoutMs)
53
+ try {
54
+ const response = await fetchImpl(url, { signal: controller.signal, redirect: 'follow' })
55
+ if (!response.ok) throw new Error('UV_HTTP_' + response.status)
56
+ if (Number(response.headers.get('content-length')) > maxBytes) throw new Error('UV_DOWNLOAD_TOO_LARGE')
57
+ if (!response.body) throw new Error('UV_EMPTY_RESPONSE')
58
+ let total = 0
59
+ for await (const chunk of response.body) {
60
+ total += chunk.length
61
+ if (total > maxBytes) { controller.abort(); throw new Error('UV_DOWNLOAD_TOO_LARGE') }
62
+ await consume(chunk)
63
+ }
64
+ if (!total) throw new Error('UV_EMPTY_RESPONSE')
65
+ return total
66
+ } finally { clearTimeout(timer); controller.abort() }
67
+ }
68
+
69
+ export async function fetchUvBytes(url, options = {}) {
70
+ const chunks = []
71
+ const length = await consumeUvResponse(url, { maxBytes: 8 * 1024 * 1024, ...options }, chunk => { chunks.push(Buffer.from(chunk)) })
72
+ return Buffer.concat(chunks, length)
73
+ }
74
+
75
+ /** Large binaries stream to a private exclusive file rather than buffering hundreds of MB in the host. */
76
+ export async function downloadUvArchive(url, file, options = {}) {
77
+ const handle = await open(file, 'wx', 0o600)
78
+ const digest = createHash('sha256')
79
+ let complete = false
80
+ try {
81
+ await consumeUvResponse(url, options, async chunk => {
82
+ digest.update(chunk)
83
+ let offset = 0
84
+ while (offset < chunk.length) {
85
+ const { bytesWritten } = await handle.write(chunk, offset, chunk.length - offset)
86
+ if (!bytesWritten) throw new Error('UV_DOWNLOAD_WRITE_FAILED')
87
+ offset += bytesWritten
88
+ }
89
+ })
90
+ await handle.sync()
91
+ complete = true
92
+ return digest.digest('hex')
93
+ } finally {
94
+ await handle.close()
95
+ if (!complete) try { await unlink(file) } catch { /* Only this newly created file is ours. */ }
91
96
  }
97
+ }
92
98
 
93
- mkdirSync(dir, { recursive: true })
94
- const tmp = join(dir, '_tmp')
95
- rmSync(tmp, { recursive: true, force: true })
96
- mkdirSync(tmp, { recursive: true })
99
+ function linuxLibc() {
100
+ if (process.platform !== 'linux') return 'gnu'
101
+ try { return process.report.getReport().header.glibcVersionRuntime ? 'gnu' : 'musl' } catch { return 'gnu' }
102
+ }
97
103
 
98
- // 源按顺序试:直连 → 两个国内能用的 GitHub 加速代理(2026-10-14 实测这两条能出真 zip ✓)
99
- // → 最后兜底是 PyPI 镜像上的 uv wheel(⚠️ 那个停在 0.2.33/2024-08,能用但不是最新 ✗)
100
- const asset = `https://github.com/astral-sh/uv/releases/latest/download/${a.name}`
104
+ export function officialUvWheel(metadata, wheel) {
105
+ const candidates = (Array.isArray(metadata?.urls) ? metadata.urls : []).filter(entry => {
106
+ const filename = String(entry.filename || '')
107
+ if (!new RegExp('^uv-\\d+\\.\\d+\\.\\d+-py3-none-' + wheel + '\\.whl$').test(filename) || !SHA.test(String(entry.digests?.sha256 || ''))) return false
108
+ try { const url = new URL(entry.url); return url.protocol === 'https:' && url.hostname === 'files.pythonhosted.org' && !url.username && !url.password && !url.port } catch { return false }
109
+ })
110
+ candidates.sort((a, b) => {
111
+ const x = a.filename.split('-')[1].split('.').map(Number), y = b.filename.split('-')[1].split('.').map(Number)
112
+ return y[0] - x[0] || y[1] - x[1] || y[2] - x[2]
113
+ })
114
+ return candidates[0] || null
115
+ }
116
+
117
+ async function installUv({ dir, log, asset, fetchImpl, run, timeoutMs }) {
118
+ const target = join(dir, asset.bin)
119
+ const usable = async path => {
120
+ try { return (await run(path, ['--version'], { timeout: 15_000 })).status === 0 } catch { return false }
121
+ }
122
+ if (existsSync(target) && await usable(target)) return target
123
+ mkdirSync(dir, { recursive: true })
124
+ // Each installer owns its extraction tree and staging file; concurrent hosts
125
+ // cannot delete another installer's archive or partially overwrite uv.exe.
126
+ const tmp = mkdtempSync(join(dir, '.uv-install-'))
127
+ const staged = `${target}.${process.pid}.${randomUUID()}.staging`
128
+ const official = `https://github.com/astral-sh/uv/releases/latest/download/${asset.name}`
101
129
  const sources = [
102
- { label: 'GitHub 直连', url: asset, file: join(tmp, a.name) },
103
- { label: 'GitHub 加速代理 gh-proxy.com', url: `https://gh-proxy.com/${asset}`, file: join(tmp, a.name), official: asset },
104
- { label: 'GitHub 加速代理 ghproxy.net', url: `https://ghproxy.net/${asset}`, file: join(tmp, a.name), official: asset },
130
+ { label: 'GitHub 官方', url: official, file: asset.name },
131
+ { label: 'GitHub 下载代理 gh-proxy.com', url: `https://gh-proxy.com/${official}`, file: asset.name },
132
+ { label: 'GitHub 下载代理 ghproxy.net', url: `https://ghproxy.net/${official}`, file: asset.name },
105
133
  ]
106
- if (process.platform === 'win32') {
107
- try {
108
- const r = await fetch('https://pypi.tuna.tsinghua.edu.cn/pypi/uv/json')
109
- const j = await r.json()
110
- // ⚠️ PyPI 的 urls 顺序**不保证最新在前** ⇒ 必须按版本号自己排序挑最新(踩过:取到了 0.2.33 的老版本 ✗)
111
- const verOf = (name) => { const m = /-(\d+\.\d+\.\d+(?:[.-][0-9A-Za-z.]+)?)-py3-none-win_amd64\.whl$/.exec(String(name)); return m ? m[1] : '0.0.0' }
112
- const cmpVer = (a, b) => {
113
- const A = verOf(a).split(/[.-]/).map(Number), B = verOf(b).split(/[.-]/).map(Number)
114
- for (let i = 0; i < 4; i++) { if ((A[i] || 0) !== (B[i] || 0)) return (B[i] || 0) - (A[i] || 0) }
115
- return 0
116
- }
117
- const w = (j.urls || [])
118
- .filter((u) => /win_amd64\.whl$/.test(String(u.filename)))
119
- .sort((x, y) => cmpVer(x.filename, y.filename))[0]
120
- // ⭐ 2026-10-26:镜像 JSON 里的文件名要消毒后再拼路径(`..\x` / 绝对分量不能进 tmp),
121
- // 且必须自带 sha256 —— 没有 digest 的源直接不用(fail closed,见下面的校验段)
122
- if (w?.url && /^[\w.-]+$/.test(String(w.filename)) && w.digests?.sha256) {
123
- sources.push({ label: 'PyPI 清华镜像(uv wheel)', url: w.url, file: join(tmp, String(w.filename)), sha: w.digests.sha256 })
124
- log(` (备选源已就绪:${w.filename})`)
125
- }
126
- } catch (e) {
127
- log(` 列 PyPI 版本时出错(不影响,继续试别的源):${e?.message ?? e}`)
134
+ const bytes = (url, limit = MAX_ARCHIVE, deadline = timeoutMs) => fetchUvBytes(url, { fetchImpl, maxBytes: limit, timeoutMs: deadline })
135
+ let githubDigest
136
+ try {
137
+ if (asset.wheel) {
138
+ try {
139
+ // Trust metadata from PyPI itself, never a mirror that can replace both
140
+ // a wheel and its claimed checksum. Download hosts are restricted too.
141
+ const metadata = JSON.parse((await bytes('https://pypi.org/pypi/uv/json', 8 * 1024 * 1024, 20_000)).toString())
142
+ const wheel = officialUvWheel(metadata, asset.wheel)
143
+ if (wheel) sources.push({ label: 'PyPI 官方 wheel', url: wheel.url, file: wheel.filename, sha: wheel.digests.sha256.toLowerCase() })
144
+ } catch (error) { log('PyPI 元数据不可用,继续其他来源:' + String(error.message)) }
128
145
  }
129
- }
130
-
131
- for (const s of sources) {
132
- try {
133
- log(`正在下载 uv —— ${s.label}`)
134
- const res = await fetch(s.url)
135
- if (!res.ok) { log(` → 返回 ${res.status},换下一个源`); continue }
136
- await pipeline(Readable.fromWeb(res.body), createWriteStream(s.file))
137
- const mb = (statSync(s.file).size / 1048576).toFixed(1)
138
- log(` → 下载完成 ${mb} MB`)
139
- // ⭐ 完整性校验:下载下来是要**被执行**的,必须跟官方 sha256 对上(防的是第三方代理动过包 ✗)
140
- // 2026-10-26 起从"拿不到就算了"改成 **fail closed**:拿不到官方校验值 ⇒ 这个源作废,
141
- // 绝不执行未校验的二进制(uv 会拉起整个 Python venv,投毒等于完整 RCE 链)。
142
- const want = await expectedSha(s, s.official || asset)
143
- if (!want) {
144
- log(' → 拿不到官方 sha256 ⇒ 拒绝使用这个源(不执行未校验的二进制)')
145
- continue
146
- }
147
- const gotSha = sha256Of(s.file)
148
- if (gotSha !== want) {
149
- log(` → ⚠️ sha256 校验不过(期望 ${want.slice(0, 12)}… 实际 ${gotSha.slice(0, 12)}…)⇒ 丢弃这个源`)
150
- continue
151
- }
152
- log(' → 官方 sha256 校验通过 ✓')
153
- log(' → 开始解压')
154
- const hit = extractArchive(a.bin, s.file, tmp)
155
- if (!hit) { log(' → 解压后没找到可执行文件,换下一个源'); continue }
156
- // ⭐ 原子落盘(2026-10-26):直接 copyFileSync 被中断会留半截 uv.exe,
157
- // 下次启动 `existsSync(target)` 又把它当好的(上面的可跑检查只能兜住一部分)
158
- const staging = `${target}.staging`
159
- copyFileSync(hit, staging)
160
- try { chmodSync(staging, 0o755) } catch { /* Windows 上不需要 */ }
161
- renameSync(staging, target)
146
+ for (const source of sources) {
162
147
  try {
163
- const r = spawnSync(target, ['--version'], { stdio: 'ignore', timeout: 15_000 })
164
- if (r.status !== 0) { log(' → 下载的 uv 跑不起来 ✗(校验过了但执行失败——多半是安全软件拦了)'); continue }
165
- log(' → 版本检查:能跑 ✓')
166
- } catch { /* 跑不起来也别拦着,后面自己会报错 */ }
167
- rmSync(tmp, { recursive: true, force: true })
168
- log(` → uv 已就位:${target}`)
169
- return target
170
- } catch (e) {
171
- log(` → ${s.label} 失败:${e?.message ?? e}`)
148
+ log('正在下载 uv:' + source.label)
149
+ if (!source.sha && githubDigest === undefined) {
150
+ try {
151
+ const text = (await bytes(official + '.sha256', 4096, 20_000)).toString()
152
+ githubDigest = /\b([0-9a-f]{64})\b/i.exec(text)?.[1]?.toLowerCase() || null
153
+ } catch { githubDigest = null }
154
+ }
155
+ const wanted = source.sha || githubDigest
156
+ if (!SHA.test(String(wanted || ''))) { log('缺少官方 SHA-256,已跳过该来源'); continue }
157
+ const file = join(tmp, randomUUID() + '-' + source.file)
158
+ const digest = await downloadUvArchive(source.url, file, { fetchImpl, timeoutMs, maxBytes: MAX_ARCHIVE })
159
+ if (digest !== wanted) { log('SHA-256 不一致,已拒绝该文件'); continue }
160
+ const into = mkdtempSync(join(tmp, 'extract-'))
161
+ const executable = await extractArchive(asset.bin, file, into, run)
162
+ if (!executable) { log('归档中缺少可执行文件'); continue }
163
+ copyFileSync(executable, staged)
164
+ try { chmodSync(staged, 0o755) } catch { /* Windows permissions differ. */ }
165
+ if (!await usable(staged)) { log('uv 版本检查失败,未替换目标文件'); continue }
166
+ renameSync(staged, target)
167
+ if (!await usable(target)) { log('uv 安装后不可执行'); continue }
168
+ log('uv 已通过官方校验和版本检查')
169
+ return target
170
+ } catch (error) { log(source.label + ' 失败:' + String(error.message)) }
172
171
  }
172
+ log('未获得通过官方校验且可执行的 uv。可手动安装 Python/uv 后重试。')
173
+ return null
174
+ } finally {
175
+ try { rmSync(staged, { force: true }) } catch { /* Never remove another installer staging file. */ }
176
+ rmSync(tmp, { recursive: true, force: true })
173
177
  }
174
-
175
- rmSync(tmp, { recursive: true, force: true })
176
- log('所有源都没拿到**通过官方校验**的 uv(网络到不了 GitHub / PyPI 镜像,或代理给的包校验不过)。'
177
- + '可以手动下载 uv 放进上面那个目录,或先在机器上装好 uv 再点安装。')
178
- return null
179
178
  }
179
+
180
+ /** Optional I/O implementations make install failures testable without executing a downloaded binary. */
181
+ export function ensureUv({ dir, log = () => {}, fetchImpl = globalThis.fetch, run = nativeRun,
182
+ platform = process.platform, arch = process.arch, libc = linuxLibc(), timeoutMs = 120_000 }) {
183
+ const asset = uvAssetFor(platform, arch, libc)
184
+ if (!asset) { log(`当前平台不支持自动下载 uv:${platform}/${arch}`); return Promise.resolve(null) }
185
+ const destination = resolve(dir)
186
+ const key = destination + '/' + asset.name
187
+ if (inflight.has(key)) return inflight.get(key)
188
+ const pending = installUv({ dir: destination, log, asset, fetchImpl, run, timeoutMs }).finally(() => inflight.delete(key))
189
+ inflight.set(key, pending)
190
+ return pending
191
+ }