dsh-deepmoyu 2.3.0 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/AGENTS.md +91 -153
  2. package/CHANGELOG.md +27 -0
  3. package/CONTRIBUTING.md +51 -0
  4. package/CONTRIBUTORS.md +3 -7
  5. package/NOTICE.md +3 -3
  6. package/README.md +141 -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 +1348 -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 +953 -270
  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/uvfetch.mjs +165 -153
  54. package/lib/verify.mjs +111 -127
  55. package/marketplace/pyyxzi__dsh-deepmoyu.yml +7 -0
  56. package/package.json +23 -12
  57. package/presets/deepmoyu.patch.yml +1 -1
  58. package/prompts/duty.md +4 -3
  59. package/screenshots.json +5 -0
  60. package/selfcheck.mjs +21 -6
  61. package/vendor/bilibili-mcp/requirements.in +9 -0
  62. package/vendor/bilibili-mcp/requirements.txt +1936 -11
  63. package/vendor/bilibili-mcp/runtime_guard.py +35 -10
  64. package/lib/__pycache__/pv.cpython-310.pyc +0 -0
  65. package/lib/__pycache__/pv_cover.cpython-310.pyc +0 -0
  66. package/tests/automation-routes.test.mjs +0 -183
  67. package/tests/automation.test.mjs +0 -79
  68. package/tests/background-tools.test.mjs +0 -182
  69. package/tests/background-window.test.mjs +0 -75
  70. package/tests/dup-cross-channel.test.mjs +0 -158
  71. package/tests/manual-browsing.test.mjs +0 -671
  72. package/tests/mcp-restart.test.mjs +0 -131
  73. package/tests/memory-chain.test.mjs +0 -413
  74. package/tests/model-relay.test.mjs +0 -116
  75. package/tests/persona.test.mjs +0 -67
  76. package/tests/pet-run-routes.test.mjs +0 -127
  77. package/tests/pet-run-settings.test.mjs +0 -38
  78. package/tests/pet-session.test.mjs +0 -125
  79. package/tests/pv-stage1.test.mjs +0 -203
  80. package/tests/sticker-routes.test.mjs +0 -141
  81. package/tests/sticker-upload-routes.test.mjs +0 -97
  82. package/tests/sticker-upload.test.mjs +0 -122
  83. package/tests/sticker.test.mjs +0 -169
  84. package/tests/test_guard_cross_channel.py +0 -141
  85. package/tests/test_pv_cover.py +0 -95
  86. package/tests/test_pv_stage1.py +0 -250
  87. package/tests/test_reply_pictures.py +0 -255
  88. package/tests/token-ledger.test.mjs +0 -78
  89. package/tests/verify_pv_sample.py +0 -91
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
+ }