@noob-stupid/dsh-plugin-console 0.3.67 → 0.4.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 (48) hide show
  1. package/lib/client.js +126 -31
  2. package/lib/index.js +60 -9687
  3. package/lib/server/domain/ai-run.js +479 -0
  4. package/lib/server/domain/ai.js +246 -0
  5. package/lib/server/domain/compat.js +474 -0
  6. package/lib/server/domain/components.js +108 -0
  7. package/lib/server/domain/dep-source.js +122 -0
  8. package/lib/server/domain/format-contract.js +265 -0
  9. package/lib/server/domain/format-scan.js +431 -0
  10. package/lib/server/domain/framework.js +393 -0
  11. package/lib/server/domain/install-job.js +561 -0
  12. package/lib/server/domain/install.js +599 -0
  13. package/lib/server/domain/jobs.js +28 -0
  14. package/lib/server/domain/market.js +409 -0
  15. package/lib/server/domain/patch.js +203 -0
  16. package/lib/server/domain/presets.js +93 -0
  17. package/lib/server/domain/quarantine.js +224 -0
  18. package/lib/server/domain/release-source.js +504 -0
  19. package/lib/server/domain/repoland.js +119 -0
  20. package/lib/server/domain/revoke.js +184 -0
  21. package/lib/server/domain/runtime.js +118 -0
  22. package/lib/server/domain/selfupdate.js +319 -0
  23. package/lib/server/domain/skills.js +234 -0
  24. package/lib/server/domain/sources.js +297 -0
  25. package/lib/server/domain/suite.js +220 -0
  26. package/lib/server/infra/exec.js +98 -0
  27. package/lib/server/infra/fsx.js +163 -0
  28. package/lib/server/infra/fw-integrity-check.js +37 -0
  29. package/lib/server/infra/http.js +373 -0
  30. package/lib/server/infra/httpd.js +51 -0
  31. package/lib/server/infra/mask.js +19 -0
  32. package/lib/server/infra/paths.js +177 -0
  33. package/lib/server/infra/semver.js +168 -0
  34. package/lib/server/routes/ai.js +172 -0
  35. package/lib/server/routes/components.js +254 -0
  36. package/lib/server/routes/framework-preflight.js +154 -0
  37. package/lib/server/routes/framework-upgrade.js +679 -0
  38. package/lib/server/routes/framework.js +544 -0
  39. package/lib/server/routes/github-login.js +198 -0
  40. package/lib/server/routes/index.js +128 -0
  41. package/lib/server/routes/install.js +116 -0
  42. package/lib/server/routes/market.js +415 -0
  43. package/lib/server/routes/plugins.js +562 -0
  44. package/lib/server/routes/skills.js +107 -0
  45. package/lib/server/routes/sources.js +437 -0
  46. package/lib/server/routes/state.js +125 -0
  47. package/lib/server/state.js +22 -0
  48. package/package.json +1 -1
@@ -0,0 +1,599 @@
1
+ // L1 · domain —— install.js(插件安装/卸载全链路:pnpm / curl 手动 / GitHub Release 三通道 + 包盒校验 + 套装补丁完整性;分层 Step 5 从 lib/index.js 搬出,只搬移未改逻辑)
2
+ // 分组见 D:\dsh\dsh-plugin-hub-plan\architecture.zh.md 三
3
+
4
+ import { readFileSync, writeFileSync, existsSync, rmSync, readdirSync, mkdirSync } from 'node:fs'
5
+ import { readFile, writeFile } from 'node:fs/promises'
6
+ import { execFile } from 'node:child_process'
7
+ import { dirname, join, resolve } from 'node:path'
8
+ import { tmpdir } from 'node:os'
9
+ import { sanitizePatchText } from './patch.js'
10
+ import { orderedRegistries, readSources } from './sources.js'
11
+ import { execFileAsync, runPnpmWithFallback } from '../infra/exec.js'
12
+ import { copyTree, queuedWrite } from '../infra/fsx.js'
13
+ import { fetchJsonUrl } from '../infra/http.js'
14
+ import { dshHome, resolvePackageJson } from '../infra/paths.js'
15
+ import { downloadReleaseArtifact, releaseInstallTarget, selectReleaseInstall } from './release-source.js'
16
+
17
+ /** bundle 包判定:声明 dsh.bundle 的包一律按官方 `dsh plugin add` 行为追加为
18
+ * profile bundle 层(其 cordis.patch.yml 的插入行在下次启动时组合进树)。
19
+ * 无论有没有 JS 入口都走 bundle 层——皮肤包(无入口)与 web-ui-settings
20
+ * (有入口)都是这样安装的,当作插件条目 insert 会漏掉它们的 bundle 补丁。 */
21
+ async function detectBundleOnly(profileDir, packageName) {
22
+ try {
23
+ const pkgPath = resolvePackageJson(packageName, profileDir)
24
+ if (pkgPath === null) throw new Error('not found')
25
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
26
+ return typeof pkg.dsh?.bundle?.patch === 'string'
27
+ } catch {
28
+ return false
29
+ }
30
+ }
31
+
32
+ /** 把包追加进 profile 的 dsh.profile.bundles 层(与官方 dsh plugin add 的 reconcile 一致)。 */
33
+ async function addBundleToManifest(profileDir, packageName) {
34
+ return queuedWrite(async () => {
35
+ const manifestPath = join(profileDir, 'package.json')
36
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
37
+ const bundles = manifest.dsh?.profile?.bundles ?? []
38
+ if (!bundles.includes(packageName)) {
39
+ bundles.push(packageName)
40
+ manifest.dsh = { ...(manifest.dsh ?? {}), profile: { ...(manifest.dsh?.profile ?? {}), bundles } }
41
+ await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8')
42
+ }
43
+ })
44
+ }
45
+
46
+ /** 官方 profile 模板自带的 bundle(其余 bundle 视为用户额外添加)。 */
47
+ const DEFAULT_BUNDLES = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']
48
+
49
+ /** 读取用户额外 bundle 的插入行归属表:行 id / 包名 → 所属 bundle 包名。 */
50
+ async function readExtraBundleOwners(profileDir) {
51
+ const owners = new Map()
52
+ try {
53
+ const manifest = JSON.parse(await readFile(join(profileDir, 'package.json'), 'utf8'))
54
+ const bundles = manifest.dsh?.profile?.bundles ?? []
55
+ for (const pkg of bundles) {
56
+ if (DEFAULT_BUNDLES.includes(pkg)) continue
57
+ try {
58
+ const pk = resolvePackageJson(pkg, profileDir)
59
+ const dir = dirname(pk ?? join(profileDir, 'node_modules', ...String(pkg).split('/'), 'package.json'))
60
+ const text = await readFile(join(dir, 'cordis.patch.yml'), 'utf8')
61
+ const lines = text.split(/\r?\n/u)
62
+ let inInsert = false
63
+ for (let index = 0; index < lines.length; index += 1) {
64
+ const line = lines[index]
65
+ if (/^- insert:\s*$/u.test(line)) {
66
+ inInsert = true
67
+ continue
68
+ }
69
+ if (/^- /u.test(line)) inInsert = false
70
+ if (!inInsert) continue
71
+ const idMatch = line.match(/^ {4}- id: ([A-Za-z0-9_.-]+)\s*$/u)
72
+ if (!idMatch) continue
73
+ owners.set(idMatch[1], pkg)
74
+ const nameMatch = (lines[index + 1] ?? '').match(/^ {6}name: ['"]([^'"]+)['"]\s*$/u)
75
+ if (nameMatch) owners.set(nameMatch[1], pkg)
76
+ }
77
+ } catch {}
78
+ }
79
+ } catch {}
80
+ return owners
81
+ }
82
+
83
+ /** 从 profile manifest 移除一个 bundle。 */
84
+ async function removeBundleFromManifest(profileDir, bundlePkg) {
85
+ return queuedWrite(async () => {
86
+ const manifestPath = join(profileDir, 'package.json')
87
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
88
+ const bundles = manifest.dsh?.profile?.bundles ?? []
89
+ const next = bundles.filter((name) => name !== bundlePkg)
90
+ if (next.length !== bundles.length) {
91
+ manifest.dsh = { ...(manifest.dsh ?? {}), profile: { ...(manifest.dsh?.profile ?? {}), bundles: next } }
92
+ await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8')
93
+ }
94
+ })
95
+ }
96
+
97
+ /**
98
+ * 读取 dsh-github-login(独立登录工具)写入的 GitHub 令牌文件。
99
+ * 只对外暴露登录状态(login),绝不下发令牌本身。
100
+ */
101
+ function readGithubAuth() {
102
+ try {
103
+ const data = JSON.parse(readFileSync(join(dshHome(), 'github-auth.json'), 'utf8'))
104
+ if (data && typeof data.token === 'string' && data.token) {
105
+ return { loggedIn: true, login: typeof data.login === 'string' && data.login && data.login !== 'unknown' ? data.login : null, token: data.token }
106
+ }
107
+ } catch {}
108
+ return { loggedIn: false, login: null, token: null }
109
+ }
110
+
111
+ /**
112
+ * 插件安装:与官方 `dsh plugin add` 使用同一管理器——corepack → pnpm add。
113
+ * profile 目录由 pnpm 管理;若用 npm 写入会与 pnpm 的目录重建互相破坏
114
+ * (曾导致入口链接丢失、DSH 启动崩溃)。registry 走国内镜像。
115
+ */
116
+ async function pnpmInstall(profileDir, spec, registry = 'https://registry.npmmirror.com', timeout = 90000, signal = null) {
117
+ const args = ['add', spec, '--registry', registry]
118
+ const opts = {
119
+ cwd: profileDir,
120
+ timeout,
121
+ windowsHide: true,
122
+ maxBuffer: 4 * 1024 * 1024,
123
+ env: {
124
+ ...process.env,
125
+ COREPACK_NPM_REGISTRY: registry,
126
+ // git 通道禁止交互式凭据:避免 Git Credential Manager 弹登录窗(匿名失败即静默失败)
127
+ GIT_TERMINAL_PROMPT: '0',
128
+ GCM_INTERACTIVE: 'never',
129
+ },
130
+ }
131
+ // 跨平台定位 corepack/pnpm(Windows 布局 / Linux npm 全局布局 / PATH 兜底),
132
+ // 旧代码只认 Windows 布局,Linux 上会生成 MODULE_NOT_FOUND 的命令(2026-09-20 事故)
133
+ await runPnpmWithFallback(args, { execOpts: opts })
134
+ }
135
+
136
+ /**
137
+ * curl 手动安装通道:node 网络黑洞(pnpm 下载卡死:socket hang up / TIMEOUT / downloaded 0)时,
138
+ * curl 与系统 tar 仍可用——用 curl 下载 registry tarball、解压到 profile 的 node_modules。
139
+ * 零依赖包可完整安装;带依赖包记录未补齐列表(不阻塞,供面板提示)。
140
+ * 返回 { version, missingDeps };失败抛错由调用方落入 next 通道。
141
+ */
142
+ async function curlManualInstall(profileDir, packageName, registries, signal = null, exactVersion = null) {
143
+ let meta = null
144
+ let metaError = null
145
+ for (const reg of registries) {
146
+ try {
147
+ const encoded = packageName.startsWith('@')
148
+ ? `@${encodeURIComponent(packageName.slice(1).split('/')[0])}%2f${encodeURIComponent(packageName.split('/').slice(1).join('/'))}`
149
+ : encodeURIComponent(packageName)
150
+ meta = await fetchJsonUrl(`${reg}/${encoded}`)
151
+ if (meta) break
152
+ } catch (error) {
153
+ metaError = error
154
+ }
155
+ }
156
+ if (!meta || typeof meta !== 'object') throw new Error(`curl 通道:无法获取 registry 元数据(${metaError?.message ?? '未知'})`)
157
+ const version = exactVersion ?? meta['dist-tags']?.latest ?? null
158
+ const tarball = version ? meta.versions?.[version]?.dist?.tarball ?? null : null
159
+ if (!version || !tarball) throw new Error('curl 通道:registry 无 dist-tags.latest / tarball')
160
+ const bin = process.platform === 'win32' ? 'curl.exe' : 'curl'
161
+ const tmp = join(tmpdir(), `pc-curl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`)
162
+ mkdirSync(tmp, { recursive: true })
163
+ try {
164
+ const tgz = join(tmp, 'pkg.tgz')
165
+ await execFileAsync(bin, ['-s', '-L', '-m', '60', '-o', tgz, tarball], { timeout: 70000, windowsHide: true, ...(signal ? { signal } : {}) })
166
+ await execFileAsync('tar', ['-xzf', tgz, '-C', tmp], { timeout: 30000, windowsHide: true, ...(signal ? { signal } : {}) })
167
+ let pkgPath = join(tmp, 'package')
168
+ if (!existsSync(join(pkgPath, 'package.json'))) {
169
+ const candidates = readdirSync(tmp, { withFileTypes: true })
170
+ .filter((d) => d.isDirectory())
171
+ .map((d) => join(tmp, d.name))
172
+ pkgPath = candidates.find((p) => existsSync(join(p, 'package.json'))) ?? pkgPath
173
+ }
174
+ if (!existsSync(join(pkgPath, 'package.json'))) throw new Error('curl 通道:解压后未找到含 package.json 的目录')
175
+ // 盒子实验:解压后先验证,通过才覆盖正式位置(失败保留旧版本,服务不中断)
176
+ const box = verifyPackageBox(pkgPath, profileDir, packageName)
177
+ const pkg = JSON.parse(readFileSync(join(pkgPath, 'package.json'), 'utf8'))
178
+ const deps = { ...(pkg.dependencies ?? {}), ...(pkg.peerDependencies ?? {}) }
179
+ const missingDeps = Object.keys(deps).filter((d) => !existsSync(join(profileDir, 'node_modules', d)))
180
+ const target = join(profileDir, 'node_modules', packageName)
181
+ if (existsSync(target)) rmSync(target, { recursive: true, force: true })
182
+ mkdirSync(dirname(target), { recursive: true })
183
+ copyTree(pkgPath, target)
184
+ // 落真实安装时间标记:npm tarball 内文件 mtime 是固定时间戳(1985-10-26,可复现构建),
185
+ // 解压后 package.json 的 mtime 不可靠,面板安装日期优先读此标记
186
+ try {
187
+ writeFileSync(join(target, '.dsh-installed-at'), String(Date.now()), 'utf8')
188
+ } catch {}
189
+ return { version, missingDeps, boxNote: box.note }
190
+ } finally {
191
+ rmSync(tmp, { recursive: true, force: true })
192
+ }
193
+ }
194
+
195
+ /** 安装通道并行竞速:pnpm 与 curl 同时尝试,先成功者生效;失败方被 abort 不干扰。
196
+ *
197
+ * ★ 2026-09-22 挂起根因修复(issue #3 发现):旧实现只挂「成功」与「120 秒兜底」两个出口 ——
198
+ * `waitSuccess` 把失败**吞成永不 settle 的 Promise**(本意是"一条失败不代表放弃另一条",是对的),
199
+ * 但两条通道**都已失败**时(包根本没发布到 registry,pnpm 与 curl 都是秒级 404)就没有出口了,
200
+ * 只能空等满 120 秒。现场表现:装一个不存在的子包,每个候选白等 2 分钟;
201
+ * 聚合仓库展开出 3 个候选就是 6 分钟,作业 8 分钟预算被吃光后掉进 AI 兜底再等 10 分钟授权 ——
202
+ * e2e(test-suite-install.mjs)看起来就是"永不结束"。
203
+ * 修法:补上第三个出口 —— 两条通道都 settle(无论成败)即刻收工;同时把定时器清掉,
204
+ * 否则每次竞速都会留下一个 120 秒的挂起定时器,拖住进程退出。
205
+ * 第 4 个参数是可选注入(单测用:把两条通道换成桩,才能离线断言"都失败 → 立刻收工"的时延语义;
206
+ * capMs 也只是给单测缩短兜底时长,生产一律用默认 120 秒)。 */
207
+ async function raceInstallChannels(profileDir, name, registries, impls = {}) {
208
+ const runPnpm = typeof impls.pnpmInstall === 'function' ? impls.pnpmInstall : pnpmInstall
209
+ const runCurl = typeof impls.curlManualInstall === 'function' ? impls.curlManualInstall : curlManualInstall
210
+ const capMs = Number.isFinite(impls.capMs) && impls.capMs > 0 ? impls.capMs : 120000
211
+ const controller = new AbortController()
212
+ const signal = controller.signal
213
+ const pnpmTask = (async () => {
214
+ let lastError = null
215
+ for (const registry of registries) {
216
+ try {
217
+ await runPnpm(profileDir, name, registry, 90000, signal)
218
+ return { channel: 'pnpm', info: null }
219
+ } catch (error) {
220
+ lastError = error
221
+ if (signal.aborted) throw error
222
+ }
223
+ }
224
+ throw lastError ?? new Error('pnpm 通道失败')
225
+ })()
226
+ const curlTask = (async () => {
227
+ const info = await runCurl(profileDir, name, registries, signal)
228
+ return { channel: 'curl', info }
229
+ })()
230
+ const waitSuccess = (promise) => promise.then((value) => ({ value }), () => new Promise(() => {}))
231
+ // 两条通道都跑完(含都失败)→ 立即以 null 收工;仍有通道在跑时才等 capMs 兜底
232
+ const bothSettled = Promise.allSettled([pnpmTask, curlTask]).then(() => null)
233
+ let timer = null
234
+ const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve(null), capMs) })
235
+ try {
236
+ const winner = await Promise.race([waitSuccess(pnpmTask), waitSuccess(curlTask), bothSettled, timeout])
237
+ return winner ? winner.value : null
238
+ } finally {
239
+ if (timer !== null) clearTimeout(timer)
240
+ controller.abort()
241
+ }
242
+ }
243
+
244
+ /**
245
+ * 盒子实验验证:解压后的包目录先通过静态校验再允许覆盖正式位置。
246
+ * - package.json 必须可解析且 name 与安装目标一致
247
+ * - main / exports 入口文件必须真实存在(防"装上了但加载即崩")
248
+ * - bundle patch(cordis.patch.yml)引用的包必须已就位(防聚合包引用缺失崩溃)
249
+ * 失败抛错 → 调用方保留旧版本(安装不中断服务)。
250
+ */
251
+ function verifyPackageBox(pkgPath, profileDir, packageName, refCheckRoot = null) {
252
+ const note = []
253
+ const pkgRaw = readFileSync(join(pkgPath, 'package.json'), 'utf8')
254
+ const pkg = JSON.parse(pkgRaw)
255
+ if (pkg.name !== packageName) {
256
+ throw new Error(`盒子验证失败:包名不符(tarball 内为 ${pkg.name},期望 ${packageName}),已保留旧版本`)
257
+ }
258
+ // 入口存在性:main 字段 / exports.'.'(字符串或对象 default)指向的文件必须存在
259
+ let entry = typeof pkg.main === 'string' ? pkg.main : null
260
+ if (entry === null && pkg.exports && typeof pkg.exports === 'object') {
261
+ const dot = pkg.exports['.'] ?? pkg.exports['./package.json'] === undefined ? pkg.exports['.'] : null
262
+ if (typeof dot === 'string') entry = dot
263
+ else if (dot && typeof dot === 'object') entry = typeof dot.default === 'string' ? dot.default : null
264
+ }
265
+ if (entry !== null) {
266
+ const entryPath = join(pkgPath, ...entry.split('/'))
267
+ if (!existsSync(entryPath)) {
268
+ throw new Error(`盒子验证失败:入口文件缺失(${entry}),已保留旧版本`)
269
+ }
270
+ }
271
+ // bundle patch 引用预检:引用的包必须已存在于目标 node_modules(防聚合包半更新崩溃)。
272
+ // refCheckRoot 指定检查根(宿主插件在根层 node_modules,与 profile 层不同)。
273
+ const checkRoot = refCheckRoot ?? join(profileDir, 'node_modules')
274
+ try {
275
+ const patchRel = pkg.dsh?.bundle?.patch
276
+ if (typeof patchRel === 'string') {
277
+ const bundlePatch = join(pkgPath, patchRel)
278
+ if (existsSync(bundlePatch)) {
279
+ const refs = parseBundlePatchRefs(readFileSync(bundlePatch, 'utf8'))
280
+ const missingRefs = refs.filter((r) => !existsSync(join(checkRoot, r.name)))
281
+ if (missingRefs.length > 0) {
282
+ note.push(`bundle 引用缺失:${missingRefs.map((r) => r.name).join('、')}(安装后由聚合完整性检查补齐/禁用)`)
283
+ }
284
+ if (refs.length > 0) note.push(`bundle 引用 ${refs.length} 个已就位`)
285
+ }
286
+ }
287
+ } catch (error) {
288
+ if (error instanceof Error && error.message.startsWith('盒子验证失败')) throw error
289
+ }
290
+ return { note: note.length > 0 ? note.join(';') : null }
291
+ }
292
+
293
+ /** 解析 bundle patch(cordis.patch.yml)的 insert 引用列表(id + 包名)。 */
294
+ function parseBundlePatchRefs(text) {
295
+ const refs = []
296
+ const lines = text.split(/\r?\n/u)
297
+ let inInsert = false
298
+ for (let i = 0; i < lines.length; i += 1) {
299
+ const line = lines[i]
300
+ if (/^- insert:\s*$/u.test(line)) { inInsert = true; continue }
301
+ if (/^- /u.test(line) && !/^ {4}- /u.test(line)) inInsert = false
302
+ if (!inInsert) continue
303
+ const idM = line.match(/^ {4}- id: ([A-Za-z0-9_.-]+)\s*$/u)
304
+ if (!idM) continue
305
+ const nameM = (lines[i + 1] ?? '').match(/^ {6}name: ['"]([^'"]+)['"]\s*$/u)
306
+ if (nameM) refs.push({ id: idM[1], name: nameM[1] })
307
+ }
308
+ return refs
309
+ }
310
+
311
+ /**
312
+ * 捆绑依赖补装(事故教训:dsh-web-ui-all 更新后 17 个捆绑依赖缺失 → 服务加载崩溃):
313
+ * curl/GitHub 通道只解压主包,这里逐个补装直接依赖(pnpm → curl 依次尝试)。
314
+ * 返回仍缺失的依赖列表。
315
+ * 安全护栏(事故教训):@deepseek-ai/* 框架内部包**绝不补装**——它们由框架依赖树管理
316
+ * (正确版本随 @deepseek-ai/dsh 一起安装),npm 上这些内部包的 dist-tags.latest 是远古
317
+ * 版本(如 dsh-host-webserver@0.0.1-rc.1),无版本约束补装会覆盖框架正确版本,
318
+ * 导致 webServer 等服务起不来、整个 profile 启动崩溃。
319
+ */
320
+ async function backfillMissingDeps(profileDir, deps, registries) {
321
+ const stillMissing = []
322
+ for (const dep of deps) {
323
+ if (/^@deepseek-ai\//u.test(dep)) continue // 框架内部包:跳过(宿主提供)
324
+ if (existsSync(join(profileDir, 'node_modules', dep))) continue
325
+ let ok = false
326
+ try {
327
+ await pnpmInstall(profileDir, dep, registries[0], 60000)
328
+ ok = existsSync(join(profileDir, 'node_modules', dep))
329
+ } catch {}
330
+ if (!ok) {
331
+ try {
332
+ await curlManualInstall(profileDir, dep, registries)
333
+ // 判断目标是否实际安装(修复:旧代码用 depInfo.missingDeps.length===0 判断
334
+ // "该依赖自身无依赖",只要目标依赖带依赖就误报缺失——即使 curl 已成功安装)
335
+ ok = existsSync(join(profileDir, 'node_modules', dep))
336
+ } catch {}
337
+ }
338
+ if (!ok) stillMissing.push(dep)
339
+ }
340
+ return stillMissing
341
+ }
342
+
343
+ /**
344
+ * GitHub release 下载安装通道(npm 上不存在的包,例如只发 GitHub release 的社区插件)。
345
+ * issue #3 起不再"只用 job.repo + releases/latest":
346
+ * ① 按包名反查真实发布仓库(显式 repo → 已装包 package.json.repository → npm 元数据 → GitHub 搜索包名);
347
+ * ② 遍历候选仓库最近 ≤10 条 release,把每条 release 的 assets 全列出,**按包名匹配**挑选产物;
348
+ * ③ 所有候选都没有匹配 asset 时,退回老行为(最新 tag 的 codeload 源码 tarball)——很多插件仓库
349
+ * 只打 tag 不发 asset,删掉这条路会让它们从"能装"变成"装不上";盒子验证照旧把关包名。
350
+ * 选源/挑选的纯逻辑与只读探测在 domain/release-source.js;本函数只做下载、盒子验证、落盘。
351
+ * 签名向后兼容:第 4 个参数是可选扩展(baseUrl/registries/token),旧调用点不受影响。
352
+ * 返回里的 sourceNote 如实写明"哪个仓库的哪条 release 的哪个 asset"(面板原样展示给用户)。
353
+ */
354
+ async function githubReleaseInstall(profileDir, repo, packageName, options = {}) {
355
+ const auth = readGithubAuth()
356
+ const token = typeof options.token === 'string' && options.token !== '' ? options.token : (auth.token ?? null)
357
+ const registries = Array.isArray(options.registries) && options.registries.length > 0
358
+ ? options.registries
359
+ : orderedRegistries(readSources())
360
+ const plan = await selectReleaseInstall({ repo, packageName, baseUrl: options.baseUrl ?? null, profileDir, registries, token })
361
+ const bin = process.platform === 'win32' ? 'curl.exe' : 'curl'
362
+ const tmp = join(tmpdir(), `pc-gh-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`)
363
+ mkdirSync(tmp, { recursive: true })
364
+ try {
365
+ const tgz = join(tmp, 'pkg.tgz')
366
+ let version = null
367
+ let sourceNote = null
368
+ if (plan.ok) {
369
+ const url = plan.asset?.browser_download_url
370
+ if (typeof url !== 'string' || url === '') throw new Error(`GitHub 通道:asset ${plan.file} 没有下载地址(接口未返回 browser_download_url)`)
371
+ await downloadReleaseArtifact(url, tgz, { bin })
372
+ version = plan.version ?? null
373
+ sourceNote = `${plan.repo} 的 release ${plan.release?.tag_name ?? '(无 tag)'} 的资产 ${plan.file}`
374
+ } else if (plan.sourceFallback !== null) {
375
+ const srcRepo = plan.sourceFallback.repo
376
+ const tag = plan.sourceFallback.tag
377
+ // codeload 官方源码 tarball(已验证本机可用 200)。GitHub 黑洞期由上层通道兜底
378
+ // (pnpm/git 通道),此处失败即报错保留旧版本。
379
+ await downloadReleaseArtifact(`https://codeload.github.com/${srcRepo}/tar.gz/refs/tags/${encodeURIComponent(tag)}`, tgz, { bin })
380
+ version = String(tag).replace(/^v/iu, '')
381
+ sourceNote = `${srcRepo} 的 release ${tag} 源码 tarball(该仓库没有任何与包名匹配的资产)`
382
+ } else {
383
+ // 清单式错误:说清试过哪些仓库、各自有哪些 release/asset(issue #3 的排查要求)
384
+ throw new Error(plan.message)
385
+ }
386
+ await execFileAsync('tar', ['-xzf', tgz, '-C', tmp], { timeout: 60000, windowsHide: true })
387
+ // 顶层目录名两种形态:asset(npm pack 产物)= package/,源码 tarball = {repo}-{sha}/ → 统一找含 package.json 的目录
388
+ const subdirs = readdirSync(tmp, { withFileTypes: true })
389
+ .filter((d) => d.isDirectory())
390
+ .map((d) => join(tmp, d.name))
391
+ const pkgPath = subdirs.find((d) => existsSync(join(d, 'package.json')))
392
+ if (!pkgPath) throw new Error('GitHub 通道:tarball 内未找到 package.json')
393
+ // 目标位置:宿主插件(面板自身,部署在宿主根层 node_modules)→ 覆盖自身所在目录;普通插件 → profile
394
+ const target = releaseInstallTarget(profileDir, packageName)
395
+ // 盒子实验:静态验证通过才覆盖(失败保留旧版本)。引用检查根用目标目录(宿主插件在根层)
396
+ const box = verifyPackageBox(pkgPath, profileDir, packageName, dirname(target))
397
+ const pkg = JSON.parse(readFileSync(join(pkgPath, 'package.json'), 'utf8'))
398
+ // 只统计 dependencies(peerDependencies 是宿主契约,不补装——见 curlManualInstall 注释)
399
+ const deps = { ...(pkg.dependencies ?? {}) }
400
+ const missingDeps = Object.keys(deps).filter((d) => !existsSync(join(profileDir, 'node_modules', d)))
401
+ if (existsSync(target)) rmSync(target, { recursive: true, force: true })
402
+ mkdirSync(dirname(target), { recursive: true })
403
+ copyTree(pkgPath, target)
404
+ // 版本号对齐(防死循环):产物内 package.json version 可能滞后于 release tag
405
+ // (历史发布只打 tag 不改 version),改写为选定版本 → 下次检测 latest===current → "已是最新"
406
+ try {
407
+ const targetPkgPath = join(target, 'package.json')
408
+ const targetPkg = JSON.parse(readFileSync(targetPkgPath, 'utf8'))
409
+ if (typeof version === 'string' && version !== '' && targetPkg.version !== version) {
410
+ targetPkg.version = version
411
+ writeFileSync(targetPkgPath, JSON.stringify(targetPkg, null, 4), 'utf8')
412
+ }
413
+ } catch {}
414
+ try {
415
+ writeFileSync(join(target, '.dsh-installed-at'), String(Date.now()), 'utf8')
416
+ } catch {}
417
+ return { version, missingDeps, boxNote: box.note, source: 'github', sourceNote }
418
+ } finally {
419
+ rmSync(tmp, { recursive: true, force: true })
420
+ }
421
+ }
422
+
423
+ /**
424
+ * 聚合包完整性保障(事故教训):dsh-web-ui-all 更新后捆绑依赖缺失,其 bundle patch
425
+ * (cordis.patch.yml)引用的包未安装 → 服务加载崩溃。
426
+ * 读已装包的 bundle patch → 检查每个 insert 引用的包是否存在 → 缺失补装(pnpm/curl)→
427
+ * 仍缺则在用户 patch 层自动禁用该行(防崩兜底)+ 返回报告供面板提示。
428
+ */
429
+ /** 读取聚合包 cordis.patch.yml 的 insert 行 name 列表(注册前校验用)。 */
430
+ function readBundlePatchRefNames(profileDir, pkgName) {
431
+ try {
432
+ const pkgJsonPath = join(profileDir, 'node_modules', pkgName, 'package.json')
433
+ if (!existsSync(pkgJsonPath)) return []
434
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'))
435
+ const patchRel = pkg.dsh?.bundle?.patch
436
+ if (typeof patchRel !== 'string') return []
437
+ const text = readFileSync(join(profileDir, 'node_modules', pkgName, patchRel), 'utf8')
438
+ const names = []
439
+ const lines = text.split(/\r?\n/u)
440
+ let inInsert = false
441
+ for (let i = 0; i < lines.length; i += 1) {
442
+ const line = lines[i]
443
+ if (/^- insert:\s*$/u.test(line)) { inInsert = true; continue }
444
+ if (/^- /u.test(line) && !/^ {4}- /u.test(line)) inInsert = false
445
+ if (!inInsert) continue
446
+ const nameM = (lines[i + 1] ?? '').match(/^ {6}name: ['"]([^'"]+)['"]\s*$/u)
447
+ if (nameM) names.push(nameM[1])
448
+ }
449
+ return names
450
+ } catch { return [] }
451
+ }
452
+
453
+ async function ensureBundlePatchIntegrity(profileDir, pkgName, userPatchPath, transientAllow = []) {
454
+ const report = { checked: 0, installed: [], disabled: [], missing: [], pending: [] }
455
+ try {
456
+ const pkgJsonPath = join(profileDir, 'node_modules', pkgName, 'package.json')
457
+ if (!existsSync(pkgJsonPath)) return report
458
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'))
459
+ const patchRel = pkg.dsh?.bundle?.patch
460
+ if (typeof patchRel !== 'string') return report
461
+ const bundlePatch = join(profileDir, 'node_modules', pkgName, patchRel)
462
+ if (!existsSync(bundlePatch)) return report
463
+ const text = readFileSync(bundlePatch, 'utf8')
464
+ // 解析 insert 引用(与 readExtraBundleOwners 同构):- insert: 块下 - id: xxx + name: '包名'
465
+ const refs = []
466
+ const lines = text.split(/\r?\n/u)
467
+ let inInsert = false
468
+ for (let i = 0; i < lines.length; i += 1) {
469
+ const line = lines[i]
470
+ if (/^- insert:\s*$/u.test(line)) { inInsert = true; continue }
471
+ if (/^- /u.test(line) && !/^ {4}- /u.test(line)) inInsert = false
472
+ if (!inInsert) continue
473
+ const idM = line.match(/^ {4}- id: ([A-Za-z0-9_.-]+)\s*$/u)
474
+ if (!idM) continue
475
+ const nameM = (lines[i + 1] ?? '').match(/^ {6}name: ['"]([^'"]+)['"]\s*$/u)
476
+ if (nameM) refs.push({ id: idM[1], name: nameM[1] })
477
+ }
478
+ report.checked = refs.length
479
+ if (refs.length === 0) return report
480
+ const registries = orderedRegistries(readSources())
481
+ for (const ref of refs) {
482
+ // 框架内部包跳过(事故教训:npm 上 @deepseek-ai/* 的 dist-tags.latest 是远古版本,
483
+ // 无版本约束补装会覆盖框架正确版本导致服务崩溃——见 backfillMissingDeps 注释)
484
+ if (/^@deepseek-ai\//u.test(ref.name)) continue
485
+ if (existsSync(join(profileDir, 'node_modules', ref.name))) continue
486
+ // 2026-09-06 加固:本次作业刚完成版本同步的包(transientAllow)此刻缺失=更新中途的瞬时态
487
+ // (pnpm 原子替换/替换失败窗口),不应按"缺失→自动禁用"处理——跳过本次判定,下次校验时再检查。
488
+ if (transientAllow.includes(ref.name)) {
489
+ report.pending.push(ref.name)
490
+ continue
491
+ }
492
+ let ok = false
493
+ try {
494
+ await pnpmInstall(profileDir, ref.name, registries[0], 60000)
495
+ ok = existsSync(join(profileDir, 'node_modules', ref.name))
496
+ } catch {}
497
+ if (!ok) {
498
+ try { await curlManualInstall(profileDir, ref.name, registries); ok = true } catch {}
499
+ }
500
+ if (ok) {
501
+ report.installed.push(ref.name)
502
+ } else {
503
+ report.missing.push(ref.name)
504
+ // 自动禁用该行(用户 patch 层,防服务加载崩溃)
505
+ try {
506
+ const userPatch = readFileSync(userPatchPath, 'utf8')
507
+ if (!userPatch.includes(`id: ${ref.id}`)) {
508
+ // issue #7 防护:清理顶层 [] 占位符后再追加(模板初始文件直接追加会生成非法 YAML)
509
+ const clean = sanitizePatchText(userPatch)
510
+ const next = clean.length === 0 || clean.endsWith('\n') ? clean : `${clean}\n`
511
+ writeFileSync(userPatchPath, `${next}- id: ${ref.id}\n disabled: true\n`, 'utf8')
512
+ report.disabled.push(ref.id)
513
+ }
514
+ } catch {}
515
+ }
516
+ }
517
+ } catch {}
518
+ return report
519
+ }
520
+
521
+ /** 本地 AI 兜底授权的等待上限(10 分钟):超时即视为用户未授权(见 install-job.js 的 ai-consent 段)。 */
522
+ const AI_CONSENT_TIMEOUT_MS = 600000
523
+
524
+ function installJobView(job) {
525
+ return {
526
+ jobId: job.id,
527
+ repo: job.repo,
528
+ packageName: job.packageName,
529
+ status: job.status,
530
+ stage: job.stage,
531
+ error: job.error,
532
+ startedAt: job.startedAt,
533
+ finishedAt: job.finishedAt,
534
+ entryId: job.entryId ?? null,
535
+ bundle: job.bundle ?? false,
536
+ ai: job.ai ?? false,
537
+ aiNote: job.aiNote ?? null,
538
+ subpackages: job.subpackages ?? null,
539
+ source: job.source ?? 'github',
540
+ curlNote: job.curlNote ?? null,
541
+ bundleNote: job.bundleNote ?? null,
542
+ lockUpdated: job.lockUpdated ?? null,
543
+ lockVersion: job.lockVersion ?? null,
544
+ lockNote: job.lockNote ?? null,
545
+ depNote: job.depNote ?? null, // 依赖来源写回说明(缺陷②:release 专属包按 link: 记录时给出可见解释,绝不静默)
546
+ compatNote: job.compatNote ?? null,
547
+ kind: job.kind ?? 'plugin',
548
+ skillName: job.skillName ?? null,
549
+ skillDir: job.skillDir ?? null,
550
+ skillNote: job.skillNote ?? null,
551
+ suiteReport: job.suiteReport ?? null,
552
+ suiteNote: job.suiteNote ?? null,
553
+ hint: job.hint ?? null,
554
+ // 子包级/套装级进度(2026-09-20 真装实测缺口:11 个子包的聚合仓库跑了 19 分钟,面板只显示
555
+ // "安装中",用户不知道在装第几个、还剩几个)。子包通道来自 install-job.js 候选循环维护的
556
+ // candidateXxx;套装通道来自 suite.js 维护的 suiteProgress(clone/装配两阶段)。
557
+ progress: job.suiteProgress
558
+ ? { channel: 'suite', ...job.suiteProgress }
559
+ : job.candidateTotal > 0
560
+ ? { channel: 'subpackage', phase: 'install', index: job.candidateIndex ?? 0, total: job.candidateTotal, name: job.candidateName ?? null, done: job.candidateDone === true }
561
+ : null,
562
+ // 授权请求(等本地 AI 兜底同意):面板要显示倒计时 + 同意/取消,所以时间与最后错误一起下发
563
+ aiConsent: { pending: job.aiPending != null, since: job.aiPendingSince ?? null, timeoutMs: job.aiConsentTimeoutMs ?? AI_CONSENT_TIMEOUT_MS, lastError: job.aiPending?.lastError ?? job.lastError ?? null },
564
+ }
565
+ }
566
+
567
+ /** 从 GitHub Release 下载预构建 tgz 装配到 node_modules/<pkgName> 已搬到 domain/release-source.js
568
+ * (与 #3 的 release 源解析同族);本模块不再重复导出它(全仓库无导入点,导出面由 lib/index.js 决定)。 */
569
+
570
+ async function syncAggregateSubpackageVersions(profileDir, packageName, registries) {
571
+ const pkgPath = join(profileDir, 'node_modules', packageName, 'package.json')
572
+ if (!existsSync(pkgPath)) return []
573
+ let pkg = null
574
+ try { pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) } catch { return [] }
575
+ const deps = { ...(pkg.dependencies ?? {}), ...(pkg.peerDependencies ?? {}) }
576
+ const updated = []
577
+ for (const dep of Object.keys(deps)) {
578
+ if (dep.startsWith('@deepseek-ai/')) continue
579
+ const spec = String(deps[dep] ?? '').replace(/^[\^~>=< ]+/u, '')
580
+ if (!spec) continue
581
+ const currentPath = join(profileDir, 'node_modules', dep, 'package.json')
582
+ if (!existsSync(currentPath)) {
583
+ try {
584
+ await curlManualInstall(profileDir, dep, registries, null, spec)
585
+ updated.push(`${dep}@${spec}(新装)`)
586
+ } catch {}
587
+ continue
588
+ }
589
+ try {
590
+ const current = JSON.parse(readFileSync(currentPath, 'utf8'))
591
+ if (current.version === spec) continue
592
+ await curlManualInstall(profileDir, dep, registries, null, spec)
593
+ updated.push(`${dep}@${spec}`)
594
+ } catch {}
595
+ }
596
+ return updated
597
+ }
598
+
599
+ export { AI_CONSENT_TIMEOUT_MS, DEFAULT_BUNDLES, detectBundleOnly, addBundleToManifest, removeBundleFromManifest, readExtraBundleOwners, readGithubAuth, pnpmInstall, curlManualInstall, raceInstallChannels, verifyPackageBox, parseBundlePatchRefs, backfillMissingDeps, githubReleaseInstall, readBundlePatchRefNames, ensureBundlePatchIntegrity, syncAggregateSubpackageVersions, installJobView }
@@ -0,0 +1,28 @@
1
+ // L1 · domain —— jobs.js(任务辅助:日志追加 + 健康等待轮询)。方案原稿把 jobLog 归 ai.js、waitHealth 归 components.js,但这俩是 install / suite / ai 三个域共用的,埋在任何一个域里都会造成跨域反向依赖 —— 故单独成模块(偏离已记录)
2
+ // 分组见 D:\dsh\dsh-plugin-hub-plan\architecture.zh.md 三
3
+
4
+
5
+ function jobLog(job, line) {
6
+ job.logText = `${job.logText ?? ''}${line}\n`
7
+ }
8
+
9
+ async function waitHealth(url, timeoutMs, job, signal) {
10
+ const deadline = Date.now() + timeoutMs
11
+ let last = ''
12
+ while (Date.now() < deadline) {
13
+ if (signal?.aborted) return false
14
+ try {
15
+ const res = await fetch(url, { signal: AbortSignal.timeout(3000) })
16
+ if (res.ok) return true
17
+ last = `HTTP ${res.status}`
18
+ } catch (error) {
19
+ last = error instanceof Error ? error.message : String(error)
20
+ }
21
+ jobLog(job, ` ⏳ 健康检查 ${url} … ${last}`)
22
+ await new Promise((r) => setTimeout(r, 2000))
23
+ }
24
+ jobLog(job, ` ✘ 健康检查超时:${url}(最后状态 ${last})`)
25
+ return false
26
+ }
27
+
28
+ export { jobLog, waitHealth }