@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,409 @@
1
+ // L1 · domain —— market.js(分层 Step 从 lib/index.js 搬出,只搬移未改逻辑)
2
+ // 分组见 D:\dsh\dsh-plugin-hub-plan\architecture.zh.md §三
3
+
4
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
5
+ import { readFile } from 'node:fs/promises'
6
+ import { dirname, join } from 'node:path'
7
+ import { detectSkillRepo } from './skills.js'
8
+ import { FETCH_OK, FETCH_UNREACHABLE, GITHUB_API, curlJson, curlText, fetchJsonUrl, githubJson, looksLikeGitmodules, rawTextFetch, rawTextWithFallback } from '../infra/http.js'
9
+ import { ENRICH_CACHE_FILE, baseDirOf, resolvePackageJson } from '../infra/paths.js'
10
+
11
+ function readEnrichCache() {
12
+ try {
13
+ const j = JSON.parse(readFileSync(ENRICH_CACHE_FILE(), 'utf8'))
14
+ return j !== null && typeof j === 'object' ? j : {}
15
+ } catch { return {} }
16
+ }
17
+
18
+ function writeEnrichCache(cache) {
19
+ try {
20
+ mkdirSync(dirname(ENRICH_CACHE_FILE()), { recursive: true })
21
+ const entries = Object.entries(cache).sort((a, b) => (b[1]?.at ?? 0) - (a[1]?.at ?? 0)).slice(0, 2000)
22
+ writeFileSync(ENRICH_CACHE_FILE(), JSON.stringify(Object.fromEntries(entries), null, 2), 'utf8')
23
+ } catch {}
24
+ }
25
+
26
+ /** 单项识别:官方通道 / 聚合仓库 / 技能 / 套装(失败返回 official=null)。 */
27
+ async function enrichItemOne(item) {
28
+ let official = /^deepseek-ai\//u.test(item.fullName ?? '') ? true : null
29
+ let aggregate = false
30
+ let aggregateInstallable = false
31
+ let hasSkill = false
32
+ let hasSuite = false
33
+ try {
34
+ const branch = item.defaultBranch ?? 'main'
35
+ const base = `https://raw.githubusercontent.com/${item.fullName}/${encodeURIComponent(branch)}/package.json`
36
+ const [pkgResult, skillResult, suiteResult] = await Promise.allSettled([
37
+ Promise.any([
38
+ curlText(base, 4000),
39
+ curlText(`https://ghproxy.net/${base}`, 4000),
40
+ ]),
41
+ detectSkillRepo(item.fullName, branch),
42
+ rawTextWithFallback(item.fullName, branch, '.gitmodules'),
43
+ ])
44
+ // 套装判定必须过内容校验:代理/CDN 对不存在的 .gitmodules 也可能回 2xx 空 body,
45
+ // 只判"探测非 null"会把普通插件标成套装置仓库(2026-09-19 事故)。
46
+ if (suiteResult.status === 'fulfilled' && looksLikeGitmodules(suiteResult.value)) {
47
+ hasSuite = true
48
+ }
49
+ if (pkgResult.status === 'fulfilled') {
50
+ const pkg = JSON.parse(pkgResult.value)
51
+ if (typeof pkg.dsh?.bundle?.patch === 'string') {
52
+ official = true
53
+ } else {
54
+ // 成功读到 package.json 且没有 dsh.bundle.patch → 确定「非官方」。
55
+ // 必须落成 false(而不是留 null),否则缓存条件 official !== null 永不成立,
56
+ // 每个非官方插件每次打开都重新请求 → /enrich 缓存命中也要十几秒。
57
+ official = false
58
+ if (pkg.private === true && (Array.isArray(pkg.workspaces) || /(^|-)dsh[-/]/u.test(String(pkg.name ?? '')))) {
59
+ aggregate = true
60
+ try {
61
+ const tree = await curlJson(`https://api.github.com/repos/${item.fullName}/git/trees/${encodeURIComponent(branch)}?recursive=1`, 6000)
62
+ const pkgPaths = (tree.tree ?? [])
63
+ .filter((n) => n.type === 'blob' && /^packages\/[^/]+\/package\.json$/u.test(n.path))
64
+ .map((n) => n.path)
65
+ .slice(0, 12)
66
+ if (pkgPaths.length > 0) {
67
+ const subs = await Promise.all(pkgPaths.map((p) => curlText(`https://raw.githubusercontent.com/${item.fullName}/${encodeURIComponent(branch)}/${p}`, 4000)
68
+ .then((t) => { try { return JSON.parse(t) } catch { return null } })
69
+ .catch(() => null)))
70
+ if (subs.some((sp) => sp && typeof sp.dsh?.bundle?.patch === 'string')) {
71
+ aggregateInstallable = true
72
+ }
73
+ }
74
+ } catch {}
75
+ }
76
+ }
77
+ }
78
+ if (skillResult.status === 'fulfilled' && skillResult.value?.hasSkill === true) {
79
+ hasSkill = true
80
+ }
81
+ } catch {}
82
+ return { ...item, official, aggregate, aggregateInstallable, hasSkill, hasSuite }
83
+ }
84
+
85
+ /**
86
+ * 批量识别(★ 筛选数据源):并发限流 + 24h 结果缓存。
87
+ * 网络黑洞期(raw.githubusercontent 大部分拉取失败)自动回退缓存中的上次判定,
88
+ * 保证「只看官方」不因瞬时网络而坍缩成 0/1 条。
89
+ */
90
+ async function enrichItems(items) {
91
+ const cache = readEnrichCache()
92
+ const out = new Array(items.length)
93
+ let next = 0
94
+ let cacheDirty = false
95
+ async function worker() {
96
+ while (true) {
97
+ const i = next
98
+ next += 1
99
+ if (i >= items.length) return
100
+ const item = items[i]
101
+ const key = `${item.fullName}@${item.defaultBranch ?? 'main'}`
102
+ const cached = cache[key]
103
+ try {
104
+ // official 已判定(true/false)→ 24h 缓存;判定失败(null)→ 1h 短缓存,减少无谓重试
105
+ if (cached !== undefined && typeof cached?.at === 'number' && cached.data) {
106
+ const ttl = cached.data.official === null ? 60 * 60 * 1000 : ENRICH_CACHE_TTL
107
+ if (Date.now() - cached.at < ttl) {
108
+ out[i] = { ...item, ...cached.data }
109
+ continue
110
+ }
111
+ }
112
+ let result = await enrichItemOne(item)
113
+ // 本次失败 → 回退缓存(哪怕已过期),避免「看天吃饭」
114
+ if ((result.official === null && !/^deepseek-ai\//u.test(item.fullName ?? '')) && cached?.data) {
115
+ result = { ...item, ...cached.data }
116
+ } else {
117
+ cache[key] = { at: Date.now(), data: { official: result.official, aggregate: result.aggregate, aggregateInstallable: result.aggregateInstallable, hasSkill: result.hasSkill, hasSuite: result.hasSuite } }
118
+ cacheDirty = true
119
+ }
120
+ out[i] = result
121
+ } catch {
122
+ // 抛错的条目也必须写缓存(null 结果 + 1h 短 TTL),否则每次打开都重试同一批,
123
+ // /enrich 即使「命中缓存」也要等十几秒。
124
+ if (cached?.data) {
125
+ out[i] = { ...item, ...cached.data }
126
+ } else {
127
+ const miss = { official: null, aggregate: false, aggregateInstallable: false, hasSkill: false, hasSuite: false }
128
+ cache[key] = { at: Date.now(), data: miss }
129
+ cacheDirty = true
130
+ out[i] = { ...item, ...miss }
131
+ }
132
+ }
133
+ }
134
+ }
135
+ await Promise.all(Array.from({ length: Math.min(12, items.length) }, worker))
136
+ if (cacheDirty) writeEnrichCache(cache)
137
+ return out
138
+ }
139
+
140
+ /** 平台搜索返回归一化(数组或 {items} 均可;兼容 GitHub/Gitee/自定义源字段)。 */
141
+ function normalizePlatformItems(data, fallbackBranch = 'main') {
142
+ const list = Array.isArray(data) ? data : (data && Array.isArray(data.items) ? data.items : [])
143
+ return list
144
+ .filter((item) => item && typeof item === 'object')
145
+ .map((item) => ({
146
+ fullName: String(item.full_name ?? item.path_with_namespace ?? item.name ?? '').trim(),
147
+ description: item.description ?? '',
148
+ htmlUrl: item.html_url ?? item.web_url ?? '',
149
+ stars: item.stargazers_count ?? item.star_count ?? 0,
150
+ updatedAt: item.updated_at ?? item.last_activity_at ?? '',
151
+ defaultBranch: item.default_branch ?? fallbackBranch,
152
+ topics: Array.isArray(item.topics) ? item.topics : [],
153
+ }))
154
+ .filter((item) => item.fullName !== '')
155
+ }
156
+
157
+ function githubRepoInfo(repo) {
158
+ const match = String(repo).trim().match(/^(?:https:\/\/github\.com\/|https:\/\/gitee\.com\/|git@github\.com:|git@gitee\.com:)?([^\s\/?#]+)\/([^\s\/?#]+?)(?:\.git)?$/u)
159
+ if (!match) throw new Error('仓库名格式应为 owner/name(支持完整仓库 URL 与中文路径)')
160
+ return `${match[1]}/${match[2]}`
161
+ }
162
+
163
+ /** 从 npm 的 repository 字段解析出 GitHub/Gitee 仓库标识(纯函数,单测覆盖)。
164
+ * 支持 `https://github.com/o/r.git`、`git+https://…`、`git://…`、带 `#path` 的 monorepo 写法。 */
165
+ function parseRepoFromUrl(url) {
166
+ const raw = String(url ?? '').trim()
167
+ // npm 老式简写:`github:owner/repo` / `gitee:owner/repo`
168
+ const shorthand = raw.match(/^(?:github|gitee):([^/\s]+)\/([^/\s#?]+?)(?:\.git)?(?:[#?].*)?$/u)
169
+ if (shorthand !== null) return `${shorthand[1]}/${shorthand[2]}`
170
+ const m = raw.match(/(?:github\.com|gitee\.com)[/:]([^/\s]+)\/([^/\s#?]+?)(?:\.git)?(?:[#?].*)?$/u)
171
+ if (m === null) return null
172
+ return `${m[1]}/${m[2]}`
173
+ }
174
+
175
+ /** 结果里是否已有"名字逐词命中查询词"的条目——决定要不要再加 `in:readme` 重查一次。
176
+ * (仓库搜索的检索面只有 名字/描述/topics;README 里的词必须显式 in:readme 才查得到) */
177
+ function hasDirectNameHit(items, query) {
178
+ const tokens = String(query ?? '').toLowerCase().split(/[\s\-_/.]+/u).filter((tk) => tk.length >= 3)
179
+ if (tokens.length === 0) return true // 查询太短/太泛:不做二次查询,避免把结果冲稀
180
+ return items.some((it) => {
181
+ const name = String(it?.fullName ?? '').toLowerCase()
182
+ return tokens.every((tk) => name.includes(tk))
183
+ })
184
+ }
185
+
186
+ /** npm 包名搜索:registry 搜索接口 → 候选包 → 读 packument 的 repository.url → 映射回 GitHub 仓库。
187
+ * 背景(2026-09-20):用户搜 `web-all`(= npm 包 `@linxin666/dsh-web-all`)搜不到,因为 `web-all`
188
+ * 只存在于 npm 包名、仓库文件与 README 里,而 GitHub 仓库搜索的检索面只有 名字/描述/topics。
189
+ * 这条通道不依赖静态索引、也不依赖 GitHub 登录,且命中后可按包名直接安装。 */
190
+ async function searchNpmPackages(query, registries, limit = 3, token = null) {
191
+ const q = String(query ?? '').trim().toLowerCase()
192
+ if (q.length < 2) return []
193
+ let hits = null
194
+ let registry = null
195
+ for (const reg of (registries ?? []).slice(0, 3)) {
196
+ try {
197
+ // eslint-disable-next-line no-await-in-loop
198
+ const data = await fetchJsonUrl(`${reg}/-/v1/search?text=${encodeURIComponent(q)}&size=10`, 8000)
199
+ if (data && Array.isArray(data.objects)) { hits = data.objects; registry = reg; break }
200
+ } catch {}
201
+ }
202
+ if (hits === null) return []
203
+ const candidates = hits
204
+ .map((o) => o?.package)
205
+ .filter((p) => p && typeof p.name === 'string' && p.name.toLowerCase().includes(q))
206
+ .slice(0, limit)
207
+ const out = []
208
+ for (const cand of candidates) {
209
+ try {
210
+ const encoded = cand.name.startsWith('@')
211
+ ? `@${encodeURIComponent(cand.name.slice(1).split('/')[0])}%2f${encodeURIComponent(cand.name.split('/').slice(1).join('/'))}`
212
+ : encodeURIComponent(cand.name)
213
+ // eslint-disable-next-line no-await-in-loop
214
+ const meta = await fetchJsonUrl(`${registry}/${encoded}`, 8000)
215
+ const repo = parseRepoFromUrl(meta?.repository?.url ?? meta?.repository ?? cand.links?.repository ?? '')
216
+ if (repo === null) continue
217
+ // 顺带补仓库真实元数据(星数/描述/默认分支)——npm 里的信息不足,且默认分支可能是 dev
218
+ let info = null
219
+ try {
220
+ // eslint-disable-next-line no-await-in-loop
221
+ info = await githubJson(`${GITHUB_API}/repos/${repo}`, null, token)
222
+ } catch {}
223
+ out.push({
224
+ fullName: repo,
225
+ description: `${info?.description ?? cand.description ?? ''}(npm 包:${cand.name}@${cand.version ?? '?'})`.trim(),
226
+ htmlUrl: info?.html_url ?? `https://github.com/${repo}`,
227
+ stars: typeof info?.stargazers_count === 'number' ? info.stargazers_count : 0,
228
+ updatedAt: info?.updated_at ?? '',
229
+ defaultBranch: info?.default_branch ?? null,
230
+ topics: Array.isArray(info?.topics) ? info.topics : [],
231
+ source: 'npm',
232
+ sourceName: 'npm',
233
+ packageName: cand.name,
234
+ npmVersion: cand.version ?? null,
235
+ npmPackage: true,
236
+ })
237
+ } catch {}
238
+ }
239
+ return out
240
+ }
241
+
242
+ /** monorepo 子包增强:GitHub 代码搜索(`<词> filename:package.json`)→ 命中 `packages/<包>/package.json`
243
+ * → 读该 package.json 取真实包名 → 作为子包条目返回(这样 `dsh-web-all`、OpenViking 这类
244
+ * 「只存在于仓库文件里的包名」也能被搜到,且带 packageName 可直接按包名安装)。
245
+ * ⚠️ GitHub **代码搜索 API 强制要求登录**(未登录实测 401 Requires authentication),未登录时返回空。 */
246
+ async function searchSubpackageItems(query, token = null, signal = null) {
247
+ const subItems = []
248
+ try {
249
+ const codeData = await githubJson(
250
+ `${GITHUB_API}/search/code?q=${encodeURIComponent(`${query} filename:package.json`)}`,
251
+ signal,
252
+ token,
253
+ )
254
+ for (const hit of (codeData.items ?? []).slice(0, 10)) {
255
+ const hitPath = typeof hit.path === 'string' ? hit.path : ''
256
+ if (!/^(?!node_modules\/)[^/]+(?:\/[^/]+)?\/package\.json$/u.test(hitPath)) continue
257
+ const repoName = hit.repository?.full_name ?? ''
258
+ if (!repoName) continue
259
+ const dir = hitPath.split('/').slice(0, -1).join('/')
260
+ let packageName = dir.split('/').slice(-1)[0]
261
+ try {
262
+ // eslint-disable-next-line no-await-in-loop
263
+ const pkgText = await rawTextWithFallback(repoName, 'main', hitPath)
264
+ if (pkgText !== null) {
265
+ const pkg = JSON.parse(pkgText)
266
+ if (pkg && typeof pkg.name === 'string') packageName = pkg.name
267
+ }
268
+ } catch {}
269
+ subItems.push({
270
+ fullName: repoName,
271
+ description: `子包:${dir}`,
272
+ htmlUrl: `https://github.com/${repoName}/tree/main/${dir}`,
273
+ stars: 0,
274
+ updatedAt: '',
275
+ defaultBranch: 'main',
276
+ topics: [],
277
+ source: 'github',
278
+ subpackagePath: dir,
279
+ packageName,
280
+ })
281
+ if (subItems.length >= 5) break
282
+ }
283
+ } catch {}
284
+ return subItems
285
+ }
286
+
287
+ /** 包探测(带失败原因):reason ∈ ok / not-found / unreachable / invalid。
288
+ * 事故(2026-09-20,另一位用户:Android + proot Ubuntu,容器无 IPv6 路由):
289
+ * 抓取超时与真 404 都让上层拿到同一个 null,于是「网络太慢」被报成「仓库没有 package.json」,
290
+ * 用户与日志都被误导。现在把两种结局分开,文案也分开。 */
291
+ async function fetchRepoPackageEx(repo, branch) {
292
+ const { state, body } = await rawTextFetch(repo, branch, 'package.json')
293
+ if (state !== FETCH_OK) return { pkg: null, reason: state }
294
+ try {
295
+ const pkg = JSON.parse(body)
296
+ if (pkg !== null && typeof pkg === 'object' && typeof pkg.name === 'string') return { pkg, reason: 'ok' }
297
+ } catch {}
298
+ return { pkg: null, reason: 'invalid' }
299
+ }
300
+
301
+ async function fetchRepoPackage(repo, branch) {
302
+ return (await fetchRepoPackageEx(repo, branch)).pkg
303
+ }
304
+
305
+ /** 探测失败时的用户可读文案:「抓取超时/不可达」与「真的没有」必须区分开。 */
306
+ function packageProbeErrorText(repo, branch, reason) {
307
+ if (reason === FETCH_UNREACHABLE) {
308
+ return `抓取超时/网络不可达:没能读到 ${repo}(${branch} 分支)的 package.json —— 通常是网络到 GitHub 太慢(例如解析出 IPv6 却无 IPv6 路由)。请重试;若持续失败,可先用「仓库落地」克隆到本地目录。`
309
+ }
310
+ if (reason === 'invalid') {
311
+ return `仓库 ${repo} 的 package.json 不是合法的包描述(缺少 name 字段),无法作为插件安装——可改用「仓库落地」克隆到本地目录。`
312
+ }
313
+ return `仓库 ${repo} 没有 package.json(也不是技能仓库),无法作为插件安装——可改用「仓库落地」克隆到本地目录。`
314
+ }
315
+
316
+ /** 提取 README 的标题与开篇段落摘要(首个二级标题之前的正文)。 */
317
+ function summarizeReadme(text) {
318
+ const lines = text.split(/\r?\n/u)
319
+ let title = ''
320
+ const intro = []
321
+ for (const line of lines) {
322
+ const heading = line.match(/^(#{1,3})\s+(.+)$/u)
323
+ if (heading) {
324
+ if (title === '') {
325
+ title = heading[2].trim()
326
+ continue
327
+ }
328
+ break
329
+ }
330
+ if (title === '' && /^[-=]{3,}$/u.test(line.trim()) && line.trim() !== '') continue
331
+ if (title === '') continue
332
+ const cleaned = line
333
+ .replace(/!\[[^\]]*\]\([^)]*\)/gu, '')
334
+ .replace(/\[([^\]]+)\]\([^)]*\)/gu, '$1')
335
+ .replace(/[`*_~]/gu, '')
336
+ .trim()
337
+ if (cleaned) intro.push(cleaned)
338
+ if (intro.join(' ').length > 700) break
339
+ }
340
+ return { title, summary: intro.join(' ').trim().slice(0, 900) }
341
+ }
342
+
343
+ /** 读取一个已加载插件的 package.json 元信息与 README 摘要。 */
344
+ async function readPluginDetails(moduleName, baseUrl, profileDir) {
345
+ if (typeof moduleName !== 'string' || moduleName.startsWith('cordis:')) return { meta: null, readme: null }
346
+ try {
347
+ const pkgPath = resolvePackageJson(moduleName, baseDirOf(baseUrl), profileDir ?? null)
348
+ if (pkgPath === null) throw new Error('not found')
349
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
350
+ const meta = {
351
+ name: moduleName,
352
+ version: pkg.version ?? null,
353
+ description: pkg.description ?? null,
354
+ homepage: pkg.homepage ?? null,
355
+ repository: typeof pkg.repository === 'string' ? pkg.repository : (pkg.repository?.url ?? null),
356
+ }
357
+ let readme = null
358
+ for (const candidate of ['README.zh.md', 'README.md']) {
359
+ try {
360
+ const text = await readFile(join(dirname(pkgPath), candidate), 'utf8')
361
+ readme = summarizeReadme(text)
362
+ break
363
+ } catch {}
364
+ }
365
+ return { meta, readme }
366
+ } catch {
367
+ return { meta: null, readme: null }
368
+ }
369
+ }
370
+
371
+ /** 服务端列出仓库子包(git trees 递归 + 并行读 package.json 的 name)。 */
372
+ async function fetchSubpackageNames(repo, branch, auth) {
373
+ try {
374
+ const data = await githubJson(`${GITHUB_API}/repos/${repo}/git/trees/${encodeURIComponent(branch)}?recursive=1`, undefined, auth)
375
+ const paths = (data.tree ?? [])
376
+ .filter((node) => node.type === 'blob' && /^(?!node_modules\/)[^/]+(?:\/[^/]+)?\/package\.json$/u.test(node.path))
377
+ .map((node) => node.path)
378
+ // 并行读取:黑洞期单条最坏 40s,24 条串行会拖到十几分钟
379
+ const results = await Promise.all(paths.slice(0, 24).map(async (path) => {
380
+ const bodyText = await rawTextWithFallback(repo, branch, path)
381
+ if (bodyText === null) return null
382
+ try {
383
+ const pkg = JSON.parse(bodyText)
384
+ if (pkg && typeof pkg.name === 'string') return { dir: path.split('/')[1], path: path.split('/').slice(0, -1).join('/'), name: pkg.name }
385
+ } catch {}
386
+ return null
387
+ }))
388
+ return results.filter((item) => item !== null)
389
+ } catch {
390
+ return []
391
+ }
392
+ }
393
+
394
+ /** 子包候选:聚合包(名字带 all)优先,上限 8 个。
395
+ * 2026-09-20 真装演练:`@dsh-suite/all` 这种 **scope 根形式**(`/all` 结尾)不被
396
+ * `(^|-)all$` 命中,聚合包没能排到最前(那次纯属仓库目录顺序碰巧第一)。补上 `/all$`。 */
397
+ async function subpackageCandidates(repo, branch, auth) {
398
+ const isAll = (name) => /(^|-)all$/u.test(name) || /-all-/u.test(name) || /\/all$/u.test(name)
399
+ const subs = await fetchSubpackageNames(repo, branch, auth)
400
+ return subs
401
+ .slice()
402
+ .sort((a, b) => Number(isAll(b.name)) - Number(isAll(a.name)))
403
+ .map((sub) => sub.name)
404
+ .slice(0, 8)
405
+ }
406
+
407
+ const ENRICH_CACHE_TTL = 24 * 60 * 60 * 1000
408
+
409
+ export { readEnrichCache, writeEnrichCache, enrichItemOne, enrichItems, normalizePlatformItems, githubRepoInfo, fetchRepoPackage, fetchRepoPackageEx, packageProbeErrorText, searchNpmPackages, searchSubpackageItems, parseRepoFromUrl, hasDirectNameHit, summarizeReadme, readPluginDetails, fetchSubpackageNames, subpackageCandidates, ENRICH_CACHE_TTL }
@@ -0,0 +1,203 @@
1
+ // L1 · domain —— patch.js(由 Step 2 从 lib/index.js 原样切出,只搬移未改逻辑)
2
+ // 分组见 D:\dsh\dsh-plugin-hub-plan\architecture.zh.md §三 L1 · domain
3
+
4
+ import { readFile, writeFile } from 'node:fs/promises'
5
+ import { dirname } from 'node:path'
6
+ import { queuedWrite } from '../infra/fsx.js'
7
+ import { escapeRegExp } from '../infra/mask.js'
8
+ import { resolvePackageJson } from '../infra/paths.js'
9
+
10
+ /** 读取补丁文件并扫描:停用块与 insert 行的 id。 */
11
+ async function readPatchState(patchPath) {
12
+ let text = ''
13
+ try {
14
+ text = await readFile(patchPath, 'utf8')
15
+ } catch (error) {
16
+ if (error.code !== 'ENOENT') throw error
17
+ }
18
+ const disables = []
19
+ const forced = []
20
+ const inserts = []
21
+ const lines = text.split(/\r?\n/u)
22
+ let inInsert = false
23
+ for (let index = 0; index < lines.length; index += 1) {
24
+ const line = lines[index]
25
+ if (/^- insert:\s*$/u.test(line)) {
26
+ inInsert = true
27
+ continue
28
+ }
29
+ if (/^- /u.test(line)) inInsert = false
30
+ if (inInsert) {
31
+ const insertRow = line.match(/^ {4}- id: ([A-Za-z0-9_.-]+)/u)
32
+ if (insertRow) inserts.push(insertRow[1])
33
+ continue
34
+ }
35
+ const disableRow = line.match(/^- id: ([A-Za-z0-9_.-]+)\s*$/u)
36
+ if (!disableRow) continue
37
+ const next = lines[index + 1] ?? ''
38
+ if (/^ {2}disabled: true\s*$/u.test(next)) disables.push(disableRow[1])
39
+ else if (/^ {2}disabled: false\s*$/u.test(next)) forced.push(disableRow[1])
40
+ }
41
+ return { disables, forced, inserts, text }
42
+ }
43
+
44
+ /** 停用:追加 disabled:true 块(已存在则不动)。 */
45
+ async function disableEntry(patchPath, id) {
46
+ return queuedWrite(async () => {
47
+ const { disables, text } = await readPatchState(patchPath)
48
+ if (disables.includes(id)) return { changed: false }
49
+ const clean = sanitizePatchText(text)
50
+ const next = clean.length === 0 || clean.endsWith('\n') ? clean : `${clean}\n`
51
+ await writeFile(patchPath, `${next}${disableBlock(id)}`, 'utf8')
52
+ return { changed: true }
53
+ })
54
+ }
55
+
56
+ /** 启用:移除 disabled:true 块;若仍被 bundle 停用则追加 disabled:false 覆盖。 */
57
+ async function enableEntry(patchPath, id) {
58
+ return queuedWrite(async () => {
59
+ const { disables, forced, text } = await readPatchState(patchPath)
60
+ const blockRe = new RegExp(`^- id: ${escapeRegExp(id)}\\r?\\n disabled: true\\r?\\n`, 'mu')
61
+ if (blockRe.test(text)) {
62
+ await writeFile(patchPath, sanitizePatchText(text.replace(blockRe, '')), 'utf8')
63
+ return { changed: true }
64
+ }
65
+ if (forced.includes(id)) return { changed: false }
66
+ const clean = sanitizePatchText(text)
67
+ const next = clean.length === 0 || clean.endsWith('\n') ? clean : `${clean}\n`
68
+ await writeFile(patchPath, `${next}- id: ${id}\n disabled: false\n`, 'utf8')
69
+ return { changed: true }
70
+ })
71
+ }
72
+
73
+ /** 追加一条 insert 启用行(插件包需已安装到 profile)。 */
74
+ async function appendInsert(patchPath, entryId, packageName) {
75
+ return queuedWrite(async () => {
76
+ const { inserts, text } = await readPatchState(patchPath)
77
+ if (inserts.includes(entryId)) return { changed: false }
78
+ const clean = sanitizePatchText(text)
79
+ const next = clean.length === 0 || clean.endsWith('\n') ? clean : `${clean}\n`
80
+ const block = `- insert:\n - id: ${entryId}\n name: '${packageName}'\n`
81
+ await writeFile(patchPath, `${next}${block}`, 'utf8')
82
+ return { changed: true }
83
+ })
84
+ }
85
+
86
+ /** 从补丁文件移除某行的 insert 块与 disabled/forced 覆盖块。 */
87
+ async function removeInsertRow(patchPath, rowId) {
88
+ return queuedWrite(async () => {
89
+ const { text } = await readPatchState(patchPath)
90
+ const blockRe = new RegExp(`^- insert:\\s*\\r?\\n {4}- id: ${escapeRegExp(rowId)}\\s*\\r?\\n( {6}name: [^\\r\\n]*\\r?\\n)?`, 'mu')
91
+ let next = text.replace(blockRe, '')
92
+ const overrideRe = new RegExp(`^- id: ${escapeRegExp(rowId)}\\s*\\r?\\n {2}disabled: (true|false)\\s*\\r?\\n`, 'mu')
93
+ next = next.replace(overrideRe, '')
94
+ if (next !== text) await writeFile(patchPath, sanitizePatchText(next), 'utf8')
95
+ })
96
+ }
97
+
98
+ /** 移除补丁中的单行 disabled/forced 覆盖块(兼容门解锁用)。 */
99
+ function removeDisableBlock(patchPath, rowId) {
100
+ return queuedWrite(async () => {
101
+ const { text } = await readPatchState(patchPath)
102
+ const overrideRe = new RegExp(`^- id: ${escapeRegExp(rowId)}\\s*\\r?\\n {2}disabled: (true|false)\\s*\\r?\\n`, 'mu')
103
+ const next = text.replace(overrideRe, '')
104
+ if (next !== text) await writeFile(patchPath, sanitizePatchText(next), 'utf8')
105
+ })
106
+ }
107
+
108
+ /**
109
+ * 清理补丁文件中的顶层空数组占位符(issue #7 事故教训):
110
+ * DSH profile 模板的 cordis.patch.yml 以注释 + 顶层 `[]` 占位符初始化(如 `# ...\n[]`)。
111
+ * 直接追加条目会生成 `[]` 后又跟 `- id: xxx` 的非法 YAML(同文档流里数组结束符 + 后续项),
112
+ * 导致 dsh 启动解析崩溃。写入前必须移除顶层独立的 `[]` / `[ ]` 占位行。
113
+ * 仅处理"整行就是空数组"的占位符;合法内容(如 `- insert:` 列表)不受影响。
114
+ */
115
+ function sanitizePatchText(text) {
116
+ return text
117
+ .split(/\r?\n/u)
118
+ .filter((line) => !/^\s*\[\s*\]\s*$/u.test(line))
119
+ .join('\n')
120
+ .replace(/\n{3,}/gu, '\n\n')
121
+ .replace(/\s+$/u, '') + '\n'
122
+ }
123
+
124
+ /** 解析用户补丁中 insert 块的 id → moduleName(name 字段)。 */
125
+ function parseInsertNames(text) {
126
+ const map = new Map()
127
+ const lines = text.split(/\r?\n/u)
128
+ let inInsert = false
129
+ let curId = null
130
+ for (const line of lines) {
131
+ if (/^- insert:\s*$/u.test(line)) { inInsert = true; curId = null; continue }
132
+ if (inInsert && /^- /u.test(line)) inInsert = false
133
+ if (inInsert) {
134
+ const idMatch = line.match(/^\s+- id: ([A-Za-z0-9_.-]+)/u)
135
+ if (idMatch) { curId = idMatch[1]; continue }
136
+ if (curId !== null) {
137
+ const nameMatch = line.match(/^\s+name: ['"]([^'"]+)['"]/u)
138
+ if (nameMatch) { map.set(curId, nameMatch[1]); curId = null }
139
+ }
140
+ }
141
+ }
142
+ return map
143
+ }
144
+
145
+ /**
146
+ * 补丁安全自愈(服务永不崩机制):
147
+ * ① 核心行被误禁用 → 自动移除禁用块恢复;
148
+ * ② 启用态用户 insert 行的模块缺失(如引用未安装包的行)→ 自动禁用(loader 对缺失模块会致命崩溃)。
149
+ * return { healed:[], autoDisabled:[], healedAt } —— healedAt=0 表示本次无修改。
150
+ */
151
+ async function healPatchSafety(patchPath) {
152
+ const profileDir = dirname(patchPath)
153
+ const { text } = await readPatchState(patchPath)
154
+ let next = text
155
+ const healed = []
156
+ const autoDisabled = []
157
+ for (const id of CORE_PATCH_ROW_IDS) {
158
+ const re = new RegExp(`^- id: ${escapeRegExp(id)}\\s*\\r?\\n {2}disabled: true\\s*\\r?\\n`, 'mu')
159
+ if (re.test(next)) {
160
+ next = next.replace(re, '')
161
+ healed.push(id)
162
+ }
163
+ }
164
+ if (healed.length === 0) {
165
+ const insertNames = parseInsertNames(next)
166
+ let require = null
167
+ for (const [id, moduleName] of insertNames) {
168
+ if (!moduleName || moduleName.startsWith('cordis:')) continue
169
+ let ok = true
170
+ try { ok = resolvePackageJson(moduleName, profileDir) !== null } catch { ok = false }
171
+ if (!ok && !new RegExp(`^- id: ${escapeRegExp(id)}\\s*\\r?\\n {2}disabled: true\\s*\\r?\\n`, 'mu').test(next)) {
172
+ next = `${next.trimEnd()}\n- id: ${id}\n disabled: true\n`
173
+ autoDisabled.push(id)
174
+ }
175
+ }
176
+ }
177
+ if (next !== text) {
178
+ await writeFile(patchPath, sanitizePatchText(next), 'utf8')
179
+ }
180
+ return { healed, autoDisabled, healedAt: next !== text ? Date.now() : 0 }
181
+ }
182
+
183
+ /** 从「子包版本对齐」记录解析纯包名(如 '@linxin666/dsh-pet@0.2.1(新装)')。 */
184
+ function syncNameFromNote(note) {
185
+ const m = String(note ?? '').match(/^((?:@[^/]+\/)?[^@]+)/u)
186
+ return m ? m[1] : null
187
+ }
188
+
189
+ /** 框架核心行:误禁用会导致启动失败(2026-09-04 session-persistence-jsonl 事故:6 行 pending、
190
+ * 启动断言失败)。任何补丁写入(适配门/脚本/工具)都禁止禁用这些行;自愈机制自动恢复误禁。 */
191
+ const CORE_PATCH_ROW_IDS = new Set([
192
+ 'session-persistence-jsonl', 'webserver', 'timer', 'hmr', 'session', 'session-checkpoint-policy',
193
+ 'message-feedback', 'workspace', 'storage', 'storage-json', 'storage-domain', 'api-gateway',
194
+ 'api-session-controller', 'api-workspace-controller', 'credentials', 'settings', 'attachment-local',
195
+ 'subprocess', 'sandbox', 'sandbox-policy', 'shell-env', 'agent', 'agent-loop', 'llm', 'web-runtime', 'web-startup',
196
+ ])
197
+
198
+ function disableBlock(id) {
199
+ return `- id: ${id}\n disabled: true\n`
200
+ }
201
+
202
+ export { readPatchState, disableEntry, enableEntry, appendInsert, removeInsertRow, removeDisableBlock, sanitizePatchText, parseInsertNames, healPatchSafety, syncNameFromNote, CORE_PATCH_ROW_IDS, disableBlock }
203
+