@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.
- package/lib/client.js +145 -15
- package/lib/server/domain/bundle-refs.js +42 -2
- package/lib/server/domain/install-job.js +53 -52
- package/lib/server/domain/install-verify.js +59 -1
- package/lib/server/domain/install.js +8 -0
- package/lib/server/domain/landed-source.js +79 -0
- package/lib/server/domain/manifest-probe.js +68 -0
- package/lib/server/domain/market.js +117 -24
- package/lib/server/domain/presence-check.js +121 -0
- package/lib/server/domain/probe-outcome.js +58 -0
- package/lib/server/domain/registry-manifest.js +102 -0
- package/lib/server/domain/repo-package-match.js +135 -0
- package/lib/server/domain/repo-url.js +23 -0
- package/lib/server/domain/subdir-detect.js +181 -0
- package/lib/server/domain/subdir-install.js +368 -0
- package/lib/server/domain/suite.js +4 -1
- package/lib/server/infra/egress.js +208 -0
- package/lib/server/infra/http.js +263 -119
- package/lib/server/routes/components.js +262 -10
- package/lib/server/routes/install.js +27 -0
- package/lib/server/routes/state.js +7 -1
- package/package.json +1 -1
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// L1 · domain —— landed-source.js(本地优先:从「仓库落地」目录读清单)
|
|
2
|
+
//
|
|
3
|
+
// 缺口(2026-10-13 用户实测 `corrinehu/dsh-workbuddy-connect` 报「抓取超时」):
|
|
4
|
+
// 「抓仓库清单」这一步**只有网络通道**——本机 4 条通道全挂(或全慢)就直接失败,
|
|
5
|
+
// 而用户其实早把仓库用「仓库落地」克隆到本地了(默认 ~/.dsh/repos/<owner>/<name>,见 repoland.js)。
|
|
6
|
+
// 网络是**最不可靠**的一环,本地文件却是零成本、零超时、零失败率的 —— 它必须排在竞速之前。
|
|
7
|
+
//
|
|
8
|
+
// 为什么单开一个模块:market.js 的探测链要加"本地优先",而 repoland.js 已经 import 了一堆
|
|
9
|
+
// 网络/进程模块(archive-source / exec / sources)。让 market.js 去 import repoland.js 会把
|
|
10
|
+
// "读一个本地文件"变成拉进整个克隆链路;这里只依赖 node 内置模块 + 调用方给的落地目录根。
|
|
11
|
+
//
|
|
12
|
+
// 分层:本文件属 L1 · domain —— 不得出现 cordis ctx(test-architecture-guard 断言⑤)。
|
|
13
|
+
|
|
14
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
15
|
+
import { join as joinPathDefault } from 'node:path'
|
|
16
|
+
|
|
17
|
+
/** `owner/name` 归一化:去空白、去 `.git` 后缀、统一小写(Windows 路径与 git 地址都不区分大小写)。 */
|
|
18
|
+
function normalizeRepo(repo) {
|
|
19
|
+
return String(repo ?? '').trim().replace(/\.git$/u, '').toLowerCase()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 把一个仓库名折成落地目录候选路径 `[<root>/<owner>/<name>, <root>/<name>]`。
|
|
23
|
+
* 为什么两种:listLandedRepos 只认 `owner/name` 两级,但用户手工 `git clone` 到落地目录下
|
|
24
|
+
* 平铺一层也是常见做法 —— 两种都试,成本只是一个 existsSync。 */
|
|
25
|
+
function landedRepoCandidates(root, repo, deps = {}) {
|
|
26
|
+
const join = deps.join ?? joinPathDefault
|
|
27
|
+
const full = normalizeRepo(repo)
|
|
28
|
+
if (full === '' || !full.includes('/')) return []
|
|
29
|
+
const [owner, ...rest] = full.split('/')
|
|
30
|
+
const name = rest.join('/')
|
|
31
|
+
const base = String(root ?? '').trim()
|
|
32
|
+
if (base === '' || name === '') return []
|
|
33
|
+
return [join(base, owner, name), join(base, name)]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 从磁盘读一份 `package.json` **文本**(本地优先的**唯一落点**)。
|
|
37
|
+
* 返回文本而不是解析后的对象:与网络通道同一口径 —— "读到了"与"是不是合法清单"是两件事,
|
|
38
|
+
* 判定权统一留给上游(fetchRepoPackageEx),**本地来源不得降低校验标准**。
|
|
39
|
+
* 任何失败(路径不存在 / 读不到 / 空文件)一律返回 null,绝不抛。 */
|
|
40
|
+
function localManifestText(dir, deps = {}) {
|
|
41
|
+
const dirText = String(dir ?? '').trim()
|
|
42
|
+
if (dirText === '') return null
|
|
43
|
+
const join = deps.join ?? joinPathDefault
|
|
44
|
+
const exists = deps.exists ?? existsSync
|
|
45
|
+
const read = deps.read ?? ((file) => readFileSync(file, 'utf8'))
|
|
46
|
+
try {
|
|
47
|
+
const file = join(dirText, 'package.json')
|
|
48
|
+
if (!exists(file)) return null
|
|
49
|
+
const text = read(file)
|
|
50
|
+
return typeof text === 'string' && text.trim() !== '' ? text : null
|
|
51
|
+
} catch {
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 探测链用的本地通道实现(生产默认值;单测注入 deps 即可完全离线)。
|
|
57
|
+
* `roots` 是「仓库落地」目录候选根:由调用方给(install-job 传 getReposDir()),
|
|
58
|
+
* 这样本模块不必 import repoland —— 只读探测不拉起克隆链路。
|
|
59
|
+
* 命中顺序即候选顺序(`owner/name` 优先,其次平铺一层)。 */
|
|
60
|
+
function landedManifestText(repo, roots, deps = {}) {
|
|
61
|
+
const dir = landedRepoDir(repo, roots, deps)
|
|
62
|
+
return dir === null ? null : localManifestText(dir, deps)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 命中「仓库落地」时的**本地目录**(0.5.47 加法):用户要能"一键从本地安装",
|
|
66
|
+
* 而安装需要的是**目录**(`file:<dir>` 规格),不只是清单文本。
|
|
67
|
+
* 与 landedManifestText 同一套候选顺序与同一份 deps 注入缝;没命中 ⇒ null。 */
|
|
68
|
+
function landedRepoDir(repo, roots, deps = {}) {
|
|
69
|
+
const list = (Array.isArray(roots) ? roots : [roots]).map((r) => String(r ?? '').trim()).filter((r) => r !== '')
|
|
70
|
+
if (list.length === 0) return null
|
|
71
|
+
for (const root of list) {
|
|
72
|
+
for (const dir of landedRepoCandidates(root, repo, deps)) {
|
|
73
|
+
if (localManifestText(dir, deps) !== null) return dir
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export { normalizeRepo, landedRepoCandidates, localManifestText, landedManifestText, landedRepoDir }
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// L1 · domain —— manifest-probe.js(把"抓仓库清单"的**额外通道**与**通道身份**真正接上;0.5.47)
|
|
2
|
+
//
|
|
3
|
+
// 用户复核上一批时的铁证:`market.js` 调 `rawTextFetch(repo, branch, 'package.json')` **一个 deps 都没传**
|
|
4
|
+
// ⇒ `http.js` 里那段 extraChannels 注入缝**不可达**;三个新模块被 import 0 次 —— 功能一样都没接上。
|
|
5
|
+
// 本模块就是"接线"那一步的唯一落点:**根清单探测只此一个入口**,它负责
|
|
6
|
+
// ① 把「仓库落地」目录根(`getReposDir()`,真实来源,不写死)与首选包名提示真的传进探测链;
|
|
7
|
+
// ② 把结果里的通道身份/逐条失败原因写回作业(面板据此显示"经 npm 镜像 / 本地落地 / 直连…");
|
|
8
|
+
// ③ 命中本地落地时给出"一键从本地安装"的落点(目录 + `link:` 规格)。
|
|
9
|
+
//
|
|
10
|
+
// 为什么单开一个模块(而不是直接写在 install-job.js 里):install-job.js 贴着
|
|
11
|
+
// test-architecture-guard.mjs 断言①的 600 行硬顶,而这一段是有判据、可离线测的接线逻辑。
|
|
12
|
+
//
|
|
13
|
+
// 分层:本文件属 L1 · domain —— 不得出现 cordis ctx(test-architecture-guard 断言⑤)。
|
|
14
|
+
|
|
15
|
+
import { getReposDir } from './repoland.js'
|
|
16
|
+
import { probeChannelLine } from './probe-outcome.js'
|
|
17
|
+
import { localInstallHint } from './presence-check.js'
|
|
18
|
+
|
|
19
|
+
/** 把"额外通道"的 deps **绑在探测袋子上**(就地包装 `probes.fetchRepoPackageEx`,返回这份 deps)。
|
|
20
|
+
*
|
|
21
|
+
* 为什么必须绑在袋子上、而不是只在调用点传第三个参数:根清单探测有**两个入口** ——
|
|
22
|
+
* `suite.js#shouldRunSuiteInstall` 会先用 `probes.fetchRepoPackageEx(repo, branch)`(**2 参**)探一次,
|
|
23
|
+
* 并把结果缓存进 `job.rootPkgProbe`,install-job 随后**复用那份缓存**。于是"只在 install-job 那一行
|
|
24
|
+
* 传 deps"等于没传:套装那次 2 参调用已经把整条路走完,额外通道(本地落地 / npm registry)
|
|
25
|
+
* **根本不会被用到** —— 与上一批"注入缝不可达"是同一个形状的坑,本轮实测撞到。
|
|
26
|
+
* 绑在袋子上之后两个入口拿到同一份 deps;注入的桩也照样收到第三个参数(测试可断言它非空)。 */
|
|
27
|
+
function bindMarketDeps(probes, job, deps = {}) {
|
|
28
|
+
const hinted = typeof probes.npmNameHint === 'function' ? probes.npmNameHint(job.repo) : null
|
|
29
|
+
const marketDeps = {
|
|
30
|
+
roots: [getReposDir()],
|
|
31
|
+
packageNames: [hinted].filter((n) => typeof n === 'string' && n !== ''),
|
|
32
|
+
...(deps.marketDeps ?? {}),
|
|
33
|
+
}
|
|
34
|
+
const raw = probes.fetchRepoPackageEx
|
|
35
|
+
if (typeof raw === 'function') {
|
|
36
|
+
probes.fetchRepoPackageEx = (repo, branch, extra = {}) => raw(repo, branch, { ...marketDeps, ...extra })
|
|
37
|
+
}
|
|
38
|
+
return marketDeps
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 抓根清单(**唯一调用点**)。返回 `rootProbe`(= fetchRepoPackageEx 的结果),
|
|
42
|
+
* 并把下面这些**写回 job**(纯加法字段,老客户端忽略):
|
|
43
|
+
* · `probe` —— `{ channel, label, summary, failures, registryNotes, packageName, localDir }`
|
|
44
|
+
* · `channelNotes` —— 人话一行(经 noteChannel 追加):"清单经 npm 镜像(npmmirror)读到(包名)"
|
|
45
|
+
* · `packageName` —— 清单里的包名(作业原本只从索引提示/子包展开拿,现在也认清单)
|
|
46
|
+
* · `localDir` / `localHint` —— 本地落地命中时的目录与 `link:` 规格(一键从本地安装)
|
|
47
|
+
* deps 已由 bindMarketDeps 绑在袋子上 ⇒ 这里 2 参调用即可(两个入口同一份 deps)。 */
|
|
48
|
+
async function probeRootManifest({ job, probes, noteChannel, branch = 'main' }) {
|
|
49
|
+
const rootProbe = job.rootPkgProbe !== undefined
|
|
50
|
+
? job.rootPkgProbe
|
|
51
|
+
: await probes.fetchRepoPackageEx(job.repo, branch)
|
|
52
|
+
job.probe = {
|
|
53
|
+
channel: rootProbe.channel ?? null, label: rootProbe.label ?? null, summary: rootProbe.summary ?? '',
|
|
54
|
+
failures: Array.isArray(rootProbe.failures) ? rootProbe.failures : [],
|
|
55
|
+
registryNotes: Array.isArray(rootProbe.registryNotes) ? rootProbe.registryNotes : [],
|
|
56
|
+
packageName: rootProbe.packageName ?? null, localDir: rootProbe.localDir ?? null,
|
|
57
|
+
}
|
|
58
|
+
const channelLine = probeChannelLine(rootProbe)
|
|
59
|
+
if (channelLine !== null) noteChannel(job, channelLine)
|
|
60
|
+
if (job.packageName === null && typeof rootProbe.packageName === 'string' && rootProbe.packageName !== '') job.packageName = rootProbe.packageName
|
|
61
|
+
if (typeof rootProbe.localDir === 'string' && rootProbe.localDir !== '') {
|
|
62
|
+
job.localDir = rootProbe.localDir
|
|
63
|
+
job.localHint = localInstallHint({ dir: rootProbe.localDir, packageName: rootProbe.packageName })
|
|
64
|
+
}
|
|
65
|
+
return rootProbe
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export { probeRootManifest, bindMarketDeps }
|
|
@@ -5,7 +5,13 @@ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
|
5
5
|
import { readFile } from 'node:fs/promises'
|
|
6
6
|
import { dirname, join } from 'node:path'
|
|
7
7
|
import { detectSkillRepo } from './skills.js'
|
|
8
|
-
import {
|
|
8
|
+
import { dshMarkersOf } from './subdir-detect.js'
|
|
9
|
+
// 0.5.47 接线:仓库引用 → 仓库标识的解析切到叶子模块(两个方向都要用,见 repo-url.js 顶部)
|
|
10
|
+
import { parseRepoFromUrl } from './repo-url.js'
|
|
11
|
+
import { landedManifestText, landedRepoDir } from './landed-source.js'
|
|
12
|
+
import { repoMatchedRegistryChannels } from './repo-package-match.js'
|
|
13
|
+
import { probeChannelLine, probeFailureLines, composeProbeFailureText } from './probe-outcome.js'
|
|
14
|
+
import { FETCH_OK, FETCH_UNREACHABLE, GITHUB_API, channelLabel, channelFailureSummary, curlJson, curlText, fetchJsonUrl, githubJson, looksLikeGitmodules, rawTextFetch, rawTextWithFallback } from '../infra/http.js'
|
|
9
15
|
import { ENRICH_CACHE_FILE, baseDirOf, marketIndexCacheFile, resolvePackageJson } from '../infra/paths.js'
|
|
10
16
|
|
|
11
17
|
function readEnrichCache() {
|
|
@@ -160,17 +166,8 @@ function githubRepoInfo(repo) {
|
|
|
160
166
|
return `${match[1]}/${match[2]}`
|
|
161
167
|
}
|
|
162
168
|
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
}
|
|
169
|
+
// `parseRepoFromUrl` 已切到叶子模块 domain/repo-url.js(0.5.47):repo-package-match.js 也要用它,
|
|
170
|
+
// 而那个模块被本文件 import —— 留在本文件就会成环。这里只 import + re-export,导出名与语义一字未变。
|
|
174
171
|
|
|
175
172
|
/** 结果里是否已有"名字逐词命中查询词"的条目——决定要不要再加 `in:readme` 重查一次。
|
|
176
173
|
* (仓库搜索的检索面只有 名字/描述/topics;README 里的词必须显式 in:readme 才查得到) */
|
|
@@ -284,24 +281,72 @@ async function searchSubpackageItems(query, token = null, signal = null) {
|
|
|
284
281
|
return subItems
|
|
285
282
|
}
|
|
286
283
|
|
|
287
|
-
/**
|
|
284
|
+
/** 包探测(带失败原因 + **通道身份** + 逐条失败原因):reason ∈ ok / not-found / unreachable / invalid。
|
|
288
285
|
* 事故(2026-09-20,另一位用户:Android + proot Ubuntu,容器无 IPv6 路由):
|
|
289
286
|
* 抓取超时与真 404 都让上层拿到同一个 null,于是「网络太慢」被报成「仓库没有 package.json」,
|
|
290
|
-
* 用户与日志都被误导。现在把两种结局分开,文案也分开。
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
287
|
+
* 用户与日志都被误导。现在把两种结局分开,文案也分开。
|
|
288
|
+
*
|
|
289
|
+
* ★ 0.5.47 接线(**上一批的错就在这一行**:`rawTextFetch(repo, branch, 'package.json')` 一个 deps
|
|
290
|
+
* 都没传,于是 http.js 里那段 extraChannels 注入缝**根本不可达**)—— 现在真正接上两条额外通道:
|
|
291
|
+
* ① 本地落地(domain/landed-source.js):命中 ⇒ 零网络、零超时地读到清单,并带出本地目录
|
|
292
|
+
* ("一键从本地安装"要用的是**目录**);
|
|
293
|
+
* ② npm registry(domain/repo-package-match.js):按 `repository` 字段把仓库引用匹配到包,
|
|
294
|
+
* 匹配上 ⇒ 读该包在 registry 上的清单;镜像优先(npmmirror)→ 官方兜底,各带上限。
|
|
295
|
+
* 返回里多几项(**纯加法**,老调用点解构 `{ pkg, reason }` 一字不用改):
|
|
296
|
+
* `channel`/`label`(胜出通道)、`failures`/`summary`(逐条原因)、`registryNotes`(匹配过程)、
|
|
297
|
+
* `localDir`(本地落地命中目录)、`packageName`(清单里的包名)。
|
|
298
|
+
* `deps.roots` = 「仓库落地」目录候选根(调用方传 `getReposDir()`);`deps.packageNames` = 首选包名
|
|
299
|
+
* 提示(市场索引 npmName);其余 deps 原样透传给 registry 通道(单测注入用)。 */
|
|
300
|
+
async function fetchRepoPackageEx(repo, branch, deps = {}) {
|
|
301
|
+
const roots = deps.roots ?? []
|
|
302
|
+
const packageNames = Array.isArray(deps.packageNames) ? deps.packageNames : []
|
|
303
|
+
const extraChannels = []
|
|
304
|
+
const localDir = landedRepoDir(repo, roots, deps)
|
|
305
|
+
if (localDir !== null) extraChannels.push({ id: 'local', label: channelLabel('local'), run: () => landedManifestText(repo, roots, deps) })
|
|
306
|
+
const registryChannels = deps.registryChannels ?? repoMatchedRegistryChannels(repo, { packageNames, deps })
|
|
307
|
+
for (const ch of registryChannels) extraChannels.push(ch)
|
|
308
|
+
const { state, body, channel, failures } = await rawTextFetch(repo, branch, 'package.json', deps.budgetMs, { ...deps, extraChannels })
|
|
309
|
+
const info = {
|
|
310
|
+
channel: channel ?? null, label: channel === null ? null : channelLabel(channel),
|
|
311
|
+
failures: Array.isArray(failures) ? failures : [], summary: channelFailureSummary(failures),
|
|
312
|
+
packageName: null, localDir: null, registryNotes: Array.isArray(deps.registryNotes) ? deps.registryNotes : [],
|
|
313
|
+
}
|
|
314
|
+
if (state !== FETCH_OK) return { pkg: null, reason: state, ...info }
|
|
294
315
|
try {
|
|
295
316
|
const pkg = JSON.parse(body)
|
|
296
|
-
if (pkg !== null && typeof pkg === 'object' && typeof pkg.name === 'string')
|
|
317
|
+
if (pkg !== null && typeof pkg === 'object' && typeof pkg.name === 'string') {
|
|
318
|
+
info.packageName = pkg.name
|
|
319
|
+
if (channel === 'local') info.localDir = localDir
|
|
320
|
+
return { pkg, reason: 'ok', ...info }
|
|
321
|
+
}
|
|
297
322
|
} catch {}
|
|
298
|
-
return { pkg: null, reason: 'invalid' }
|
|
323
|
+
return { pkg: null, reason: 'invalid', ...info }
|
|
299
324
|
}
|
|
300
325
|
|
|
301
326
|
async function fetchRepoPackage(repo, branch) {
|
|
302
327
|
return (await fetchRepoPackageEx(repo, branch)).pkg
|
|
303
328
|
}
|
|
304
329
|
|
|
330
|
+
/** 读仓库里**指定路径**的文件文本(2026-10-13 加法,子目录插件安装用):`<子目录>/package.json`。
|
|
331
|
+
*
|
|
332
|
+
* 为什么要单开一个入口:子目录安装必须先**核实那份清单**(是不是 DSH 插件、包名是什么),
|
|
333
|
+
* 而这条路此前只能读仓库根(fetchRepoPackageEx 写死 'package.json')。判据刻意与根探测同一把尺子。
|
|
334
|
+
*
|
|
335
|
+
* 出网收口(与单测红线同一条缝):走 `__setSubpkgProbeDefaults({ rawText })` ——
|
|
336
|
+
* 单测把回落值换成**会抛的 tripwire** ⇒ 真有代码走到真实出网会当场红、且一个请求都发不出去
|
|
337
|
+
* (与 routes/components.js 的 `__setOpenFolderSpawn` 同一套做法:进程与出网各收一个可注入单点;
|
|
338
|
+
* 测试**不许**给 node:child_process / 任何 ESM 命名空间打补丁)。生产不注入 ⇒ 回落真实现。
|
|
339
|
+
* 返回文本;404 / 超时 / 不可达 / 空 body 一律 null —— 调用方负责把"没读到"与"没有"分开表达。 */
|
|
340
|
+
async function fetchRepoPathText(repo, branch, path, deps = {}) {
|
|
341
|
+
const read = typeof deps.rawText === 'function' ? deps.rawText : probeEgressDefaults?.rawText
|
|
342
|
+
if (typeof read === 'function') {
|
|
343
|
+
const text = await read(repo, branch, path)
|
|
344
|
+
return typeof text === 'string' && text.trim() !== '' ? text : null
|
|
345
|
+
}
|
|
346
|
+
const { state, body } = await rawTextFetch(repo, branch, path)
|
|
347
|
+
return state === FETCH_OK ? body : null
|
|
348
|
+
}
|
|
349
|
+
|
|
305
350
|
/** 探测失败时的用户可读文案:「抓取超时/不可达」与「真的没有」必须区分开。 */
|
|
306
351
|
function packageProbeErrorText(repo, branch, reason) {
|
|
307
352
|
if (reason === FETCH_UNREACHABLE) {
|
|
@@ -379,7 +424,11 @@ function subpackageBranchOrder(branch, defaults = ['main', 'master']) {
|
|
|
379
424
|
return [...new Set(merged.map((b) => b.trim()))]
|
|
380
425
|
}
|
|
381
426
|
|
|
382
|
-
/** 单个分支上读子包列表(git trees 递归 + 并行读 package.json 的 name)。
|
|
427
|
+
/** 单个分支上读子包列表(git trees 递归 + 并行读 package.json 的 name)。
|
|
428
|
+
* 2026-10-13 加法:每行多带一个 `markers`(该清单里的 `dsh.bundle` / `dsh.client` 标记,见
|
|
429
|
+
* subdir-detect.dshMarkersOf)——**零额外网络请求**(package.json 本来就要读来取 name),
|
|
430
|
+
* 让上层能回答"这个子目录是不是 DSH 插件",而不是只知道"这里有个包"。
|
|
431
|
+
* 老调用方(只读 name)签名与语义一字未变(多一个字段属纯加法)。 */
|
|
383
432
|
async function fetchSubpackageNamesOnBranch(repo, branch, auth, { ghJson, rawText }) {
|
|
384
433
|
try {
|
|
385
434
|
const data = await ghJson(`${GITHUB_API}/repos/${repo}/git/trees/${encodeURIComponent(branch)}?recursive=1`, undefined, auth)
|
|
@@ -392,7 +441,7 @@ async function fetchSubpackageNamesOnBranch(repo, branch, auth, { ghJson, rawTex
|
|
|
392
441
|
if (bodyText === null) return null
|
|
393
442
|
try {
|
|
394
443
|
const pkg = JSON.parse(bodyText)
|
|
395
|
-
if (pkg && typeof pkg.name === 'string') return { dir: path.split('/')[1], path: path.split('/').slice(0, -1).join('/'), name: pkg.name }
|
|
444
|
+
if (pkg && typeof pkg.name === 'string') return { dir: path.split('/')[1], path: path.split('/').slice(0, -1).join('/'), name: pkg.name, markers: dshMarkersOf(pkg) }
|
|
396
445
|
} catch {}
|
|
397
446
|
return null
|
|
398
447
|
}))
|
|
@@ -402,6 +451,32 @@ async function fetchSubpackageNamesOnBranch(repo, branch, auth, { ghJson, rawTex
|
|
|
402
451
|
}
|
|
403
452
|
}
|
|
404
453
|
|
|
454
|
+
/** 仓库子目录插件识别(2026-10-13 加法,真机 `Fei2411/DeepSeek-Bot`):
|
|
455
|
+
* 根目录没有 package.json 时**向下找** `dsh` 插件(1–2 层,与上面的路径口径一致)。
|
|
456
|
+
* 返回 `[{ path, name, markers }]`,path 为相对仓库根的目录(如 `ds-bot`)。
|
|
457
|
+
* 分支按 `subpackageBranchOrder`(已拿到的默认分支打头,再回退 main/master)。
|
|
458
|
+
* **失败与"确实没有"必须分得开**:扫描抛错/读不到 → 返回 `null`("没读到");
|
|
459
|
+
* 扫完确实一个都没有 → 返回 `[]`("没有")。上层文案据此决定是"未发现"还是
|
|
460
|
+
* "子目录这一轮没能完整读到,结论只覆盖读到的部分"(`decideNoRootManifest` 的 scanFailed)。
|
|
461
|
+
* deps 只为单测注入,生产调用不传;**原样透传给 fetchSubpackageNames**(同一个 deps 袋子:
|
|
462
|
+
* `{ githubJson, rawText }` 走真实现,或 `{ fetchSubpackageNames: 桩 }` 整体替换)——
|
|
463
|
+
* 上一版用 `deps.fetchDeps` 又包了一层,结果单测的桩拿到空袋子(`rawText is not a function`)。 */
|
|
464
|
+
async function fetchSubdirPlugins(repo, branch, auth = null, deps = {}) {
|
|
465
|
+
const fetchNames = typeof deps.fetchSubpackageNames === 'function' ? deps.fetchSubpackageNames : fetchSubpackageNames
|
|
466
|
+
let list = null
|
|
467
|
+
try {
|
|
468
|
+
list = await fetchNames(repo, branch, auth, deps)
|
|
469
|
+
} catch {
|
|
470
|
+
return null
|
|
471
|
+
}
|
|
472
|
+
if (!Array.isArray(list)) return null
|
|
473
|
+
return list
|
|
474
|
+
.filter((item) => item !== null && typeof item === 'object' && typeof item.name === 'string' && item.name !== '')
|
|
475
|
+
.map((item) => ({ path: String(item.path ?? item.dir ?? ''), name: item.name, markers: Array.isArray(item.markers) ? item.markers : [] }))
|
|
476
|
+
.filter((item) => item.path !== '')
|
|
477
|
+
.slice(0, 12)
|
|
478
|
+
}
|
|
479
|
+
|
|
405
480
|
/** 从市场索引里查这个仓库的**首选 npm 包名**(批次 C-⑪,2026-09-27 加法)。
|
|
406
481
|
* 数据源与 /market-index 完全一致:routes/market.js 每次成功拉到索引都会写一份落盘缓存
|
|
407
482
|
* (infra/paths.marketIndexCacheFile),这里读它 —— 与内存缓存等价、且跨重启有效。
|
|
@@ -460,12 +535,30 @@ async function expandSubpackageCandidates({ repo, branch = 'main', auth = null,
|
|
|
460
535
|
.slice(0, limit)
|
|
461
536
|
}
|
|
462
537
|
|
|
538
|
+
// ── 子包探针的出网回落值收口(可注入 + 红线保险;2026-10-13)────────────────────────────
|
|
539
|
+
// 为什么要有这一层:`test-subdir-plugin.mjs` 的"全程离线"是靠**把桩塞进 deps 袋子**保证的,
|
|
540
|
+
// 而 `fetchSubpackageNames` 在 deps 缺项时会**回落到真实现**(下面的 githubJson /
|
|
541
|
+
// rawTextWithFallback)—— 一旦哪天桩的袋子没接上(本文件已有一次前科:上一版用 deps.fetchDeps
|
|
542
|
+
// 多包了一层,单测的桩拿到空袋子),测试会**先真的发出 GitHub 请求**、然后才红。
|
|
543
|
+
// 那不算守卫("真出网了也只是事后报错")。所以把这两个回落值收成**唯一可注入点**:
|
|
544
|
+
// ① 注入缝:单测 `__setSubpkgProbeDefaults({ ghJson, rawText })` 换成**会抛的 tripwire**
|
|
545
|
+
// ⇒ 真有代码走到出网,当场红,且一个请求都发不出去(预防,不是事后取证);
|
|
546
|
+
// ② 生产不注入(null)⇒ 回落值与改动前逐字相同(githubJson / rawTextWithFallback),行为零变化。
|
|
547
|
+
// 与 routes/components.js 的 `__setOpenFolderSpawn` 同一套做法(那边管进程,这边管出网)。
|
|
548
|
+
let probeEgressDefaults = null
|
|
549
|
+
|
|
550
|
+
/** 单测注入子包探针的出网回落值:传对象 = 打桩(**不发任何真实请求**);传 null = 还原生产实现。 */
|
|
551
|
+
function __setSubpkgProbeDefaults(deps) {
|
|
552
|
+
probeEgressDefaults = deps !== null && typeof deps === 'object' ? deps : null
|
|
553
|
+
return probeEgressDefaults !== null
|
|
554
|
+
}
|
|
555
|
+
|
|
463
556
|
/** 服务端列出仓库子包(git trees 递归 + 并行读 package.json 的 name)。
|
|
464
557
|
* branch 可以是**单个分支名**(老调用点不变)或**分支数组**(按序试);两种形态都会自动
|
|
465
558
|
* 追加 main / master 兜底(见 subpackageBranchOrder)。deps 只为单测注入,生产调用不传。 */
|
|
466
559
|
async function fetchSubpackageNames(repo, branch, auth, deps = {}) {
|
|
467
|
-
const ghJson = deps.githubJson ?? githubJson
|
|
468
|
-
const rawText = deps.rawText ?? rawTextWithFallback
|
|
560
|
+
const ghJson = deps.githubJson ?? probeEgressDefaults?.ghJson ?? githubJson
|
|
561
|
+
const rawText = deps.rawText ?? probeEgressDefaults?.rawText ?? rawTextWithFallback
|
|
469
562
|
for (const b of subpackageBranchOrder(branch)) {
|
|
470
563
|
// eslint-disable-next-line no-await-in-loop
|
|
471
564
|
const subs = await fetchSubpackageNamesOnBranch(repo, b, auth, { ghJson, rawText })
|
|
@@ -489,4 +582,4 @@ async function subpackageCandidates(repo, branch, auth) {
|
|
|
489
582
|
|
|
490
583
|
const ENRICH_CACHE_TTL = 24 * 60 * 60 * 1000
|
|
491
584
|
|
|
492
|
-
export { readEnrichCache, writeEnrichCache, enrichItemOne, enrichItems, normalizePlatformItems, githubRepoInfo, fetchRepoPackage, fetchRepoPackageEx, packageProbeErrorText, searchNpmPackages, searchSubpackageItems, parseRepoFromUrl, hasDirectNameHit, summarizeReadme, readPluginDetails, fetchSubpackageNames, fetchSubpackageNamesOnBranch, subpackageBranchOrder, subpackageCandidates, expandSubpackageCandidates, npmNameHintForRepo, applyNpmNameHint, ENRICH_CACHE_TTL }
|
|
585
|
+
export { readEnrichCache, writeEnrichCache, enrichItemOne, enrichItems, normalizePlatformItems, githubRepoInfo, fetchRepoPackage, fetchRepoPackageEx, fetchRepoPathText, packageProbeErrorText, searchNpmPackages, searchSubpackageItems, parseRepoFromUrl, hasDirectNameHit, summarizeReadme, readPluginDetails, fetchSubpackageNames, fetchSubpackageNamesOnBranch, fetchSubdirPlugins, subpackageBranchOrder, subpackageCandidates, expandSubpackageCandidates, npmNameHintForRepo, applyNpmNameHint, ENRICH_CACHE_TTL, probeChannelLine, probeFailureLines, composeProbeFailureText, __setSubpkgProbeDefaults }
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// L1 · domain —— presence-check.js("它其实已经装上了"的只读核实)
|
|
2
|
+
//
|
|
3
|
+
// 缺口(2026-10-13 用户实测 `corrinehu/dsh-workbuddy-connect`,报「抓取超时」):
|
|
4
|
+
// 那个插件**当时其实已经装好了**(profile dependencies 里有 `^0.7.2`、`dsh.profile.bundles`
|
|
5
|
+
// 里有它、node_modules 下也有实体目录),可面板给的是一句网络失败 —— 用户以为没装上,
|
|
6
|
+
// 于是反复重装。**报"安装失败"之前必须先核实"是不是已经装上了"**。
|
|
7
|
+
//
|
|
8
|
+
// 判据(三处独立事实,全部只读,零网络):
|
|
9
|
+
// ① `<profile>/node_modules/<name>/package.json` 能解析且 name 完全相等 → 物在盘上(最硬的一条)
|
|
10
|
+
// ② `<profile>/package.json` 的 dependencies / devDependencies 里有这个 name(装过并钉住的痕迹)
|
|
11
|
+
// ③ `<profile>/package.json` 的 `dsh.profile.bundles` 里有这个 name(装配链已认它)
|
|
12
|
+
// 三者**分开如实汇报**:只有 ① 能说"已安装";只有 ②③ 而没有 ①,说明"声明在、物不在"
|
|
13
|
+
// (正是 0.4.x 那类"装了没钉住 / 删了目录没删声明"的现场)—— 那种情况必须说清缺什么,
|
|
14
|
+
// 不能跟着报成功(与 manifest.js#fallbackNoteOf 的诚实口径一致)。
|
|
15
|
+
//
|
|
16
|
+
// 分层:本文件属 L1 · domain —— 不得出现 cordis ctx(test-architecture-guard 断言⑤)。
|
|
17
|
+
|
|
18
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
19
|
+
import { join } from 'node:path'
|
|
20
|
+
|
|
21
|
+
/** 读 profile 根 package.json(解析失败/不存在一律 null,绝不抛)。 */
|
|
22
|
+
function readProfilePkg(profileDir, deps = {}) {
|
|
23
|
+
const exists = deps.exists ?? existsSync
|
|
24
|
+
const read = deps.read ?? ((file) => readFileSync(file, 'utf8'))
|
|
25
|
+
try {
|
|
26
|
+
const file = join(String(profileDir ?? ''), 'package.json')
|
|
27
|
+
if (!exists(file)) return null
|
|
28
|
+
const parsed = JSON.parse(read(file))
|
|
29
|
+
return parsed !== null && typeof parsed === 'object' ? parsed : null
|
|
30
|
+
} catch {
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 核实一个包是否已在某个 profile 里(三处事实分开返回)。`pkgName` 为空/非法 → installed=false。
|
|
36
|
+
* 返回 `{ installed, packageName, version, sources, missing, message }`:
|
|
37
|
+
* · installed=true → ① 命中(物在盘上),可直接对用户说"已安装 vX(无需重装)"
|
|
38
|
+
* · installed=false → `missing` 列出"有声明没实体"的那些痕迹,供上层拼诚实文案 */
|
|
39
|
+
function checkPluginPresence(profileDir, pkgName, deps = {}) {
|
|
40
|
+
const name = typeof pkgName === 'string' ? pkgName.trim() : ''
|
|
41
|
+
const out = { installed: false, packageName: name, version: null, sources: [], missing: [], message: '' }
|
|
42
|
+
if (name === '') return out
|
|
43
|
+
const exists = deps.exists ?? existsSync
|
|
44
|
+
const read = deps.read ?? ((file) => readFileSync(file, 'utf8'))
|
|
45
|
+
const nodeModulesDir = join(String(profileDir ?? ''), 'node_modules')
|
|
46
|
+
// ① 物在盘上(最硬的一条)
|
|
47
|
+
try {
|
|
48
|
+
const manifest = join(nodeModulesDir, name, 'package.json')
|
|
49
|
+
if (exists(manifest)) {
|
|
50
|
+
let parsed = null
|
|
51
|
+
try { parsed = JSON.parse(read(manifest)) } catch { parsed = null }
|
|
52
|
+
if (parsed !== null && typeof parsed === 'object' && parsed.name === name) {
|
|
53
|
+
out.installed = true
|
|
54
|
+
out.version = typeof parsed.version === 'string' ? parsed.version : null
|
|
55
|
+
out.sources.push('node_modules')
|
|
56
|
+
out.message = `已安装 ${name}${out.version === null ? '' : ` v${out.version}`}(无需重装)`
|
|
57
|
+
return out
|
|
58
|
+
}
|
|
59
|
+
// 目录在但清单读不出来/名字对不上:不算已安装,但值得如实说明(半成品目录)
|
|
60
|
+
out.missing.push(`node_modules/${name}(清单读不出来或 name 不匹配)`)
|
|
61
|
+
}
|
|
62
|
+
} catch {}
|
|
63
|
+
// ② / ③ 声明层痕迹(agent 面板与官方插件页看见的就是它们)
|
|
64
|
+
const pkg = readProfilePkg(profileDir, deps)
|
|
65
|
+
try {
|
|
66
|
+
const depsObj = pkg?.dependencies
|
|
67
|
+
const devDepsObj = pkg?.devDependencies
|
|
68
|
+
if (depsObj !== null && typeof depsObj === 'object' && typeof depsObj[name] === 'string') out.sources.push('dependencies')
|
|
69
|
+
if (devDepsObj !== null && typeof devDepsObj === 'object' && typeof devDepsObj[name] === 'string') out.sources.push('devDependencies')
|
|
70
|
+
const bundles = pkg?.dsh?.profile?.bundles
|
|
71
|
+
if (Array.isArray(bundles) && bundles.includes(name)) out.sources.push('dsh.profile.bundles')
|
|
72
|
+
} catch {}
|
|
73
|
+
if (out.sources.length > 0) {
|
|
74
|
+
out.missing.push(`node_modules/${name}(物不在盘上)`)
|
|
75
|
+
out.message = `声明里有 ${name}(${out.sources.join('、')}),但 ${out.missing.join('、')} —— 这是"装了没装完整",需要重装一次`
|
|
76
|
+
}
|
|
77
|
+
return out
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 从仓库名猜包名候选(`owner/dsh-foo` → `dsh-foo`)。
|
|
81
|
+
* 只用于**兜底核实**(市场索引的 npmNameHint 才是首选口径):用户点的是仓库,
|
|
82
|
+
* 而插件包名多半就是仓库名去掉 owner(实测 `corrinehu/dsh-workbuddy-connect` → `dsh-workbuddy-connect`)。
|
|
83
|
+
* 猜错无害:核实失败就照旧报网络错误,绝不会因为"猜中"而谎报已安装(名字必须完全相等)。 */
|
|
84
|
+
function packageNameCandidates(repo, extra = []) {
|
|
85
|
+
const out = []
|
|
86
|
+
const push = (value) => {
|
|
87
|
+
const text = typeof value === 'string' ? value.trim() : ''
|
|
88
|
+
if (text !== '' && !out.includes(text)) out.push(text)
|
|
89
|
+
}
|
|
90
|
+
for (const value of extra) push(value)
|
|
91
|
+
const full = String(repo ?? '').trim().replace(/\.git$/u, '')
|
|
92
|
+
if (full.includes('/')) push(full.split('/').pop())
|
|
93
|
+
return out
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 在候选名里找**第一个**真的已装上的(物在盘上)。
|
|
97
|
+
* 返回 checkPluginPresence 的结果或 null。多个候选只回报第一次命中 —— 不猜、不合并。 */
|
|
98
|
+
function firstInstalledPresence(profileDir, names, deps = {}) {
|
|
99
|
+
for (const name of Array.isArray(names) ? names : [names]) {
|
|
100
|
+
const res = checkPluginPresence(profileDir, name, deps)
|
|
101
|
+
if (res.installed) return res
|
|
102
|
+
}
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 本地落地命中时给用户的"一键从本地安装"落点:目录 + 既有本地目录来源的规格。
|
|
107
|
+
* 规格用 `link:` —— 与仓库既有约定一致(source-spec.js 判据 C / subdir-install.js:
|
|
108
|
+
* `pnpm add link:<目录>` 就是本地目录的正常形态,依赖由 link 自足性补齐),不另发明协议。 */
|
|
109
|
+
function localInstallHint({ dir, packageName = null } = {}) {
|
|
110
|
+
const text = typeof dir === 'string' ? dir.trim() : ''
|
|
111
|
+
if (text === '') return null
|
|
112
|
+
const spec = `link:${text.replace(/\\/gu, '/')}`
|
|
113
|
+
return {
|
|
114
|
+
dir: text,
|
|
115
|
+
spec,
|
|
116
|
+
packageName: typeof packageName === 'string' && packageName !== '' ? packageName : null,
|
|
117
|
+
note: `已从「仓库落地」本地目录读到清单:${text} —— 可按本地目录安装(${spec}),无需等网络`,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export { readProfilePkg, checkPluginPresence, packageNameCandidates, firstInstalledPresence, localInstallHint }
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// L1 · domain —— probe-outcome.js(探测结果的**通道身份 / 逐条失败原因 / 已装核实**收口;0.5.47 加法)
|
|
2
|
+
//
|
|
3
|
+
// 为什么单开一个模块:install-job.js 贴着 600 行架构硬顶(test-architecture-guard 断言①),
|
|
4
|
+
// 而"清单是怎么读到的、失败了都试过谁、其实是不是已经装上了"这三件事本身就是一段有判据、
|
|
5
|
+
// 可离线测的纯逻辑;放在 market.js 里还会把它顶过 600 行(本轮就撞上了)。
|
|
6
|
+
//
|
|
7
|
+
// 三层内容:
|
|
8
|
+
// ① 通道身份:成功时如实说"经哪条通道读到的"(本地落地 / npm 镜像 / …),不再只说失败;
|
|
9
|
+
// ② 逐条失败原因:全失败时**逐条**列出"通道 + 各自为什么没成"(用户红线:不许一句 unreachable 带过);
|
|
10
|
+
// ③ 已装核实:报"失败"之前先核实"是不是其实已经装上了"(判据在 domain/presence-check.js,只读零网络)。
|
|
11
|
+
//
|
|
12
|
+
// 分层:本文件属 L1 · domain —— 不得出现 cordis ctx(test-architecture-guard 断言⑤)。
|
|
13
|
+
|
|
14
|
+
import { channelFailureSummary, channelLabel } from '../infra/http.js'
|
|
15
|
+
/** ★胜出通道身份(0.5.47):成功时如实告诉用户"清单是**经哪条通道**读到的"
|
|
16
|
+
* (本地落地 / npm 镜像 / npm 官方源 / https / gh CLI / curl 直连 / jsDelivr)。
|
|
17
|
+
* 为什么必须带出来:用户被"抓取超时"误导过一次 —— 面板只说失败、不说试过什么,
|
|
18
|
+
* 于是既不知道能不能换源、也不知道其实本地就有。 */
|
|
19
|
+
function probeChannelLine(probe) {
|
|
20
|
+
if (probe === null || typeof probe !== 'object') return null
|
|
21
|
+
const channel = typeof probe.channel === 'string' && probe.channel !== '' ? probe.channel : null
|
|
22
|
+
if (channel === null) return null
|
|
23
|
+
const name = typeof probe.packageName === 'string' && probe.packageName !== '' ? `(${probe.packageName})` : ''
|
|
24
|
+
if (channel === 'local') {
|
|
25
|
+
const dir = typeof probe.localDir === 'string' && probe.localDir !== '' ? `:${probe.localDir}` : ''
|
|
26
|
+
return `清单来自「仓库落地」本地目录${dir}${name}`
|
|
27
|
+
}
|
|
28
|
+
return `清单经 ${channelLabel(channel)} 读到${name}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 失败时的逐条原因清单(字符串行数组;空 = 一个原因都没收到)。
|
|
32
|
+
* 收 `summary`(已经拼好的文本)与 `failures`(结构化数组)两种形态:install-job 下发的
|
|
33
|
+
* `job.probe` 是前者,`fetchRepoPackageEx` 的返回是后者,两条路都要能出清单。 */
|
|
34
|
+
function probeFailureLines(probe) {
|
|
35
|
+
if (probe === null || typeof probe !== 'object') return []
|
|
36
|
+
const summary = typeof probe.summary === 'string' && probe.summary !== ''
|
|
37
|
+
? probe.summary
|
|
38
|
+
: channelFailureSummary(probe.failures)
|
|
39
|
+
return summary === '' ? [] : summary.split('\n')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** ★全通道失败时的诚实文案:既有 baseText(**保留现文案**)+ 逐条「通道:原因」+ registry 匹配备注。
|
|
43
|
+
* 用户红线:**不许一句 unreachable 带过** —— 每条通道各自为什么没成都要看得见。 */
|
|
44
|
+
function composeProbeFailureText(probe, baseText) {
|
|
45
|
+
const base = typeof baseText === 'string' && baseText !== '' ? baseText : '抓取仓库清单失败'
|
|
46
|
+
const lines = probeFailureLines(probe)
|
|
47
|
+
const notes = Array.isArray(probe?.registryNotes) ? probe.registryNotes.filter((n) => typeof n === 'string' && n !== '') : []
|
|
48
|
+
const parts = [base]
|
|
49
|
+
if (lines.length > 0) parts.push(`各通道的失败原因:\n${lines.join('\n')}`)
|
|
50
|
+
if (notes.length > 0) parts.push(`npm registry 核对(按 repository 字段匹配,不靠猜名):\n${notes.map((n) => `· ${n}`).join('\n')}`)
|
|
51
|
+
return parts.join('\n')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 「报失败之前先核实其实已经装上了」的**实现归属**:domain/install-verify.js 的
|
|
55
|
+
// installedEvidence / failedJobEvidence(0.5.47 那批已接在 routes/install-status 与 routes/state 两处)。
|
|
56
|
+
// 本模块只管"这次探测是怎么读到的 / 为什么没读到",不重复实现核实 —— 同一件事只留一个承担者。
|
|
57
|
+
|
|
58
|
+
export { probeChannelLine, probeFailureLines, composeProbeFailureText }
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// L1 · domain —— registry-manifest.js(npm registry 上的**包清单**通道:packument 元数据)
|
|
2
|
+
//
|
|
3
|
+
// 缺口(2026-10-13 用户实测 `corrinehu/dsh-workbuddy-connect` 报「抓取超时」):
|
|
4
|
+
// 「抓仓库清单」这一步的 4 条通道**全部指向 GitHub**(raw / api / gh CLI / jsDelivr CDN)——
|
|
5
|
+
// GitHub 一挂,整个探测就完;而**同一个包在 npm 上好好的**(该插件确实已发布:name
|
|
6
|
+
// `dsh-workbuddy-connect`、version 0.7.3)。registry 是另一条完全独立的网络路径
|
|
7
|
+
// (国内还有 npmmirror 镜像),把它接进探测链就能救回这一整类"GitHub 不通但 npm 通"的失败。
|
|
8
|
+
//
|
|
9
|
+
// 取的是 registry 的 **packument /latest 文档**:它本身就是这个包的 package.json 清单
|
|
10
|
+
// (name / version / private / dsh 标记 / repository …),无需下载 tarball 再解压 —— 一次 GET,
|
|
11
|
+
// 与"读仓库清单"的产物同构,因此可以直接进同一条竞速链。
|
|
12
|
+
//
|
|
13
|
+
// 顺序(按可达性):npmmirror 镜像优先 → 官方 registry 兜底;各带上限(见 REGISTRY_TIMEOUT_MS)。
|
|
14
|
+
// **安全**:镜像与官方一视同仁 —— 拿回来的文本照样过上游的 JSON 解析 + name 字段校验,
|
|
15
|
+
// 回退通道不降低任何校验标准。
|
|
16
|
+
//
|
|
17
|
+
// 分层:本文件属 L1 · domain —— 不得出现 cordis ctx(test-architecture-guard 断言⑤)。
|
|
18
|
+
|
|
19
|
+
import { curlText } from '../infra/http.js'
|
|
20
|
+
|
|
21
|
+
/** 单条 registry 通道的请求上限。两条串行(镜像→官方)最坏 ≈2×8s,与既有 4 条通道并行,
|
|
22
|
+
* 不额外拉长整体等待(外层还有 FETCH_BUDGET_MS 兜底)。 */
|
|
23
|
+
const REGISTRY_TIMEOUT_MS = 8000
|
|
24
|
+
const NPM_MIRROR = 'https://registry.npmmirror.com'
|
|
25
|
+
const NPM_OFFICIAL = 'https://registry.npmjs.org'
|
|
26
|
+
|
|
27
|
+
/** 只接受合法 npm 包名(外部索引/环境给的名字不得拿去拼 URL 路径)。
|
|
28
|
+
* 与 market.js#npmNameHintForRepo 同一判据(那里也在拦外部数据)。 */
|
|
29
|
+
function isSafePackageName(name) {
|
|
30
|
+
return /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/u.test(String(name ?? ''))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** scoped 包名的路径形式:`@scope/name` → `@scope%2Fname`(registry 也接受未编码的 `/`,
|
|
34
|
+
* 但编码后对镜像更稳)。非 scoped 原样返回。 */
|
|
35
|
+
function registryPathOf(name) {
|
|
36
|
+
const text = String(name ?? '')
|
|
37
|
+
return text.startsWith('@') ? text.replace('/', '%2F') : text
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 读一个包在某个 registry 上的清单**文本**(`<registry>/<name>/latest`)。
|
|
41
|
+
* 返回 string = 读到了;返回 null = 这个 registry 上**确定没有**这个包(404)
|
|
42
|
+
* —— 或(0.5.47 加法)文档的 `repository` 字段**指向的不是调用方要的那个仓库**:
|
|
43
|
+
* 本模块只负责"取回来","是不是本仓库的包"由 repo-package-match.js 判;
|
|
44
|
+
* 但一旦调用方给了 `expectedRepo`,不匹配就必须当成"没读到"(**不猜名**:名字像不算命中)。
|
|
45
|
+
* 抛异常 = 通道本身失败(网络/超时/5xx),由竞速记录成"这条通道为什么没成"。
|
|
46
|
+
* deps.text 只为单测注入(生产走 curlText,绕开 node:https 的 TLS 指纹拦截)。 */
|
|
47
|
+
async function registryManifestText(name, registry = NPM_MIRROR, deps = {}) {
|
|
48
|
+
if (!isSafePackageName(name)) throw new Error('包名不合法,拒绝请求 registry')
|
|
49
|
+
const text = deps.text ?? curlText
|
|
50
|
+
const url = `${String(registry).replace(/\/+$/u, '')}/${registryPathOf(name)}/latest`
|
|
51
|
+
const timeout = Number.isFinite(deps.timeoutMs) && deps.timeoutMs > 0 ? deps.timeoutMs : REGISTRY_TIMEOUT_MS
|
|
52
|
+
try {
|
|
53
|
+
const body = await text(url, timeout, { ipv4: true })
|
|
54
|
+
const trimmed = typeof body === 'string' ? body.trim() : ''
|
|
55
|
+
if (trimmed === '') throw new Error('registry 返回空响应')
|
|
56
|
+
if (typeof deps.expectedRepo === 'string' && deps.expectedRepo !== '' && !repoFieldMatches(trimmed, deps.expectedRepo)) return null
|
|
57
|
+
return trimmed
|
|
58
|
+
} catch (error) {
|
|
59
|
+
// 404 = 该源上没有这个包(确定性结论,交回上层按 not-found 处理,不再上升为通道失败)
|
|
60
|
+
if (error instanceof Error && /HTTP 404|404/u.test(error.message)) return null
|
|
61
|
+
throw error
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 文档的 `repository` 字段是否指向这个仓库(归一化比较:忽略大小写 / `.git` / `git+` / `#path`)。
|
|
66
|
+
* 这里做**宽松**判定(只回答"像不像同一个仓库"),严格判定在 repo-package-match.js。
|
|
67
|
+
* 拿不到 repository 字段 ⇒ 一律不匹配(没有证据就不认账)。 */
|
|
68
|
+
function repoFieldMatches(manifestText, repo) {
|
|
69
|
+
let parsed = null
|
|
70
|
+
try { parsed = JSON.parse(manifestText) } catch { return false }
|
|
71
|
+
const raw = parsed?.repository ?? parsed?.latest?.repository ?? null
|
|
72
|
+
const url = typeof raw === 'string' ? raw : (raw !== null && typeof raw === 'object' ? raw.url : null)
|
|
73
|
+
const norm = (value) => String(value ?? '').trim().toLowerCase()
|
|
74
|
+
.replace(/^git\+/u, '').replace(/\.git(?=$|[#?])/u, '').replace(/^\/+|\/+$/gu, '')
|
|
75
|
+
const want = norm(repo)
|
|
76
|
+
if (want === '' || typeof url !== 'string' || url === '') return false
|
|
77
|
+
const text = norm(url)
|
|
78
|
+
// 认 https://github.com/o/r、git://github.com/o/r、github:o/r、ssh://git@github.com/o/r 这些写法
|
|
79
|
+
const tail = text.replace(/^[a-z+]+:\/\//u, '').replace(/^[^@/]*@/u, '').replace(/^[a-z]+:/u, '')
|
|
80
|
+
const path = tail.replace(/^[^/]+\//u, '')
|
|
81
|
+
return tail === want || path === want || tail.endsWith(`/${want}`) || text.includes(want)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 探测链的 registry 通道描述列表(顺序即优先级:npmmirror → 官方)。
|
|
85
|
+
* `deps.text` 注入后两条通道共用(单测离线用);生产不传。 */
|
|
86
|
+
function registryManifestChannels(name, deps = {}) {
|
|
87
|
+
if (!isSafePackageName(name)) return []
|
|
88
|
+
return [
|
|
89
|
+
{
|
|
90
|
+
id: 'npm-mirror',
|
|
91
|
+
label: 'npm 镜像',
|
|
92
|
+
run: () => registryManifestText(name, NPM_MIRROR, deps),
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: 'npm-official',
|
|
96
|
+
label: 'npm 官方源',
|
|
97
|
+
run: () => registryManifestText(name, NPM_OFFICIAL, deps),
|
|
98
|
+
},
|
|
99
|
+
]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export { REGISTRY_TIMEOUT_MS, NPM_MIRROR, NPM_OFFICIAL, isSafePackageName, registryPathOf, registryManifestText, registryManifestChannels, repoFieldMatches }
|