@noob-stupid/dsh-plugin-console 0.3.67 → 0.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.
- package/lib/client.js +126 -31
- package/lib/index.js +60 -9687
- package/lib/server/domain/ai-run.js +479 -0
- package/lib/server/domain/ai.js +246 -0
- package/lib/server/domain/compat.js +474 -0
- package/lib/server/domain/components.js +108 -0
- package/lib/server/domain/dep-source.js +122 -0
- package/lib/server/domain/format-contract.js +265 -0
- package/lib/server/domain/format-scan.js +434 -0
- package/lib/server/domain/framework.js +393 -0
- package/lib/server/domain/install-job.js +561 -0
- package/lib/server/domain/install.js +599 -0
- package/lib/server/domain/jobs.js +28 -0
- package/lib/server/domain/market.js +409 -0
- package/lib/server/domain/patch.js +203 -0
- package/lib/server/domain/presets.js +93 -0
- package/lib/server/domain/quarantine.js +224 -0
- package/lib/server/domain/release-source.js +504 -0
- package/lib/server/domain/repoland.js +119 -0
- package/lib/server/domain/revoke.js +184 -0
- package/lib/server/domain/runtime.js +118 -0
- package/lib/server/domain/selfupdate.js +319 -0
- package/lib/server/domain/skills.js +234 -0
- package/lib/server/domain/sources.js +297 -0
- package/lib/server/domain/suite.js +220 -0
- package/lib/server/infra/exec.js +98 -0
- package/lib/server/infra/fsx.js +163 -0
- package/lib/server/infra/fw-integrity-check.js +37 -0
- package/lib/server/infra/http.js +373 -0
- package/lib/server/infra/httpd.js +51 -0
- package/lib/server/infra/mask.js +19 -0
- package/lib/server/infra/paths.js +177 -0
- package/lib/server/infra/semver.js +168 -0
- package/lib/server/routes/ai.js +172 -0
- package/lib/server/routes/components.js +254 -0
- package/lib/server/routes/framework-preflight.js +160 -0
- package/lib/server/routes/framework-upgrade.js +679 -0
- package/lib/server/routes/framework.js +544 -0
- package/lib/server/routes/github-login.js +198 -0
- package/lib/server/routes/index.js +128 -0
- package/lib/server/routes/install.js +116 -0
- package/lib/server/routes/market.js +415 -0
- package/lib/server/routes/plugins.js +562 -0
- package/lib/server/routes/skills.js +107 -0
- package/lib/server/routes/sources.js +437 -0
- package/lib/server/routes/state.js +125 -0
- package/lib/server/state.js +22 -0
- 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
|
+
|