@noob-stupid/dsh-plugin-console 0.5.18 → 0.5.19

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.
@@ -21,10 +21,9 @@ import { deriveEntryId, listEntries } from './runtime.js'
21
21
  import { detectSkillRepo, runSkillInstallJob } from './skills.js'
22
22
  import { orderedRegistries, readSources } from './sources.js'
23
23
  import { reconcileLockfile } from './selfupdate.js'
24
- import { probeGitmodules, resolveInstallKind, runSuiteInstallJob } from './suite.js'
24
+ import { fetchRepoMeta, runSuiteInstallJob, shouldRunSuiteInstall } from './suite.js'
25
25
  import { buildPnpmEnv, execFileAsync, runPnpmWithFallback } from '../infra/exec.js'
26
26
  import { cleanupStalePackageDir, startTrashCleanup, trashScanRoots } from '../infra/fsx.js'
27
- import { GITHUB_API, META_BUDGET_MS, curlJson, githubJson } from '../infra/http.js'
28
27
  import { findPatchPath, packageNameOf, resolvePackageJson } from '../infra/paths.js'
29
28
 
30
29
  /** release 通道的候选预算(issue #3):release 通道现在按包名反查发布仓库,**每个候选**至少一次
@@ -242,10 +241,11 @@ async function runInstallJob(job, ports, deps = {}) {
242
241
  }
243
242
  }
244
243
  if (candidates.length === 0) {
245
- // 套装兜底:submodule 聚合仓库(根 .gitmodules 内容校验通过)→ 自动转套装安装,不依赖前端标记。
246
- // 判据是**内容**(resolveInstallKind),不是"探测非 null":后者会把代理/CDN 对不存在文件回的
247
- // 2xx 空 body、垃圾页当成套装(2026-09-19 用户反馈的「未找到 .gitmodules」事故根因)。
248
- if (resolveInstallKind(job.kind, await probeGitmodules(job.repo)) === 'suite') {
244
+ // 套装判定(0.5.19 改错,唯一入口在 suite.js 的 shouldRunSuiteInstall):**先看根包是否已发布**,
245
+ // 再按 .gitmodules 的**内容**判是否套装(不是"探测非 null":代理/CDN 对不存在的文件会回 2xx 空 body、
246
+ // 垃圾页,2026-09-19「未找到 .gitmodules」事故的口径不变)。根包/子包任一已发布到 registry 时优先
247
+ // 插件通道 —— 真机 dsh-web:429 MB 套装克隆 vs 5.97 MB 的子包 @linxin666/dsh-web-all。
248
+ if (await shouldRunSuiteInstall(job, probes) === true) {
249
249
  job.kind = 'suite'
250
250
  const suiteResult = await runSuiteInstallJob(job, ports)
251
251
  // 套装装配出的普通插件是 copyTree 铺进去的(不在 lock 里)→ 同样对账,避免之后被 pnpm 还原/清理
@@ -263,20 +263,15 @@ async function runInstallJob(job, ports, deps = {}) {
263
263
  // clone 后才发现没有 .gitmodules(探测假阳性 / 仓库已重构)→ 回落普通安装,不给用户一个失败
264
264
  if (suiteResult?.notASuite !== true) return
265
265
  job.kind = 'plugin'
266
- job.suiteNote = '探测到的 .gitmodules 与仓库实际内容不符(不是 submodule 套装仓库),已自动回落普通插件安装'
266
+ job.suiteNote = suiteResult?.reason ?? '探测到的 .gitmodules 与仓库实际内容不符(不是 submodule 套装仓库),已自动回落普通插件安装'
267
267
  }
268
- // 兜底:宿主端自行拉取仓库元数据(githubJson 与 curl 竞速 + 8s 超时降级)。8s 而非旧值 3s:
269
- // IPv6 无路由时单条通道就要 5.4s,3s 预算必输 → branch 恒为 main(2026-09-20 另一位用户实测)。
270
- const meta = await Promise.race([
271
- Promise.any([
272
- githubJson(`${GITHUB_API}/repos/${job.repo}`),
273
- curlJson(`${GITHUB_API}/repos/${job.repo}`, 12000, {}, { ipv4: true }),
274
- ]),
275
- new Promise((resolve) => setTimeout(() => resolve(null), META_BUDGET_MS)),
276
- ]).catch(() => null)
268
+ // 仓库元数据(默认分支)探测搬进 suite.js 的 fetchRepoMeta(githubJson 与 curl 竞速 + 8s 降级,
269
+ // 黑洞期不卡 40s;8s 而非 3s 的理由见那里的注释)。套装判定时已经探过一次 → 这里复用缓存,不重复联网。
270
+ const meta = job.repoMeta !== undefined ? job.repoMeta : await fetchRepoMeta(job.repo)
277
271
  const branch = meta?.default_branch ?? 'main'
278
272
  job.defaultBranch = branch // 批次 B-⑥:后续读子包一律以它打头(默认分支是 dev 的仓库不再读不到子包)
279
- const { pkg, reason } = await probes.fetchRepoPackageEx(job.repo, branch)
273
+ const rootProbe = job.rootPkgProbe !== undefined ? job.rootPkgProbe : await probes.fetchRepoPackageEx(job.repo, branch)
274
+ const { pkg, reason } = rootProbe
280
275
  if (pkg === null) {
281
276
  // 无 package.json:先探测是否技能仓库(含 SKILL.md)→ 自动转技能安装;
282
277
  // 否则标记 hint=repo-land,前端给出「仓库落地」一键入口(克隆到本地目录)。
@@ -306,7 +301,7 @@ async function runInstallJob(job, ports, deps = {}) {
306
301
  // private: true,但 main/dev 各有 22 个子包目录。失败原因是当时市场索引源全挂、网络受限,
307
302
  // subpackageCandidates() 读不到列表 —— 旧代码只有"有没有子包"一个出口,把**没读到**
308
303
  // 报成了**不存在**,直接把用户带偏。教训:探测失败必须与确定性结论分开表达。
309
- let subs = await probes.subpackageCandidates(job.repo, branch)
304
+ let subs = job.subpackageProbe !== undefined ? job.subpackageProbe : await probes.subpackageCandidates(job.repo, branch)
310
305
  if (subs.length === 0) {
311
306
  // 读不到时先换一条分支重试:meta 探测失败时 branch 恒为 main,而默认分支为 dev 的仓库
312
307
  // (本例 dsh-web 就是 dev 为默认分支)main 上的子包布局可能不同/为空;
@@ -7,8 +7,10 @@ import { tmpdir } from 'node:os'
7
7
  import { appendInsert } from './patch.js'
8
8
  import { gitCloneRepo } from './repoland.js'
9
9
  import { deriveEntryId, listEntries } from './runtime.js'
10
+ import { noteChannel } from './git-channel.js'
11
+ import { orderedRegistries, readSources } from './sources.js'
10
12
  import { copyTree } from '../infra/fsx.js'
11
- import { looksLikeGitmodules, rawTextWithFallback } from '../infra/http.js'
13
+ import { GITHUB_API, META_BUDGET_MS, curlJson, fetchJsonUrl, githubJson, looksLikeGitmodules, rawTextWithFallback } from '../infra/http.js'
12
14
  import { dshHome, findPatchPath } from '../infra/paths.js'
13
15
 
14
16
  /** 解析 .gitmodules:返回 [{name, path, url}](submodule 套装识别用)。 */
@@ -92,16 +94,140 @@ function resolveInstallKind(requestKind, probeText) {
92
94
  return looksLikeGitmodules(probeText) ? 'suite' : 'plugin'
93
95
  }
94
96
 
97
+ /** 仓库元数据(默认分支)探测:githubJson 与 curlJson 竞速 + META_BUDGET_MS 降级(黑洞期不卡 40s)。
98
+ * 0.5.19 从 install-job.js 原样搬来(**只搬移,表达式一字未改**):套装判定前要先读根包名
99
+ * (见 shouldRunSuiteInstall),这次探测的结果缓存在 job.repoMeta 上供下游复用,同一个作业不重复联网。
100
+ * 8s 而非旧值 3s:IPv6 无路由的环境里单条通道就要 5.4s,3s 预算必输 → branch 恒为 main,
101
+ * 默认分支为 dev 的仓库会取错分支(2026-09-20 另一位用户实测)。 */
102
+ async function fetchRepoMeta(repo) {
103
+ const meta = await Promise.race([
104
+ Promise.any([
105
+ githubJson(`${GITHUB_API}/repos/${repo}`),
106
+ curlJson(`${GITHUB_API}/repos/${repo}`, 12000, {}, { ipv4: true }),
107
+ ]),
108
+ new Promise((resolve) => setTimeout(() => resolve(null), META_BUDGET_MS)),
109
+ ]).catch(() => null)
110
+ return meta ?? null
111
+ }
112
+
113
+ /** 这个包名在 registry 上**确实存在**吗?只有确定性命中才算"存在":404 / 超时 / 不可达一律当"查不到"。
114
+ * 口径必须这么窄的理由:查不到不能推翻 .gitmodules 判据 —— 网络问题不该把套装安装变成插件安装。
115
+ * 用既有 npm 元数据能力(与 market.js 读 packument 同一套:配置里的软件源顺序 + fetchJsonUrl),
116
+ * 最多看前 2 个源、每源 6 秒封顶;测试用 probes.namePublished 替换(见 test-suite-fallback.mjs)。 */
117
+ async function namePublishedOnRegistry(name) {
118
+ const encoded = name.startsWith('@')
119
+ ? `@${encodeURIComponent(name.slice(1).split('/')[0])}%2f${encodeURIComponent(name.split('/').slice(1).join('/'))}`
120
+ : encodeURIComponent(name)
121
+ for (const reg of orderedRegistries(readSources()).slice(0, 2)) {
122
+ try {
123
+ // eslint-disable-next-line no-await-in-loop
124
+ const data = await fetchJsonUrl(`${reg}/${encoded}`, 6000)
125
+ const hasLatest = typeof data?.['dist-tags']?.latest === 'string'
126
+ const hasVersions = data !== null && typeof data === 'object' && data.versions !== null && typeof data.versions === 'object' && Object.keys(data.versions).length > 0
127
+ if (hasLatest || hasVersions) return true
128
+ } catch {}
129
+ }
130
+ return false
131
+ }
132
+
133
+ /** 套装判定(唯一入口,0.5.19 改错):**先看根包是否已发布,再决定要不要按 .gitmodules 走套装**。
134
+ * 真机事故(2026-09-27,用户点装 zhu1090093659/dsh-web 全家桶):市场卡片点「添加到本地」只带 owner/repo、
135
+ * 不带包名 → candidates=[] → 旧流程直接探 .gitmodules(该仓库根目录确实有一份)→ 第一步就 clone 整个仓库
136
+ * (429 MB),而真正能装上的子包 @linxin666/dsh-web-all(5.97 MB)根本没机会被尝试。
137
+ * 判据顺序(每一步都只做"确认",不做"猜测"):
138
+ * ① 读根 package.json(下游本来也要读,结果缓存进 job 复用)→ 有 name 且 registry 上存在 → **插件通道**;
139
+ * ② 否则按 .gitmodules **内容**判(内容不像 gitmodules 就不是套装 —— 2026-09-19 事故的口径不变);
140
+ * ③ 判成套装后再问一句 registry:根包没发布、但**子包**已发布(真机 dsh-web 就是这种)→ 插件通道。
141
+ * 理由:仓库里有已发布的可安装单元时,按包名装才是"装得上 + 能随 lock 更新"的那条路;
142
+ * 子包也都没发布(子模块是纯 git 组件)→ 照旧走套装装配,能力一点没少。
143
+ * 返回 true = 走套装安装。副作用:job.repoMeta / job.defaultBranch / job.rootPkgProbe / job.subpackageProbe
144
+ * 缓存本次探测结果,下游候选循环直接复用(install-job.js 里带 `??` 的那几行)。 */
145
+ async function shouldRunSuiteInstall(job, probes = {}) {
146
+ // 同一作业只判一次:缓存下来后 install-job(判定套装分支)与套装作业入口(显式「安装套装」)
147
+ // 共用同一个结论,第二次调用零联网 —— 见 runSuiteInstallJob 顶部的同一道判据。
148
+ if (job.suiteDecision !== undefined) return job.suiteDecision
149
+ const decide = (value, reason) => {
150
+ job.suiteDecision = value
151
+ job.suiteDecisionReason = reason
152
+ return value
153
+ }
154
+ if (job.kind === 'skill') return decide(false, '技能请求不走套装通道')
155
+ const gmProbe = typeof probes.probeGitmodules === 'function' ? probes.probeGitmodules : probeGitmodules
156
+ const meta = await fetchRepoMeta(job.repo).catch(() => null)
157
+ job.repoMeta = meta
158
+ const branch = meta?.default_branch ?? 'main'
159
+ job.defaultBranch = branch
160
+ let root = null
161
+ if (typeof probes.fetchRepoPackageEx === 'function') {
162
+ // 探测异常不改变结论:回落成"按 .gitmodules 判"(旧行为),不让一次探测把安装打挂
163
+ try { root = await probes.fetchRepoPackageEx(job.repo, branch) } catch { root = null }
164
+ job.rootPkgProbe = root
165
+ }
166
+ const rootPkg = root !== null && root !== undefined && root.pkg !== null && typeof root.pkg === 'object' ? root.pkg : null
167
+ const name = rootPkg !== null && typeof rootPkg.name === 'string' ? rootPkg.name : ''
168
+ const published = async (pkgName) => (typeof probes.namePublished === 'function'
169
+ ? (await probes.namePublished(pkgName)) === true
170
+ : namePublishedOnRegistry(pkgName))
171
+ if (name !== '' && (await published(name)) === true) {
172
+ job.rootPublished = name
173
+ noteChannel(job, `根包 ${name} 已发布到 npm:优先插件通道(不判套装、不克隆仓库)`)
174
+ return decide(false, `根包 ${name} 已发布到 npm`)
175
+ }
176
+ if (resolveInstallKind(job.kind, await gmProbe(job.repo)) !== 'suite') return decide(false, '仓库根目录没有有效的 .gitmodules(不是 submodule 套装仓库)')
177
+ if (name !== '' && typeof probes.subpackageCandidates === 'function') {
178
+ let subs = []
179
+ try { subs = await probes.subpackageCandidates(job.repo, branch) } catch { subs = [] }
180
+ if (Array.isArray(subs) && subs.length > 0) {
181
+ job.subpackageProbe = subs
182
+ for (const cand of subs.slice(0, 3)) {
183
+ // eslint-disable-next-line no-await-in-loop
184
+ if ((await published(cand)) === true) {
185
+ job.subpackagePreferred = cand
186
+ noteChannel(job, `根包未发布到 npm,但子包 ${cand} 已发布:优先插件通道(跳过套装克隆,避免白拉整个仓库)`)
187
+ return decide(false, `根包未发布到 npm,但子包 ${cand} 已发布到 npm`)
188
+ }
189
+ }
190
+ }
191
+ }
192
+ return decide(true, '')
193
+ }
194
+
95
195
  /** 套装安装(submodule 聚合仓库):照仓库 install.ps1/README 语义——
96
196
  * clone 套装 → 手动镜像拉取子模块 → 按类型装配(bundle 插件含 Release tgz 兜底 / 普通插件 / 技能 / agent 预设)。
97
197
  * 不执行第三方脚本本体(安全护栏:脚本型只读语义不运行)。 */
98
- async function runSuiteInstallJob(job, ports) {
198
+ async function runSuiteInstallJob(job, ports, deps = {}) {
199
+ // 根克隆的实现可注入(测试注入缝,沿用 runInstallJob 的 deps 风格:生产调用方不传第三个参数)
200
+ const gitClone = typeof deps.gitClone === 'function' ? deps.gitClone : gitCloneRepo
201
+ // 0.5.19:套装作业入口的**同一道判据** —— 前端 hasSuite=true 的卡片点「添加到本地」时发的是
202
+ // kind=suite,路由会直接调到这里(routes/install.js 的 runSuiteThenFallback),根本不经过 install-job
203
+ // 的候选循环;真机 dsh-web 的 429 MB 就是这条路拉起来的。判定结论缓存在 job.suiteDecision 上:
204
+ // install-job 已经判过(并注入过探测桩)时这里零联网直接复用;路由调用时由它把真实探测传进来。
205
+ const decided = job.suiteDecision === undefined
206
+ ? await shouldRunSuiteInstall(job, deps.probes ?? {})
207
+ : job.suiteDecision
208
+ if (decided === false) {
209
+ job.stage = 'detecting'
210
+ return { notASuite: true, reason: `${job.suiteDecisionReason ?? '该仓库有已发布到 npm 的可安装包'},已自动回落普通插件安装` }
211
+ }
99
212
  const tmpDir = join(tmpdir(), `dsh-suite-${job.id}-${Date.now()}`)
100
213
  const report = []
101
214
  try {
102
215
  job.stage = 'preparing'
103
216
  mkdirSync(tmpDir, { recursive: true })
104
- await gitCloneRepo(job.repo, tmpDir, job.source)
217
+ try {
218
+ await gitClone(job.repo, tmpDir, job.source)
219
+ } catch (cloneError) {
220
+ // 0.5.19(改错):套装仓库**克隆失败**不再把作业判 failed。
221
+ // 旧代码整个函数只有一个 try/catch → 根克隆一失败就 job.status='failed'(真机 dsh-web:429 MB 巨仓、
222
+ // ghproxy 的 git 协议 0 B/s,必然失败),而 notASuite(把决定权交回普通通道)**只在"克隆成功但
223
+ // .gitmodules 为空"时才返回** → 克隆失败等于没有任何回落,用户只看到一个失败的任务。
224
+ // 克隆失败只证明"这条路走不通",不证明"装不上":回落普通通道(npm → Release → git,按包名施工)。
225
+ job.stage = 'detecting'
226
+ return {
227
+ notASuite: true,
228
+ reason: `套装仓库克隆失败(${cloneError instanceof Error ? cloneError.message : String(cloneError)}),已自动回落普通插件安装`,
229
+ }
230
+ }
105
231
  const subs = readGitmodules(tmpDir)
106
232
  if (subs.length === 0) {
107
233
  // 探测与仓库实际内容不符(假阳性 / 仓库已重构):**不报失败**,把决定权交回调用方
@@ -217,4 +343,4 @@ async function runSuiteInstallJob(job, ports) {
217
343
  job.finishedAt = Date.now()
218
344
  }
219
345
  }
220
- export { readGitmodules, findPresetDirs, packageEntryExists, runSuiteInstallJob, probeGitmodules, resolveInstallKind }
346
+ export { readGitmodules, findPresetDirs, packageEntryExists, runSuiteInstallJob, probeGitmodules, resolveInstallKind, fetchRepoMeta, shouldRunSuiteInstall }
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { installJobView } from '../domain/install.js'
5
5
  import { runInstallJob } from '../domain/install-job.js'
6
- import { githubRepoInfo } from '../domain/market.js'
6
+ import { fetchRepoPackageEx, githubRepoInfo, subpackageCandidates } from '../domain/market.js'
7
7
  import { runSkillInstallJob } from '../domain/skills.js'
8
8
  import { probeGitmodules, resolveInstallKind, runSuiteInstallJob } from '../domain/suite.js'
9
9
  import { sendError, sendJson } from '../infra/httpd.js'
@@ -83,10 +83,15 @@ async function routeInstall(req, res, rc) {
83
83
  // 套装通道兜底:探测说有、clone 下来却没有 .gitmodules(假阳性 / 仓库已重构)时
84
84
  // 回落普通插件安装,而不是给用户一个「未找到 .gitmodules」的失败。
85
85
  const runSuiteThenFallback = async () => {
86
- const result = await runSuiteInstallJob(job, ctx)
86
+ // 0.5.19:套装作业入口自己也有一道判据(根包/子包已发布 → 不克隆整个仓库),它需要真实探测:
87
+ // 显式「安装套装」(前端 hasSuite=true 的卡片)走的就是这条路,不经过 install-job 的候选循环。
88
+ const result = await runSuiteInstallJob(job, ctx, {
89
+ probes: { fetchRepoPackageEx, subpackageCandidates, probeGitmodules },
90
+ })
87
91
  if (result?.notASuite !== true) return
88
92
  job.kind = 'plugin'
89
- job.suiteNote = '仓库实际内容与探测不符(没有 .gitmodules),已自动回落普通插件安装'
93
+ // 回落文案一律用 result.reason:克隆失败与"内容不符"是两回事,不能都报成后者(用户会被带偏)
94
+ job.suiteNote = result.reason ?? '仓库实际内容与探测不符(没有 .gitmodules),已自动回落普通插件安装'
90
95
  await runInstallJob(job, ctx)
91
96
  }
92
97
  // 后台执行:请求立即返回,安装不受客户端断开/离开面板影响
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.5.18",
3
+ "version": "0.5.19",
4
4
  "description": "DSH 框架升级安全与插件升级门控:一键升级、失败自动回滚、升级后回滚上版、旧插件不适配自动禁用;内置多源插件市场为发现层,插件源全部可自定义,可指向公司内网私有源 / 私有索引 / 本地 Git 仓库,纯内网离线可用 | Framework upgrade safety & plugin version gating for DSH, with a customizable multi-source plugin market: point every source at internal mirrors or a local file:// repo for intranet-only, offline installs.",
5
5
  "repository": {
6
6
  "type": "git",