@noob-stupid/dsh-plugin-console 0.5.45 → 0.5.47

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.
@@ -0,0 +1,135 @@
1
+ // L1 · domain —— repo-package-match.js(**按 `repository` 字段把仓库引用匹配到 npm 包**;0.5.47 加法)
2
+ //
3
+ // 缺口(与 0.5.46 同源的真机现场):用户按**仓库引用**(`corrinehu/dsh-workbuddy-connect`)安装时,
4
+ // 「抓仓库清单」这一步四条通道全指向 GitHub,GitHub 一挂整条路就断;而同一个包在 npm 上好好的。
5
+ // 要把 registry 接进回退链,先得回答"这个仓库**对应哪个 npm 包**"。
6
+ //
7
+ // 判据(用户红线):**不靠猜名** —— 名字只是候选,命中的唯一凭据是 registry 文档里
8
+ // `repository` 字段**逐字符指向这个仓库**(归一化后相等:忽略大小写、忽略 `.git` 后缀,
9
+ // 认 `git+https://` / `git://` / `github:o/r` / `#path` 这些写法,见 market.js#parseRepoFromUrl)。
10
+ // 名字猜错无害(匹配不上就当作"没这条通道");名字猜对也**不会**因为"名字像"就认账。
11
+ //
12
+ // 数据结构(registry `<name>/latest` 文档 = 该版本的 package.json):
13
+ // `{ name, version, repository: "https://github.com/o/r" | { url } | "github:o/r", … }`
14
+ // 取 `repository` 时两处都看:`latest.repository` 与顶层 `repository`(历史包两种都出现过)。
15
+ //
16
+ // 分层:本文件属 L1 · domain —— 不得出现 cordis ctx(test-architecture-guard 断言⑤)。
17
+
18
+ import { parseRepoFromUrl } from './repo-url.js'
19
+ import { isSafePackageName, registryManifestText, NPM_MIRROR, NPM_OFFICIAL } from './registry-manifest.js'
20
+
21
+ /** 仓库引用归一化:去空白、去 `git+`、去 `.git` 后缀、去首尾斜杠、统一小写。 */
22
+ function normalizeRepoRef(repo) {
23
+ return String(repo ?? '')
24
+ .trim()
25
+ .replace(/^git\+/u, '')
26
+ .replace(/\.git$/u, '')
27
+ .replace(/^\/+|\/+$/gu, '')
28
+ .toLowerCase()
29
+ }
30
+
31
+ /** 从 registry 文档里取 `repository` 的仓库标识(认字符串 / `{ url }` / 老式简写)。
32
+ * 取不到 → null(**不猜**:没有 `repository` 字段的包不参与按仓库匹配)。 */
33
+ function repositoryRefOf(manifest) {
34
+ if (manifest === null || typeof manifest !== 'object') return null
35
+ const raw = manifest.repository ?? manifest.latest?.repository ?? null
36
+ const url = typeof raw === 'string' ? raw : (raw !== null && typeof raw === 'object' ? raw.url : null)
37
+ const parsed = parseRepoFromUrl(url)
38
+ return parsed === null ? null : normalizeRepoRef(parsed)
39
+ }
40
+
41
+ /** 这份 registry 文档指向哪个仓库(解析不出 ⇒ null)。 */
42
+ function manifestRepoRef(manifestText) {
43
+ let parsed = null
44
+ try { parsed = JSON.parse(String(manifestText)) } catch { return null }
45
+ return repositoryRefOf(parsed)
46
+ }
47
+
48
+ /** 候选包名:显式给的首选(市场索引 npmName / 调用方)在前,其次**仓库名本身**去 owner。
49
+ * 只保留合法 npm 包名(外部数据不拿去拼 URL)。**不生成 scoped 变体**:那是猜名,本模块口径是不猜。 */
50
+ function candidatePackageNames(repo, extra = []) {
51
+ const out = []
52
+ const push = (value) => {
53
+ const text = typeof value === 'string' ? value.trim() : ''
54
+ if (text !== '' && isSafePackageName(text) && !out.includes(text)) out.push(text)
55
+ }
56
+ for (const value of Array.isArray(extra) ? extra : [extra]) push(value)
57
+ const full = String(repo ?? '').trim().replace(/\.git$/u, '')
58
+ if (full.includes('/')) push(full.split('/').filter((p) => p !== '').pop())
59
+ return out
60
+ }
61
+
62
+ /** registry 源的候选(顺序即优先级:npmmirror 镜像优先 → 官方兜底)。 */
63
+ function registrySources(deps = {}) {
64
+ const given = Array.isArray(deps.registries) ? deps.registries.filter((r) => r !== null && typeof r?.url === 'string' && r.url !== '') : []
65
+ if (given.length > 0) return given
66
+ return [
67
+ { id: 'npm-mirror', label: 'npm 镜像', url: NPM_MIRROR },
68
+ { id: 'npm-official', label: 'npm 官方源', url: NPM_OFFICIAL },
69
+ ]
70
+ }
71
+
72
+ /** 按仓库引用在 registry 上找包:返回 `{ hit, packageName, text, registry, channel, label, notes }`。
73
+ * · hit=true ⇒ `repository` 字段逐字符指向本仓库(证据来自 registry 文档本身,不是名字像)
74
+ * · hit=false ⇒ notes 里逐条写明"哪个候选在哪个源上:没有这个包 / 不是这个仓库 / 通道失败"
75
+ * 每个源上逐个候选试(候选很少,且每步都有上限);deps.text/registries/timeoutMs 只为单测注入。 */
76
+ async function resolvePackageByRepo(repo, { packageNames = [], deps = {} } = {}) {
77
+ const notes = []
78
+ const want = normalizeRepoRef(repo)
79
+ if (want === '') return { hit: false, packageName: null, text: null, registry: null, channel: null, label: null, notes: ['仓库引用为空,未查 registry'] }
80
+ const names = candidatePackageNames(repo, packageNames)
81
+ if (names.length === 0) return { hit: false, packageName: null, text: null, registry: null, channel: null, label: null, notes: ['没有可用作候选的合法 npm 包名(不猜名,跳过 registry 通道)'] }
82
+ for (const source of registrySources(deps)) {
83
+ for (const name of names) {
84
+ try {
85
+ // eslint-disable-next-line no-await-in-loop
86
+ const text = await registryManifestText(name, source.url, deps)
87
+ if (text === null) {
88
+ notes.push(`${name} @ ${source.label}:registry 上没有这个包(404)`)
89
+ continue
90
+ }
91
+ const ref = manifestRepoRef(text)
92
+ if (ref === null) {
93
+ notes.push(`${name} @ ${source.label}:文档里没有可解析的 repository 字段,无法证明它就是这个仓库`)
94
+ continue
95
+ }
96
+ if (ref !== want) {
97
+ notes.push(`${name} @ ${source.label}:仓库字段指向 ${ref},不是 ${want}`)
98
+ continue
99
+ }
100
+ return { hit: true, packageName: name, text, registry: source.url, channel: source.id, label: source.label, notes }
101
+ } catch (error) {
102
+ notes.push(`${name} @ ${source.label}:通道失败(${error instanceof Error ? error.message : String(error)})`)
103
+ }
104
+ }
105
+ }
106
+ return { hit: false, packageName: null, text: null, registry: null, channel: null, label: null, notes }
107
+ }
108
+
109
+ /** 把"按仓库匹配"接上既有 registry 探测链:返回**带 repository 校验**的通道(顺序同 registrySources)。
110
+ * 未命中 ⇒ null("这个候选不是本仓库的包",与 404 同一出口;**不冒充**"这个包不存在");
111
+ * 命中 ⇒ 该包在 registry 上的清单文本(与"读仓库里的 package.json"同构,直接进同一条竞速链)。
112
+ * 回退不降低校验标准:镜像与官方一视同仁,都过 `repository` 字段这一关。 */
113
+ function repoMatchedRegistryChannels(repo, { packageNames = [], deps = {} } = {}) {
114
+ const want = normalizeRepoRef(repo)
115
+ const names = candidatePackageNames(repo, packageNames)
116
+ if (want === '' || names.length === 0) return []
117
+ return registrySources(deps).map((source) => ({
118
+ id: source.id,
119
+ label: `${source.label}(按 repository 字段核对)`,
120
+ run: async () => {
121
+ for (const name of names) {
122
+ // eslint-disable-next-line no-await-in-loop
123
+ const text = await registryManifestText(name, source.url, deps)
124
+ if (text === null) continue
125
+ if (manifestRepoRef(text) === want) return text
126
+ }
127
+ return null
128
+ },
129
+ }))
130
+ }
131
+
132
+ export {
133
+ normalizeRepoRef, repositoryRefOf, manifestRepoRef, candidatePackageNames,
134
+ registrySources, resolvePackageByRepo, repoMatchedRegistryChannels,
135
+ }
@@ -0,0 +1,23 @@
1
+ // L1 · domain —— repo-url.js(仓库引用 ↔ 仓库 URL 的归一与解析;0.5.47 从 market.js 切出)
2
+ //
3
+ // 为什么单开一个叶子模块:`parseRepoFromUrl` **两个方向都要用** —— market.js 的 npm 包名搜索
4
+ // (packument 的 repository.url → 仓库标识)与 repo-package-match.js 的"按 repository 字段匹配仓库"。
5
+ // 后者被 market.js import,若它还回头 import market.js 就成环(ESM 环里 TDZ 会让其中一个拿到 undefined)。
6
+ // 放到叶子模块后两边都只向下依赖,方向单向、可静态断言。
7
+ //
8
+ // 分层:本文件属 L1 · domain —— 不得出现 cordis ctx(test-architecture-guard 断言⑤)。
9
+
10
+ /** 从 npm 的 repository 字段解析出 GitHub/Gitee 仓库标识(纯函数,单测覆盖)。
11
+ * 支持 `https://github.com/o/r.git`、`git+https://…`、`git://…`、`ssh://git@github.com/o/r`、
12
+ * `github:o/r` / `gitee:o/r` 简写、带 `#path` 的 monorepo 写法。 */
13
+ function parseRepoFromUrl(url) {
14
+ const raw = String(url ?? '').trim()
15
+ // npm 老式简写:`github:owner/repo` / `gitee:owner/repo`
16
+ const shorthand = raw.match(/^(?:github|gitee):([^/\s]+)\/([^/\s#?]+?)(?:\.git)?(?:[#?].*)?$/u)
17
+ if (shorthand !== null) return `${shorthand[1]}/${shorthand[2]}`
18
+ const m = raw.match(/(?:github\.com|gitee\.com)[/:]([^/\s]+)\/([^/\s#?]+?)(?:\.git)?(?:[#?].*)?$/u)
19
+ if (m === null) return null
20
+ return `${m[1]}/${m[2]}`
21
+ }
22
+
23
+ export { parseRepoFromUrl }
@@ -0,0 +1,181 @@
1
+ // L1 · domain —— subdir-detect.js(仓库子目录插件识别)
2
+ //
3
+ // 为什么单开一个文件:install-job.js 已经贴着 600 行硬顶(tests/test-architecture-guard.mjs 断言),
4
+ // 而"根目录没有 package.json 时向下扫子目录"是一块自洽的只读逻辑,搬出来还能独立单测。
5
+ //
6
+ // 真机现场(2026-10-13 用户实测):`Fei2411/DeepSeek-Bot`(topics 含 dsh-plugin)根目录**没有**
7
+ // package.json —— 但 `ds-bot/package.json` 同时声明了 `dsh.client` 与 `dsh.bundle.patch`,
8
+ // 是不折不扣的 DSH 插件。旧判定只看仓库根,于是给用户一句"不是插件",
9
+ // 既不准(结论错)也不给路(没有下一步)。判据:**向下扫 1–2 层**(`<dir>/package.json`、
10
+ // `<dir>/<sub>/package.json`,与 fetchSubpackageNames 的路径口径完全一致),
11
+ // 命中带 dsh 标记的清单就明说"插件在 <子目录>/ 下",扫完确实没有才保持旧结论 ——
12
+ // 且文案必须补上"已检查根目录与 N 层子目录"(诚实:让用户知道我们看过哪里)。
13
+ //
14
+ // 分层:本文件属 L1 · domain —— 不得出现 cordis ctx(test-architecture-guard 断言⑤)。
15
+
16
+ /** 清单里的 DSH 插件标记(`dsh.bundle` / `dsh.client` 任一)。
17
+ * 判据与官方安装器一致:`dsh.bundle.patch` 说明是 bundle 层包(其 cordis.patch.yml 会被组合进树),
18
+ * `dsh.client` 说明带 UI 入口 —— 两者任一即"这是给 DSH 用的包"。
19
+ * 纯函数(单测覆盖):JSON 解析失败、`dsh` 不是对象、字段类型不对,一律返回 [],绝不抛。 */
20
+ function dshMarkersOf(pkg) {
21
+ const out = []
22
+ try {
23
+ if (pkg === null || typeof pkg !== 'object') return out
24
+ const dsh = pkg.dsh
25
+ if (dsh !== null && typeof dsh === 'object') {
26
+ if (typeof dsh.bundle?.patch === 'string' && dsh.bundle.patch !== '') out.push('dsh.bundle.patch')
27
+ if (dsh.client !== null && dsh.client !== undefined) out.push('dsh.client')
28
+ }
29
+ } catch {
30
+ return []
31
+ }
32
+ return out
33
+ }
34
+
35
+ /** 读一段 package.json 文本(已从网络/磁盘拿到)→ 归一化的包信息。
36
+ * 返回 `{ name, markers, privateRoot }`;解析失败或没有合法名字返回 null
37
+ * ("读到了但不算包"与"没读到"是两件事,这里只管前者,调用方负责后者)。 */
38
+ function describeDshManifest(text) {
39
+ try {
40
+ const pkg = JSON.parse(text)
41
+ if (pkg === null || typeof pkg !== 'object') return null
42
+ if (typeof pkg.name !== 'string' || pkg.name === '') return null
43
+ return { name: pkg.name, markers: dshMarkersOf(pkg), privateRoot: pkg.private === true }
44
+ } catch {
45
+ return null
46
+ }
47
+ }
48
+
49
+ /** 把"已知子包清单"(各行含 path / name / markers)折成**用户可读的结论**(纯函数,单测覆盖)。
50
+ *
51
+ * `subs` 形态与 market.js 的 `fetchSubdirPlugins` 返回值一致:
52
+ * `[{ path: 'ds-bot', name: 'ds-bot', markers: ['dsh.client', 'dsh.bundle.patch'] }, …]`
53
+ *
54
+ * 返回:
55
+ * · found=true → `{ dir, packageName, markers, checkedDirs }`(插件在哪个子目录)
56
+ * · found=false → `{ checkedDirs }`(真扫过这些目录,一个 dsh 清单都没有)
57
+ * `checkedDirs` 永远如实列出**本次真的读到清单的那些目录**(没读到的用 `scanned` 计数带过),
58
+ * 这样文案里那句"已检查 N 层子目录"是有据可查的,而不是一句空话。 */
59
+ function pickSubdirPlugin(subs) {
60
+ const list = (Array.isArray(subs) ? subs : []).filter((s) => s !== null && typeof s === 'object')
61
+ const checkedDirs = list.map((s) => String(s.path ?? s.dir ?? '')).filter((d) => d !== '')
62
+ const hit = list.find((s) => Array.isArray(s.markers) && s.markers.length > 0)
63
+ if (hit === undefined) return { found: false, checkedDirs }
64
+ return {
65
+ found: true,
66
+ dir: String(hit.path ?? hit.dir ?? ''),
67
+ packageName: typeof hit.name === 'string' && hit.name !== '' ? hit.name : String(hit.path ?? '').split('/').pop(),
68
+ markers: [...hit.markers],
69
+ checkedDirs,
70
+ }
71
+ }
72
+
73
+ /** 命中子目录插件时的**可安装下一步**(纯函数,单测覆盖)。
74
+ *
75
+ * 0.5.47 改错(用户实测):0.5.46 只做到"认出来了 + 给一段可复制的文字",**装不了** ——
76
+ * 用户拿到一句"插件在 ds-bot/ 下"之后还得自己想办法。现在把结构化的可执行计划一并带出:
77
+ * `{ action: 'install-subdir', repo, branch, dir, packageName, markers }`。
78
+ * 面板据此渲染一个**按钮**(而不是让用户从报错里抄路径),点下去就是把 `subdir` 交给既有的
79
+ * `/plugin-console/install` —— 由 domain/subdir-install.js 先落地该子目录、再按既有**本地目录来源**
80
+ * (`link:`,见 source-spec.js 的判据 C)装进 profile。
81
+ * 判据不放松:到服务端那一步仍要重新读 `<dir>/package.json` 并按同一把尺子核实(名字 + dsh 标记),
82
+ * 前端传什么都不算数。`markers` 只为展示判据来源;`repo`/`branch` 由**识别当时**的事实给出,
83
+ * 免得面板再猜一次。 */
84
+ function subdirInstallPlan(repo, branch, pick) {
85
+ const dir = String(pick?.dir ?? '')
86
+ const packageName = String(pick?.packageName ?? '')
87
+ if (dir === '' || packageName === '') return null
88
+ return {
89
+ action: 'install-subdir',
90
+ repo: String(repo ?? ''),
91
+ branch: String(branch ?? ''),
92
+ dir,
93
+ packageName,
94
+ markers: Array.isArray(pick?.markers) ? [...pick.markers] : [],
95
+ }
96
+ }
97
+
98
+ /** 命中子目录插件时的用户可读结论(纯函数,单测覆盖)。
99
+ * 要点(对应用户实测的质疑"它不是 DSH 插件吗"):
100
+ * ① 先**纠正结论** —— 这不是"不是插件",而是"插件不在根目录";
101
+ * ② 给出**子目录路径**与**包名**,以及标记来源(dsh.bundle.patch / dsh.client)—— 判据可核验;
102
+ * ③ 给出**可安装的下一步**(0.5.47 改错):控制台自己能从这个子目录装 —— 面板上的按钮走
103
+ * `/plugin-console/install` + `subdir`,服务端先稀疏取该子目录源码到本地、再按既有
104
+ * **本地目录来源**(`link:`)装进 profile;清单校验 / 来源校验与别的安装路径**同一套**。
105
+ * npm 上若已发布该包,另给一条可直接照抄的命令(两条路都要说清,不二选一藏起来)。 */
106
+ function subdirPluginText(repo, branch, pick, source = 'github') {
107
+ const dir = String(pick?.dir ?? '')
108
+ const pkgName = String(pick?.packageName ?? '')
109
+ const markers = Array.isArray(pick?.markers) ? pick.markers.join('、') : ''
110
+ return `仓库 ${repo}(${branch} 分支)根目录没有 package.json,但**插件在子目录 ${dir}/ 下**`
111
+ + `(${dir}/package.json 声明了 DSH 标记:${markers})。`
112
+ + `它就是 DSH 插件,只是清单不在仓库根——${pkgName === '' ? '该子包的包名没能读到' : `包名是 ${pkgName}`}。`
113
+ + `下一步:点下面的「从这个子目录安装」即可 —— 控制台会先把 ${dir}/ 的源码取到本地,`
114
+ + `再按既有**本地目录来源**(link:)装进 profile(清单校验与来源校验与其它安装路径同一套,`
115
+ + `装完照样收口:清单 + lock + 挂载行)。`
116
+ + `${pkgName === '' ? '' : `该包若已发布到 npm,也可以直接安装:dsh plugin --profile <你的 profile> add ${pkgName}。`}`
117
+ }
118
+
119
+ /** 扫完根目录与子目录都没找到插件时的用户可读结论(纯函数,单测覆盖)。
120
+ * 与 packageProbeErrorText 的分工:那个只管"根目录为什么没读到"(超时/非法/确实没有),
121
+ * 这里补上**本次到底检查了哪里**(子目录层数 + 读到清单的目录名),
122
+ * 让"不是插件"这个结论有据可查,而不是一句没有出处的断言。
123
+ *
124
+ * `scanFailed`(2026-10-13 补):子目录这一轮**没能扫成**(探针抛错 / 返回非数组 / 根本没给探针)时,
125
+ * 结论句必须从"未发现声明 dsh 标记的 package.json"降级成"只覆盖读到的部分" ——
126
+ * 否则一次网络限流就会变成一句比事实更强的断言("没有"≠"没读到")。
127
+ * `reason` 是根清单探测的原因码:`FETCH_UNREACHABLE`('unreachable')同理降级。 */
128
+ function notPluginText(repo, branch, reason, checkedCount, subCount, baseText = '', scanFailed = false) {
129
+ const head = baseText !== '' ? baseText.replace(/。$/u, '') : `仓库 ${repo}(${branch} 分支)不是可安装的插件`
130
+ const partial = scanFailed || reason === 'unreachable' || subCount > checkedCount
131
+ const scope = `已检查根目录与 2 层子目录(本次读到清单的子目录 ${checkedCount} 个`
132
+ + `${subCount > checkedCount ? `,另有 ${subCount - checkedCount} 个目录清单没读到(可能是网络受限,不代表不存在)` : ''})`
133
+ const verdict = partial
134
+ ? ';子目录这一轮没能完整读到,所以"没有 dsh 插件"这条结论只覆盖读到的部分'
135
+ : ',未发现声明 dsh 标记的 package.json'
136
+ return `${head}。${scope}${verdict}。`
137
+ }
138
+
139
+ /** 根目录没有清单时**唯一**的结论出口(纯函数,单测覆盖):把扫描结果折成
140
+ * 服务端 job 上要写的那几个字段(hint / error / subplugin),调用方(install-job)只剩赋值。
141
+ * 为什么要把出口收成一个函数:两个分支必须**互斥且都给出路** —— 命中子目录插件时文案说
142
+ * "插件在 <子目录>/ 下 + 下一步",没命中时文案必须补"已检查根目录与 2 层子目录"(诚实)。
143
+ * 任何"分支走错一句话"的变异都会被 tests/test-subdir-plugin.mjs 抓住。
144
+ * `probe`(可选)负责真去扫 —— 注入缝与 install-job 的 marketProbes 同一套语义:
145
+ * 传桩函数即可离线断言"扫到了什么就说什么",生产调用传真的 fetchSubdirPlugins。
146
+ * 探针**抛错或返回非数组**(含 fetchSubdirPlugins 的"扫描失败 → null"契约)⇒ `scanFailed=true`:
147
+ * 结论照旧给,但文案降级为"只覆盖读到的部分"("没读到"绝不冒充"没有")。 */
148
+ async function decideNoRootManifest({ repo, branch, reason, subScan = null, probe = null, baseText = '', source = 'github' } = {}) {
149
+ let scan = []
150
+ let scanFailed = false
151
+ if (Array.isArray(subScan)) {
152
+ scan = subScan
153
+ } else if (typeof probe === 'function') {
154
+ try {
155
+ const result = await probe(repo, branch)
156
+ if (Array.isArray(result)) scan = result
157
+ else scanFailed = true
158
+ } catch {
159
+ scanFailed = true
160
+ }
161
+ } else {
162
+ // 没给探针 = 这一轮根本没扫过子目录 —— 不许假装"检查过了"
163
+ scanFailed = true
164
+ }
165
+ const pick = pickSubdirPlugin(scan)
166
+ if (pick.found === true) {
167
+ return {
168
+ hint: 'subdir-plugin',
169
+ // 0.5.47:`install` 是**可执行**的下一步(面板据此渲染按钮);老客户端忽略该字段(纯加法)。
170
+ subplugin: { dir: pick.dir, packageName: pick.packageName, markers: pick.markers, checkedDirs: pick.checkedDirs, install: subdirInstallPlan(repo, branch, pick) },
171
+ error: subdirPluginText(repo, branch, pick, source),
172
+ }
173
+ }
174
+ return {
175
+ hint: 'repo-land',
176
+ subplugin: null,
177
+ error: notPluginText(repo, branch, reason, pick.checkedDirs.length, Array.isArray(scan) ? scan.length : 0, baseText, scanFailed),
178
+ }
179
+ }
180
+
181
+ export { dshMarkersOf, describeDshManifest, pickSubdirPlugin, subdirInstallPlan, subdirPluginText, notPluginText, decideNoRootManifest }