@noob-stupid/dsh-plugin-console 0.5.46 → 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 +93 -9
- package/lib/server/domain/install-job.js +26 -12
- package/lib/server/domain/install-verify.js +59 -1
- package/lib/server/domain/install.js +7 -3
- package/lib/server/domain/landed-source.js +79 -0
- package/lib/server/domain/manifest-probe.js +68 -0
- package/lib/server/domain/market.js +64 -20
- 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 +36 -7
- 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 +85 -8
- package/lib/server/routes/install.js +27 -0
- package/lib/server/routes/state.js +7 -1
- package/package.json +1 -1
|
@@ -6,7 +6,12 @@ import { readFile } from 'node:fs/promises'
|
|
|
6
6
|
import { dirname, join } from 'node:path'
|
|
7
7
|
import { detectSkillRepo } from './skills.js'
|
|
8
8
|
import { dshMarkersOf } from './subdir-detect.js'
|
|
9
|
-
|
|
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'
|
|
10
15
|
import { ENRICH_CACHE_FILE, baseDirOf, marketIndexCacheFile, resolvePackageJson } from '../infra/paths.js'
|
|
11
16
|
|
|
12
17
|
function readEnrichCache() {
|
|
@@ -161,17 +166,8 @@ function githubRepoInfo(repo) {
|
|
|
161
166
|
return `${match[1]}/${match[2]}`
|
|
162
167
|
}
|
|
163
168
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
function parseRepoFromUrl(url) {
|
|
167
|
-
const raw = String(url ?? '').trim()
|
|
168
|
-
// npm 老式简写:`github:owner/repo` / `gitee:owner/repo`
|
|
169
|
-
const shorthand = raw.match(/^(?:github|gitee):([^/\s]+)\/([^/\s#?]+?)(?:\.git)?(?:[#?].*)?$/u)
|
|
170
|
-
if (shorthand !== null) return `${shorthand[1]}/${shorthand[2]}`
|
|
171
|
-
const m = raw.match(/(?:github\.com|gitee\.com)[/:]([^/\s]+)\/([^/\s#?]+?)(?:\.git)?(?:[#?].*)?$/u)
|
|
172
|
-
if (m === null) return null
|
|
173
|
-
return `${m[1]}/${m[2]}`
|
|
174
|
-
}
|
|
169
|
+
// `parseRepoFromUrl` 已切到叶子模块 domain/repo-url.js(0.5.47):repo-package-match.js 也要用它,
|
|
170
|
+
// 而那个模块被本文件 import —— 留在本文件就会成环。这里只 import + re-export,导出名与语义一字未变。
|
|
175
171
|
|
|
176
172
|
/** 结果里是否已有"名字逐词命中查询词"的条目——决定要不要再加 `in:readme` 重查一次。
|
|
177
173
|
* (仓库搜索的检索面只有 名字/描述/topics;README 里的词必须显式 in:readme 才查得到) */
|
|
@@ -285,24 +281,72 @@ async function searchSubpackageItems(query, token = null, signal = null) {
|
|
|
285
281
|
return subItems
|
|
286
282
|
}
|
|
287
283
|
|
|
288
|
-
/**
|
|
284
|
+
/** 包探测(带失败原因 + **通道身份** + 逐条失败原因):reason ∈ ok / not-found / unreachable / invalid。
|
|
289
285
|
* 事故(2026-09-20,另一位用户:Android + proot Ubuntu,容器无 IPv6 路由):
|
|
290
286
|
* 抓取超时与真 404 都让上层拿到同一个 null,于是「网络太慢」被报成「仓库没有 package.json」,
|
|
291
|
-
* 用户与日志都被误导。现在把两种结局分开,文案也分开。
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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 }
|
|
295
315
|
try {
|
|
296
316
|
const pkg = JSON.parse(body)
|
|
297
|
-
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
|
+
}
|
|
298
322
|
} catch {}
|
|
299
|
-
return { pkg: null, reason: 'invalid' }
|
|
323
|
+
return { pkg: null, reason: 'invalid', ...info }
|
|
300
324
|
}
|
|
301
325
|
|
|
302
326
|
async function fetchRepoPackage(repo, branch) {
|
|
303
327
|
return (await fetchRepoPackageEx(repo, branch)).pkg
|
|
304
328
|
}
|
|
305
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
|
+
|
|
306
350
|
/** 探测失败时的用户可读文案:「抓取超时/不可达」与「真的没有」必须区分开。 */
|
|
307
351
|
function packageProbeErrorText(repo, branch, reason) {
|
|
308
352
|
if (reason === FETCH_UNREACHABLE) {
|
|
@@ -538,4 +582,4 @@ async function subpackageCandidates(repo, branch, auth) {
|
|
|
538
582
|
|
|
539
583
|
const ENRICH_CACHE_TTL = 24 * 60 * 60 * 1000
|
|
540
584
|
|
|
541
|
-
export { readEnrichCache, writeEnrichCache, enrichItemOne, enrichItems, normalizePlatformItems, githubRepoInfo, fetchRepoPackage, fetchRepoPackageEx, packageProbeErrorText, searchNpmPackages, searchSubpackageItems, parseRepoFromUrl, hasDirectNameHit, summarizeReadme, readPluginDetails, fetchSubpackageNames, fetchSubpackageNamesOnBranch, fetchSubdirPlugins, subpackageBranchOrder, subpackageCandidates, expandSubpackageCandidates, npmNameHintForRepo, applyNpmNameHint, ENRICH_CACHE_TTL, __setSubpkgProbeDefaults }
|
|
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 }
|
|
@@ -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 }
|
|
@@ -70,12 +70,39 @@ function pickSubdirPlugin(subs) {
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
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
|
+
|
|
73
98
|
/** 命中子目录插件时的用户可读结论(纯函数,单测覆盖)。
|
|
74
99
|
* 要点(对应用户实测的质疑"它不是 DSH 插件吗"):
|
|
75
100
|
* ① 先**纠正结论** —— 这不是"不是插件",而是"插件不在根目录";
|
|
76
101
|
* ② 给出**子目录路径**与**包名**,以及标记来源(dsh.bundle.patch / dsh.client)—— 判据可核验;
|
|
77
|
-
* ③
|
|
78
|
-
*
|
|
102
|
+
* ③ 给出**可安装的下一步**(0.5.47 改错):控制台自己能从这个子目录装 —— 面板上的按钮走
|
|
103
|
+
* `/plugin-console/install` + `subdir`,服务端先稀疏取该子目录源码到本地、再按既有
|
|
104
|
+
* **本地目录来源**(`link:`)装进 profile;清单校验 / 来源校验与别的安装路径**同一套**。
|
|
105
|
+
* npm 上若已发布该包,另给一条可直接照抄的命令(两条路都要说清,不二选一藏起来)。 */
|
|
79
106
|
function subdirPluginText(repo, branch, pick, source = 'github') {
|
|
80
107
|
const dir = String(pick?.dir ?? '')
|
|
81
108
|
const pkgName = String(pick?.packageName ?? '')
|
|
@@ -83,9 +110,10 @@ function subdirPluginText(repo, branch, pick, source = 'github') {
|
|
|
83
110
|
return `仓库 ${repo}(${branch} 分支)根目录没有 package.json,但**插件在子目录 ${dir}/ 下**`
|
|
84
111
|
+ `(${dir}/package.json 声明了 DSH 标记:${markers})。`
|
|
85
112
|
+ `它就是 DSH 插件,只是清单不在仓库根——${pkgName === '' ? '该子包的包名没能读到' : `包名是 ${pkgName}`}。`
|
|
86
|
-
+
|
|
87
|
-
+
|
|
88
|
-
+
|
|
113
|
+
+ `下一步:点下面的「从这个子目录安装」即可 —— 控制台会先把 ${dir}/ 的源码取到本地,`
|
|
114
|
+
+ `再按既有**本地目录来源**(link:)装进 profile(清单校验与来源校验与其它安装路径同一套,`
|
|
115
|
+
+ `装完照样收口:清单 + lock + 挂载行)。`
|
|
116
|
+
+ `${pkgName === '' ? '' : `该包若已发布到 npm,也可以直接安装:dsh plugin --profile <你的 profile> add ${pkgName}。`}`
|
|
89
117
|
}
|
|
90
118
|
|
|
91
119
|
/** 扫完根目录与子目录都没找到插件时的用户可读结论(纯函数,单测覆盖)。
|
|
@@ -138,7 +166,8 @@ async function decideNoRootManifest({ repo, branch, reason, subScan = null, prob
|
|
|
138
166
|
if (pick.found === true) {
|
|
139
167
|
return {
|
|
140
168
|
hint: 'subdir-plugin',
|
|
141
|
-
|
|
169
|
+
// 0.5.47:`install` 是**可执行**的下一步(面板据此渲染按钮);老客户端忽略该字段(纯加法)。
|
|
170
|
+
subplugin: { dir: pick.dir, packageName: pick.packageName, markers: pick.markers, checkedDirs: pick.checkedDirs, install: subdirInstallPlan(repo, branch, pick) },
|
|
142
171
|
error: subdirPluginText(repo, branch, pick, source),
|
|
143
172
|
}
|
|
144
173
|
}
|
|
@@ -149,4 +178,4 @@ async function decideNoRootManifest({ repo, branch, reason, subScan = null, prob
|
|
|
149
178
|
}
|
|
150
179
|
}
|
|
151
180
|
|
|
152
|
-
export { dshMarkersOf, describeDshManifest, pickSubdirPlugin, subdirPluginText, notPluginText, decideNoRootManifest }
|
|
181
|
+
export { dshMarkersOf, describeDshManifest, pickSubdirPlugin, subdirInstallPlan, subdirPluginText, notPluginText, decideNoRootManifest }
|