@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 CHANGED
@@ -463,6 +463,20 @@ window.__ModuleLoader__.load({
463
463
  subpluginFound: "该仓库根目录没有清单,但插件在子目录 {dir}/ 下({pkg},标记:{markers})—— 它不是「不是插件」,只是清单不在仓库根",
464
464
  subpluginCopyDir: "复制子目录路径",
465
465
  subpluginCopyName: "复制包名",
466
+ // 子目录插件的一键安装(0.5.47 加法):识别出来之后**必须能装**,不能只给一段文字。
467
+ subpluginInstall: "从这个子目录安装",
468
+ subpluginInstallHint: "先按稀疏取源码把该子目录取到本地,再按既有本地目录来源(link:)装进 profile;清单校验与来源校验与其它安装路径同一套,装完照样收口(清单 + lock + 挂载行)",
469
+ // 「报失败前先核实已装」(0.5.47 改错):已装 ⇒ 这句话是主行,历史失败折叠成次要信息。
470
+ alreadyInstalled: "已安装 v{version}(无需重装)",
471
+ alreadyInstalledNoVersion: "已安装(无需重装;版本号读不到)",
472
+ installedBadge: "已安装",
473
+ alreadyInstalledWhere: "命中:{where}",
474
+ whereDependencies: "依赖清单 dependencies",
475
+ whereBundles: "dsh.profile.bundles",
476
+ whereNodeModules: "node_modules",
477
+ failureSecondary: "历史失败(次要信息,仅供排查;已装上的插件无需重装)",
478
+ failureSecondaryShow: "显示历史失败",
479
+ failureSecondaryHide: "收起历史失败",
466
480
  repoLandExists: "该仓库已存在,无需重复落地",
467
481
  repoLandDirLabel: "保存路径",
468
482
  repoLandSaveDir: "保存路径",
@@ -1028,6 +1042,20 @@ window.__ModuleLoader__.load({
1028
1042
  subpluginFound: "This repository has no manifest at its root, but the plugin lives in the subdirectory {dir}/ ({pkg}, markers: {markers}) — it is not \"not a plugin\", the manifest is just not at the repository root",
1029
1043
  subpluginCopyDir: "Copy subdirectory path",
1030
1044
  subpluginCopyName: "Copy package name",
1045
+ // One-click install from a subdirectory (0.5.47): recognition must come with a way to install.
1046
+ subpluginInstall: "Install from this subdirectory",
1047
+ subpluginInstallHint: "Fetches the subdirectory source (sparse) into a local folder, then installs it through the existing local-directory source (link:). Manifest and source validation are the same as for any other install path, and finalization (manifest + lock + mount row) still applies",
1048
+ // "Verify it is actually installed before reporting failure" (0.5.47): this line is primary when it is.
1049
+ alreadyInstalled: "Already installed v{version} (no reinstall needed)",
1050
+ alreadyInstalledNoVersion: "Already installed (no reinstall needed; version unreadable)",
1051
+ installedBadge: "Installed",
1052
+ alreadyInstalledWhere: "Found in: {where}",
1053
+ whereDependencies: "manifest dependencies",
1054
+ whereBundles: "dsh.profile.bundles",
1055
+ whereNodeModules: "node_modules",
1056
+ failureSecondary: "Earlier failure (secondary, for diagnosis only; an installed plugin needs no reinstall)",
1057
+ failureSecondaryShow: "Show earlier failure",
1058
+ failureSecondaryHide: "Hide earlier failure",
1031
1059
  repoLandExists: "Already exists — nothing to clone",
1032
1060
  repoLandDirLabel: "Save path",
1033
1061
  repoLandSaveDir: "Save path",
@@ -2433,6 +2461,8 @@ window.__ModuleLoader__.load({
2433
2461
  }, [state.status]);
2434
2462
  // 已关闭的失败提示(用户点 × 后不再显示,服务重启后重置)
2435
2463
  const [dismissedFailures, setDismissedFailures] = react.useState({});
2464
+ // 「已装上的那次失败」里,历史失败正文是否展开(0.5.47 加法:默认收起 —— 次要信息不许喧宾夺主)
2465
+ const [failureDetailOpen, setFailureDetailOpen] = react.useState({});
2436
2466
  // 软件源管理
2437
2467
  const [sourcesOpen, setSourcesOpen] = react.useState(false);
2438
2468
  const [sourcesData, setSourcesData] = react.useState(null);
@@ -3673,7 +3703,9 @@ window.__ModuleLoader__.load({
3673
3703
  () => { setSearchSourceName(""); setSearchSourceUrl(""); setSearchSourceHeaders(""); },
3674
3704
  );
3675
3705
  };
3676
- const startJob = (repo, packageName, source = "github", kind = "plugin", update = false) => {
3706
+ // subdir 用于「从这个子目录安装」(0.5.47 加法):服务端按它去读 <subdir>/package.json、
3707
+ // 稀疏取该子目录源码、再按既有本地目录来源(link:)装。为空时请求体形状与改动前逐字相同。
3708
+ const startJob = (repo, packageName, source = "github", kind = "plugin", update = false, subdir = null) => {
3677
3709
  // 防并发(与 addLocal 一致):已有安装/更新进行中时忽略新点击(更新按钮此前可绕过,
3678
3710
  // 反复点击会产生几十个并发任务装同一插件)
3679
3711
  if (installing !== null) {
@@ -3684,7 +3716,9 @@ window.__ModuleLoader__.load({
3684
3716
  setReloadHint(false);
3685
3717
  setRestartHint(false);
3686
3718
  setMessage(null);
3687
- call("/plugin-console/install", { repo: repo ?? "", packageName, source, kind, update }).then(
3719
+ const body = { repo: repo ?? "", packageName, source, kind, update };
3720
+ if (subdir !== null && subdir !== undefined && String(subdir) !== "") body.subdir = String(subdir);
3721
+ call("/plugin-console/install", body).then(
3688
3722
  (data) => {
3689
3723
  if (data && data.jobId) {
3690
3724
  setJobs((prev) => ({
@@ -5418,10 +5452,33 @@ window.__ModuleLoader__.load({
5418
5452
  ? el("span", { className: styles.tag, "data-user": "true" }, t("suiteComponentOk"))
5419
5453
  : el("span", { className: styles.tag, "data-error": "true" }, t("suiteComponentFail"))),
5420
5454
  r.note ? el("p", { className: styles.desc }, r.note) : null))))),
5421
- (state.status === "ready" ? state.data.recentFailures ?? [] : []).filter((job) => !dismissedFailures[job.jobId]).map((job) =>
5422
- el("div", { key: "fail-" + job.jobId, className: styles.detail },
5455
+ (state.status === "ready" ? state.data.recentFailures ?? [] : []).filter((job) => !dismissedFailures[job.jobId]).map((job) => {
5456
+ // ★ 0.5.47 改错(用户被误导过):服务端在下发**之前**已经查过三处(依赖清单 /
5457
+ // dsh.profile.bundles / node_modules)。已经装上时,「已安装 vX(无需重装)」是**主行**,
5458
+ // 历史失败降级成次要信息(默认收起)—— 绝不让用户以为没装上、再点一次重试。
5459
+ const evidence = job.installedEvidence !== null && job.installedEvidence !== undefined && job.installedEvidence.installed === true
5460
+ ? job.installedEvidence
5461
+ : null;
5462
+ const whereText = evidence === null
5463
+ ? ""
5464
+ : (Array.isArray(evidence.where) ? evidence.where : []).map((w) => w === "dependencies" ? t("whereDependencies") : w === "bundles" ? t("whereBundles") : w === "node_modules" ? t("whereNodeModules") : String(w)).join(" / ");
5465
+ const installedLine = evidence === null
5466
+ ? null
5467
+ : (evidence.version === null || evidence.version === undefined
5468
+ ? t("alreadyInstalledNoVersion")
5469
+ : t("alreadyInstalled").replace("{version}", String(evidence.version)))
5470
+ + (whereText === "" ? "" : " · " + t("alreadyInstalledWhere").replace("{where}", whereText));
5471
+ const failureOpen = failureDetailOpen[job.jobId] === true;
5472
+ // 子目录插件的一键安装(0.5.47 加法):服务端给了结构化的可执行计划才渲染按钮
5473
+ // (`install.action === "install-subdir"`)—— 只在文案里写"可以装"不算数。
5474
+ const subInstall = job.subplugin !== null && job.subplugin !== undefined
5475
+ && job.subplugin.install !== null && job.subplugin.install !== undefined
5476
+ && job.subplugin.install.action === "install-subdir"
5477
+ ? job.subplugin.install
5478
+ : null;
5479
+ return el("div", { key: "fail-" + job.jobId, className: styles.detail },
5423
5480
  el("div", { className: styles.rowTop },
5424
- el("span", { className: styles.phase, "data-phase": "failed" }, t("recentFailures")),
5481
+ el("span", { className: styles.phase, "data-phase": evidence === null ? "failed" : "active" }, evidence === null ? t("recentFailures") : t("installedBadge")),
5425
5482
  el("strong", { className: styles.name }, job.packageName ?? job.repo),
5426
5483
  el("button", {
5427
5484
  type: "button",
@@ -5430,10 +5487,12 @@ window.__ModuleLoader__.load({
5430
5487
  "aria-label": t("dismissFailure"),
5431
5488
  onClick: () => setDismissedFailures((prev) => ({ ...prev, [job.jobId]: true })),
5432
5489
  }, "✕")),
5433
- el("p", { className: styles.message, "data-error": "true" }, job.error ?? ""),
5490
+ installedLine === null
5491
+ ? el("p", { className: styles.message, "data-error": "true" }, job.error ?? "")
5492
+ : el("p", { className: styles.message, "data-warn": "true" }, installedLine),
5434
5493
  // 子目录插件(2026-10-13 加法,真机 Fei2411/DeepSeek-Bot):服务端已判定"插件在
5435
5494
  // <子目录>/ 下",这里把结论**单独显示**(不用用户从一整段报错里找路径),
5436
- // 并给出可复制的下一步(子目录路径 / 包名)。用 styles.message、不加新 class。
5495
+ // 0.5.47 起再给**可点的一步**(不只是可复制的路径)。用 styles.message、不加新 class。
5437
5496
  job.subplugin !== null && job.subplugin !== undefined
5438
5497
  ? el("p", { className: styles.message, "data-warn": "true" },
5439
5498
  t("subpluginFound")
@@ -5441,6 +5500,15 @@ window.__ModuleLoader__.load({
5441
5500
  .replace("{pkg}", String(job.subplugin.packageName ?? "?"))
5442
5501
  .replace("{markers}", Array.isArray(job.subplugin.markers) ? job.subplugin.markers.join(" / ") : "?"))
5443
5502
  : null,
5503
+ subInstall !== null
5504
+ ? el("div", { className: styles.rowTop },
5505
+ el("button", {
5506
+ type: "button",
5507
+ className: styles.toggle,
5508
+ title: t("subpluginInstallHint"),
5509
+ onClick: () => startJob(job.repo, subInstall.packageName ?? job.packageName ?? null, job.source ?? "github", "plugin", false, subInstall.dir),
5510
+ }, t("subpluginInstall") + " · " + String(subInstall.dir ?? "") + "/"))
5511
+ : null,
5444
5512
  job.subplugin !== null && job.subplugin !== undefined && typeof job.subplugin.dir === "string" && job.subplugin.dir !== ""
5445
5513
  ? el("div", { className: styles.rowTop },
5446
5514
  el("button", { type: "button", className: styles.toggle, onClick: () => copyInstallCommand(String(job.subplugin.dir)) }, t("subpluginCopyDir")),
@@ -5451,13 +5519,29 @@ window.__ModuleLoader__.load({
5451
5519
  ...diagLines(job).map(diagLineEl),
5452
5520
  el("div", { className: styles.rowTop },
5453
5521
  el("button", { type: "button", className: styles.toggle, onClick: () => startJob(job.repo, job.packageName, job.source ?? "github") }, t("retry")),
5522
+ // 历史失败(0.5.47):已装上的插件这张卡里,失败正文与"重试"降级进可折叠的次要区
5523
+ // —— 默认收起,用户不会被一句红色失败误导成"没装上"。
5524
+ evidence !== null
5525
+ ? el("button", {
5526
+ type: "button",
5527
+ className: styles.toggle,
5528
+ onClick: () => setFailureDetailOpen((prev) => ({ ...prev, [job.jobId]: failureOpen !== true })),
5529
+ }, failureOpen ? t("failureSecondaryHide") : t("failureSecondaryShow"))
5530
+ : null,
5454
5531
  // 「仓库落地」一键克隆:hint=repo-land(旧结论)与 hint=subdir-plugin(插件在子目录里)
5455
5532
  // 都要给 —— 后者的文案本身就写着"先按「仓库落地」克隆到本地查看该子目录",
5456
5533
  // 按钮若只在 repo-land 时渲染,那句话就指向一个不存在的入口。
5457
5534
  job.hint === "repo-land" || job.hint === "subdir-plugin"
5458
5535
  ? el("button", { type: "button", className: styles.toggle, title: repoLandTitleText(), onClick: () => { setRepoLandOpen(true); repoLandEntry(job.repo); } }, t("repoLandEntry"))
5459
- : null)),
5460
- ),
5536
+ : null),
5537
+ // 次要信息区(默认收起):历史失败正文 + 服务端那句核实note + 诊断行,只在用户展开时显示
5538
+ evidence !== null && failureOpen
5539
+ ? el("div", { className: styles.detail },
5540
+ el("p", { className: styles.note }, t("failureSecondary")),
5541
+ el("p", { className: styles.message, "data-error": "true" }, job.error ?? ""),
5542
+ typeof evidence.note === "string" && evidence.note !== "" ? el("p", { className: styles.note }, evidence.note) : null)
5543
+ : null)
5544
+ }),
5461
5545
  repoInfo !== null
5462
5546
  ? el("aside", { className: styles.floatPanel },
5463
5547
  el("div", { className: styles.descTopbar },
@@ -16,8 +16,10 @@ import { gatePrivateRoot } from './repo-size.js'
16
16
  import { tryPresetSourceChannel } from './preset-source.js'
17
17
  import { settlePresetOutcome } from './preset-in-package.js'
18
18
  import { alreadyInstalledTarget, verifyInstalledEntry } from './install-verify.js'
19
- import { applyNpmNameHint, expandSubpackageCandidates, fetchRepoPackage, fetchRepoPackageEx, fetchSubdirPlugins, fetchSubpackageNames, npmNameHintForRepo, packageProbeErrorText, subpackageCandidates } from './market.js'
19
+ import { applyNpmNameHint, composeProbeFailureText, expandSubpackageCandidates, fetchRepoPackage, fetchRepoPackageEx, fetchSubdirPlugins, fetchSubpackageNames, npmNameHintForRepo, packageProbeErrorText, subpackageCandidates } from './market.js'
20
20
  import { decideNoRootManifest } from './subdir-detect.js'
21
+ import { runSubdirInstallJob } from './subdir-install.js'
22
+ import { bindMarketDeps, probeRootManifest } from './manifest-probe.js' // 0.5.47 接线:抓清单的额外通道 + 通道身份
21
23
  import { finalizeInstallPersistence } from './install-finalize.js'
22
24
  import { readPatchState, syncNameFromNote } from './patch.js'
23
25
  import { listEntries } from './runtime.js'
@@ -42,11 +44,9 @@ const RELEASE_CHANNEL_BUDGET = 3
42
44
  * 否则替身与 cordis 语义不一致,这类缺陷还会再溜过去一次。 */
43
45
  function channelImpls(ports) {
44
46
  const real = { pnpmInstall, curlManualInstall, raceInstallChannels, githubReleaseInstall, backfillMissingDeps }
45
- // 生产路径上 ports 就是 cordis 的 ctx 代理,属性式读取未 inject 的名字会**同步抛**
46
- // `cannot get property "installChannels" without inject` —— 0.3.59 因此每一次安装都在这里失败。
47
- // 正规写法是 ctx.get('installChannels')(Cordis 的可选读取,未声明也不抛,与本文件其它
48
- // ports.get('subagents') / ports.get('agents') 一致);普通对象(测试替身/窄接口)才回退属性访问。
49
- // try/catch 保留 0.3.61 的兜底语义:万一还有别的 ctx 形状,宁可回落真实实现也不要炸掉安装。
47
+ // 生产路径上 ports 就是 cordis 的 ctx 代理,属性式读取未 inject 的名字会**同步抛**(0.3.59 每次安装
48
+ // 都在这里失败)⇒ 正规写法是 ctx.get('installChannels')(可选读取,未声明也不抛);普通对象(测试
49
+ // 替身/窄接口)才回退属性访问。try/catch 保留 0.3.61 的兜底语义:宁可回落真实实现也别炸掉安装。
50
50
  let override = null
51
51
  try {
52
52
  override = typeof ports?.get === 'function' ? (ports.get('installChannels') ?? null) : (ports?.installChannels ?? null)
@@ -75,9 +75,8 @@ async function tryCandidateChannels({ job, ch, name, profileDir, registries, rep
75
75
  const patchPath = join(profileDir, 'cordis.patch.yml')
76
76
  let installedName = null
77
77
  let lastError = null
78
- // 0.5.41 接线:这次成功装上的**来源**(`{ spec, origin, kind }`)—— 取样通道(curl tarball /
79
- // GitHub release 资产)知道真实下载地址,收口那一步据此走 `pnpm add <spec>` 真装(依赖交给 pnpm)。
80
- // registry 通道不填(既有流程一字不改);git 通道也不填(它本来就是 pnpm 装的,清单里已是 pnpm 写的规格)。
78
+ // 0.5.41 接线:这次成功装上的**来源**(`{ spec, origin, kind }`)—— 取样通道(curl tarball / GitHub
79
+ // release 资产)知道真实下载地址,收口那一步据此走 `pnpm add <spec>` 真装;registry/git 通道都不填。
81
80
  let source = null
82
81
  // 加法优化:包已在 node_modules 且名字匹配时,不再重复安装/触发 EPERM,直接进入启用流程
83
82
  // (2026-09-27 搬进 install-verify.js#alreadyInstalledTarget:本文件贴着 600 行架构硬顶)
@@ -225,6 +224,9 @@ async function runInstallJob(job, ports, deps = {}) {
225
224
  fetchSubdirPlugins,
226
225
  subpackageCandidates,
227
226
  npmNameHint: npmNameHintForRepo,
227
+ // 技能仓库探测 + 默认分支探测也进袋子(2026-10-13,**测试安全**):这两处原本是直接调用,离线测试
228
+ // 拦不住(本机实测会真的连 api.github.com/ghproxy/ghfast)。生产默认值与改动前逐字相同。
229
+ detectSkillRepo, fetchRepoMeta,
228
230
  ...(deps.marketProbes ?? {}),
229
231
  }
230
232
  // 展开实现必须跟随**注入的** fetchSubpackageNames(否则注入的探测桩会被绕过、悄悄去打真网络 ——
@@ -232,10 +234,22 @@ async function runInstallJob(job, ports, deps = {}) {
232
234
  if (deps.marketProbes?.expandSubpackages === undefined) {
233
235
  probes.expandSubpackages = (opts = {}) => expandSubpackageCandidates({ ...opts, deps: { fetchSubpackageNames: probes.fetchSubpackageNames } })
234
236
  }
237
+ // ★ 0.5.47:额外通道的 deps **绑在袋子上**(套装判定先探一次 2 参调用并缓存,只在调用点传 deps 会被绕过)
238
+ bindMarketDeps(probes, job, deps)
235
239
  const jobBudgetMs = Number.isFinite(deps.jobBudgetMs) && deps.jobBudgetMs > 0 ? deps.jobBudgetMs : 8 * 60 * 1000
236
240
  // 源码通道(体积门禁 + 稀疏取源码 + 预设装配)的注入缝:与 marketProbes 同一风格,生产调用方不传。
237
241
  const sourceDeps = deps.sourceDeps ?? {}
238
242
  const aiConsentTimeoutMs = Number.isFinite(deps.aiConsentTimeoutMs) && deps.aiConsentTimeoutMs > 0 ? deps.aiConsentTimeoutMs : AI_CONSENT_TIMEOUT_MS
243
+ // ★ 子目录插件(2026-10-13 加法,第二轮):用户点了「从这个子目录安装」⇒ 走专门的子目录通道
244
+ // (先落地该子目录,再按既有**本地目录来源** link: 装;判据与理由见 domain/subdir-install.js)。
245
+ // 位置刻意放在**任何仓库根探测之前**:这条路本来就不该按根包去探(真机 Fei2411/DeepSeek-Bot
246
+ // 根目录根本没有清单),否则会白跑一轮探测、还可能把结论带偏。
247
+ // `channels` 沿用本文件唯一那个通道注入缝(channelImpls),不在这里另立一个说法。
248
+ if (typeof job.subdir === 'string' && job.subdir.trim() !== '') {
249
+ await runSubdirInstallJob(job, ports, { ...deps, channels: channelImpls(ports) })
250
+ job.finishedAt = Date.now()
251
+ return
252
+ }
239
253
  try {
240
254
  job.stage = 'preparing'
241
255
  // 2026-09-06 事故(室友机器):对 deepseek-ai/deepseek-harness(框架本体仓库)点安装会按 bundle 规则
@@ -284,7 +298,7 @@ async function runInstallJob(job, ports, deps = {}) {
284
298
  const meta = job.repoMeta !== undefined ? job.repoMeta : await fetchRepoMeta(job.repo)
285
299
  const branch = meta?.default_branch ?? 'main'
286
300
  job.defaultBranch = branch // 批次 B-⑥:后续读子包一律以它打头(默认分支是 dev 的仓库不再读不到子包)
287
- const rootProbe = job.rootPkgProbe !== undefined ? job.rootPkgProbe : await probes.fetchRepoPackageEx(job.repo, branch)
301
+ const rootProbe = await probeRootManifest({ job, probes, noteChannel, branch }) // 0.5.47 接线:额外通道 + 通道身份
288
302
  const { pkg, reason } = rootProbe
289
303
  if (pkg === null) {
290
304
  // 无 package.json:先探测是否技能仓库(含 SKILL.md)→ 自动转技能安装;
@@ -294,7 +308,7 @@ async function runInstallJob(job, ports, deps = {}) {
294
308
  // 现在先向下扫 1–2 层子目录(与 fetchSubpackageNames 的路径口径一致):
295
309
  // · 扫到带 dsh 标记的清单 → 明说"插件在 <子目录>/ 下"并给出可复制的安装写法;
296
310
  // · 扫完确实没有 → 保持旧结论,但文案补上"已检查根目录与 N 层子目录"(诚实)。
297
- const skillProbe = await detectSkillRepo(job.repo, branch)
311
+ const skillProbe = await (probes.detectSkillRepo ?? detectSkillRepo)(job.repo, branch)
298
312
  if (skillProbe.hasSkill) {
299
313
  job.kind = 'skill'
300
314
  await runSkillInstallJob(job, ports)
@@ -305,7 +319,7 @@ async function runInstallJob(job, ports, deps = {}) {
305
319
  branch,
306
320
  reason,
307
321
  probe: probes.fetchSubdirPlugins,
308
- baseText: packageProbeErrorText(job.repo, branch, reason),
322
+ baseText: composeProbeFailureText(rootProbe, packageProbeErrorText(job.repo, branch, reason)),
309
323
  source: job.source,
310
324
  })
311
325
  job.status = 'failed'
@@ -235,4 +235,62 @@ function alreadyInstalledTarget(profileDir, name, job = null) {
235
235
  return null
236
236
  }
237
237
 
238
- export { verifyInstalledEntry, verifyTarballDigest, entryFieldOf, alreadyInstalledTarget, MIRROR_HINT }
238
+ /** 「其实已经装上了」的**三处核实**(2026-10-13 加法,用户被误导过):
239
+ * · ① profile `package.json` 的 `dependencies` 里有这个包(任何 spec 形态都算);
240
+ * · ② `dsh.profile.bundles` 里有它(bundle 层已注册);
241
+ * · ③ `node_modules/<包名>/package.json` 在盘上(真装着,顺便读出**版本号**)。
242
+ *
243
+ * 为什么要有它:面板的「最近安装失败」只看作业状态,于是会出现**明明已经装上了还报失败**的
244
+ * 误导(重试一次还可能把好端端的安装弄坏)。判据刻意是**三处任一命中即算已安装**——
245
+ * 缺哪一处是"持久化是否完整"的问题(由 persist.js 的 persisted 如实报),不是"装没装上"的问题。
246
+ * 只读、永不抛、绝不改动任何东西。返回 `{ installed, packageName, version, where, spec, note }`。 */
247
+ function installedEvidence(profileDir, packageName) {
248
+ const name = typeof packageName === 'string' ? packageName.trim() : ''
249
+ if (name === '') return { installed: false, packageName: null, version: null, where: [], spec: null, note: null }
250
+ const dir = String(profileDir ?? '')
251
+ const where = []
252
+ let spec = null
253
+ try {
254
+ const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
255
+ const dep = manifest?.dependencies?.[name]
256
+ if (typeof dep === 'string' && dep !== '') { spec = dep; where.push('dependencies') }
257
+ } catch {}
258
+ try {
259
+ if (readBundles(dir).includes(name)) where.push('bundles')
260
+ } catch {}
261
+ let version = null
262
+ try {
263
+ const pkg = JSON.parse(readFileSync(join(dir, 'node_modules', ...name.split('/'), 'package.json'), 'utf8'))
264
+ if (pkg !== null && typeof pkg === 'object' && pkg.name === name) {
265
+ where.push('node_modules')
266
+ version = typeof pkg.version === 'string' && pkg.version !== '' ? pkg.version : null
267
+ }
268
+ } catch {}
269
+ const installed = where.length > 0
270
+ if (installed !== true) return { installed: false, packageName: name, version: null, where: [], spec, note: null }
271
+ const place = where.join('、')
272
+ const versionText = version === null ? '(node_modules 里读不到版本号)' : ` v${version}`
273
+ return {
274
+ installed: true,
275
+ packageName: name,
276
+ version,
277
+ where,
278
+ spec,
279
+ note: `${name} 其实已经装上了${versionText} —— 命中:${place}。`
280
+ + `最近一次安装报的失败**不代表没装上**(可能是重复安装/更新那一步失败),无需重装。`,
281
+ }
282
+ }
283
+
284
+ /** 失败作业 → 面板要显示的那段核实结果(名字从作业上按既有字段依次取,取不到就返回 null)。
285
+ * 顺序刻意是"最具体的先":子目录插件点名的包名 → 本次作业的候选包名 → 子目录识别结果里的包名。
286
+ * 名字取不到时返回 null(**不下"没装上"的结论** —— 那正是我们要避免的误导)。 */
287
+ function failedJobEvidence(profileDir, job) {
288
+ if (job === null || job === undefined) return null
289
+ const candidates = [job.packageName, job.candidateName, job.subplugin?.packageName, job.subplugin?.install?.packageName]
290
+ const name = candidates.find((n) => typeof n === 'string' && n.trim() !== '')
291
+ if (name === undefined) return null
292
+ return installedEvidence(profileDir, name)
293
+ }
294
+
295
+ export { verifyInstalledEntry, verifyTarballDigest, entryFieldOf, alreadyInstalledTarget, installedEvidence, failedJobEvidence, MIRROR_HINT }
296
+
@@ -536,9 +536,8 @@ function installJobView(job) {
536
536
  suiteNote: job.suiteNote ?? null, repoSize: job.repoSize ?? null,
537
537
  ...presetJobView(job),
538
538
  hint: job.hint ?? null,
539
- // 子目录插件识别结果(2026-10-13 加法,真机 Fei2411/DeepSeek-Bot:插件在 ds-bot/ 而不是仓库根)。
540
- // 下发结构化字段而不只是塞进 error 文案:面板要能**单独把它显示出来**(插件在哪个子目录、
541
- // 包名、哪些 dsh 标记),用户才不用从一整段报错里找路径。老客户端忽略该字段(纯加法)。
539
+ // 子目录插件识别结果(2026-10-13 加法,真机 Fei2411/DeepSeek-Bot:插件在 ds-bot/ 而不是仓库根):
540
+ // 下发结构化字段而不只塞进 error 文案 —— 面板要能**单独显示**(哪个子目录、包名、哪些 dsh 标记)。
542
541
  subplugin: job.subplugin ?? null,
543
542
  // 子包级/套装级进度(2026-09-20 真装实测缺口:11 个子包的聚合仓库跑了 19 分钟,面板只显示
544
543
  // "安装中",用户不知道在装第几个、还剩几个)。子包通道来自 install-job.js 候选循环维护的
@@ -558,6 +557,11 @@ function installJobView(job) {
558
557
  // 持久化判据(0.5.38 加法,见 domain/persist.js):`persisted:false` ⇒ 面板必须报「未持久化」
559
558
  // (重启/任何一次 pnpm 操作后可能消失)并给出「一键钉住」动作 —— 绝不报"安装成功"。
560
559
  persisted: job.persisted ?? null, persistNote: job.persistNote ?? null, persist: job.persist ?? null,
560
+ // 「其实已经装上了」的核实结果(0.5.47 加法,见 install-verify.js#installedEvidence):失败作业由路由在下发前**现查**三处覆盖。
561
+ installedEvidence: job.installedEvidence ?? null,
562
+ // 抓清单那一轮的**通道身份** + 本地落地落点(0.5.47 加法,见 domain/manifest-probe.js):
563
+ // 面板据此显示"清单经 npm 镜像 / 本地落地 / 直连读到的",本地命中时给出「从本地安装」的落点。
564
+ probe: job.probe ?? null, localDir: job.localDir ?? null, localHint: job.localHint ?? null,
561
565
  }
562
566
  }
563
567
 
@@ -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 }