@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 CHANGED
@@ -458,6 +458,25 @@ window.__ModuleLoader__.load({
458
458
  repoLandBusy: "克隆中…(大仓库可能较慢)",
459
459
  repoLandDone: "已落地到:",
460
460
  repoLandOpenFolder: "打开文件夹",
461
+ repoOpenDone: "已请求打开:",
462
+ repoOpenFailed: "打开文件夹失败:",
463
+ subpluginFound: "该仓库根目录没有清单,但插件在子目录 {dir}/ 下({pkg},标记:{markers})—— 它不是「不是插件」,只是清单不在仓库根",
464
+ subpluginCopyDir: "复制子目录路径",
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: "收起历史失败",
461
480
  repoLandExists: "该仓库已存在,无需重复落地",
462
481
  repoLandDirLabel: "保存路径",
463
482
  repoLandSaveDir: "保存路径",
@@ -1018,6 +1037,25 @@ window.__ModuleLoader__.load({
1018
1037
  repoLandBusy: "Cloning… (large repos may take a while)",
1019
1038
  repoLandDone: "Landed at: ",
1020
1039
  repoLandOpenFolder: "Open folder",
1040
+ repoOpenDone: "Requested to open: ",
1041
+ repoOpenFailed: "Failed to open folder: ",
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",
1043
+ subpluginCopyDir: "Copy subdirectory path",
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",
1021
1059
  repoLandExists: "Already exists — nothing to clone",
1022
1060
  repoLandDirLabel: "Save path",
1023
1061
  repoLandSaveDir: "Save path",
@@ -2229,7 +2267,10 @@ window.__ModuleLoader__.load({
2229
2267
  if (!window.confirm(t("repoLandRemoveConfirm").replace("{repo}", item.repo))) return;
2230
2268
  try {
2231
2269
  await call("/plugin-console/repo-remove", { path: item.path });
2232
- } catch {}
2270
+ } catch (error) {
2271
+ // 2026-10-13 改错:删不掉时旧代码 `catch {}` 静默吞掉,用户看到的是"点了没反应、仓库还在"。
2272
+ setMessage(t("safetySwitchFailed") + ":" + (error && typeof error.message === "string" && error.message !== "" ? error.message : t("safetySwitchFailed")));
2273
+ }
2233
2274
  repoLandRefresh();
2234
2275
  }
2235
2276
  async function repoLandStart() {
@@ -2245,9 +2286,22 @@ window.__ModuleLoader__.load({
2245
2286
  }
2246
2287
  setRepoLandBusy(false);
2247
2288
  }
2248
- async function repoLandOpenFolder() {
2249
- if (repoLandResult === null || repoLandResult.ok !== true) return;
2250
- try { await call("/plugin-console/repo-open", { path: repoLandResult.path }); } catch {}
2289
+ /** 打开仓库落地目录(2026-10-13 改错:**成功与失败都必须有结果**)。
2290
+ * 旧实现 `try { await call(...) } catch {}` —— 非 2xx(路径被守卫拒绝 / 目录不存在 /
2291
+ * 文件管理器起不来)全被吞掉,用户只看到"点了没反应"(真机实测报告)。
2292
+ * 现在:服务端回结构化状态 → 成功显示"已在文件管理器中打开:<path>"(服务端的 note 也带上),
2293
+ * 失败把**原因原样**显示(守卫拒绝的原因、目录不存在、spawn 失败的系统原话)。
2294
+ * 供「刚落地」按钮与「已落地列表」每行的按钮共用(两处行为必须一致,别再各写一遍)。 */
2295
+ async function repoLandOpenFolder(target) {
2296
+ const path = typeof target === "string" && target !== "" ? target : (repoLandResult && repoLandResult.path);
2297
+ if (!path) return;
2298
+ try {
2299
+ const data = await call("/plugin-console/repo-open", { path });
2300
+ const note = data && typeof data.note === "string" && data.note !== "" ? "(" + data.note + ")" : "";
2301
+ setMessage(t("repoOpenDone") + (data && data.path ? data.path : path) + note);
2302
+ } catch (error) {
2303
+ setMessage(t("repoOpenFailed") + (error && typeof error.message === "string" && error.message !== "" ? error.message : t("safetySwitchFailed")));
2304
+ }
2251
2305
  }
2252
2306
  const sectionRef = react.useRef(null);
2253
2307
  const [compCardTop, setCompCardTop] = react.useState(null);
@@ -2407,6 +2461,8 @@ window.__ModuleLoader__.load({
2407
2461
  }, [state.status]);
2408
2462
  // 已关闭的失败提示(用户点 × 后不再显示,服务重启后重置)
2409
2463
  const [dismissedFailures, setDismissedFailures] = react.useState({});
2464
+ // 「已装上的那次失败」里,历史失败正文是否展开(0.5.47 加法:默认收起 —— 次要信息不许喧宾夺主)
2465
+ const [failureDetailOpen, setFailureDetailOpen] = react.useState({});
2410
2466
  // 软件源管理
2411
2467
  const [sourcesOpen, setSourcesOpen] = react.useState(false);
2412
2468
  const [sourcesData, setSourcesData] = react.useState(null);
@@ -3647,7 +3703,9 @@ window.__ModuleLoader__.load({
3647
3703
  () => { setSearchSourceName(""); setSearchSourceUrl(""); setSearchSourceHeaders(""); },
3648
3704
  );
3649
3705
  };
3650
- 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) => {
3651
3709
  // 防并发(与 addLocal 一致):已有安装/更新进行中时忽略新点击(更新按钮此前可绕过,
3652
3710
  // 反复点击会产生几十个并发任务装同一插件)
3653
3711
  if (installing !== null) {
@@ -3658,7 +3716,9 @@ window.__ModuleLoader__.load({
3658
3716
  setReloadHint(false);
3659
3717
  setRestartHint(false);
3660
3718
  setMessage(null);
3661
- 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(
3662
3722
  (data) => {
3663
3723
  if (data && data.jobId) {
3664
3724
  setJobs((prev) => ({
@@ -4804,7 +4864,7 @@ window.__ModuleLoader__.load({
4804
4864
  ? (repoLandResult.ok === true
4805
4865
  ? el("div", { className: styles.rowTop },
4806
4866
  el("p", { className: styles.message, style: { flex: 1, minWidth: 0, wordBreak: "break-all" } }, t("repoLandDone") + repoLandResult.path),
4807
- el("button", { type: "button", className: styles.toggle, onClick: repoLandOpenFolder }, t("repoLandOpenFolder")))
4867
+ el("button", { type: "button", className: styles.toggle, onClick: () => repoLandOpenFolder(repoLandResult.path) }, t("repoLandOpenFolder")))
4808
4868
  : el("p", { className: styles.message, "data-error": "true" }, t("safetySwitchFailed") + ":" + (repoLandResult.error ?? "?")))
4809
4869
  : null,
4810
4870
  el("strong", { className: styles.name }, t("repoLandList") + "(" + repoLanded.length + ")"),
@@ -4814,7 +4874,7 @@ window.__ModuleLoader__.load({
4814
4874
  repoLanded.map((item) =>
4815
4875
  el("div", { key: item.repo, className: styles.rowTop },
4816
4876
  el("strong", { className: styles.name, style: { flex: "1 1 auto" } }, item.repo),
4817
- el("button", { type: "button", className: styles.toggle, onClick: () => { call("/plugin-console/repo-open", { path: item.path }).catch(() => {}); } }, t("repoLandOpenFolder")),
4877
+ el("button", { type: "button", className: styles.toggle, onClick: () => repoLandOpenFolder(item.path) }, t("repoLandOpenFolder")),
4818
4878
  el("button", { type: "button", className: styles.trashBtn, title: t("repoLandRemoveConfirm"), "aria-label": t("repoLandRemoveConfirm"), onClick: () => repoLandRemove(item) }, "✕"))))
4819
4879
  )))
4820
4880
  : null,
@@ -5392,10 +5452,33 @@ window.__ModuleLoader__.load({
5392
5452
  ? el("span", { className: styles.tag, "data-user": "true" }, t("suiteComponentOk"))
5393
5453
  : el("span", { className: styles.tag, "data-error": "true" }, t("suiteComponentFail"))),
5394
5454
  r.note ? el("p", { className: styles.desc }, r.note) : null))))),
5395
- (state.status === "ready" ? state.data.recentFailures ?? [] : []).filter((job) => !dismissedFailures[job.jobId]).map((job) =>
5396
- 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 },
5397
5480
  el("div", { className: styles.rowTop },
5398
- 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")),
5399
5482
  el("strong", { className: styles.name }, job.packageName ?? job.repo),
5400
5483
  el("button", {
5401
5484
  type: "button",
@@ -5404,14 +5487,61 @@ window.__ModuleLoader__.load({
5404
5487
  "aria-label": t("dismissFailure"),
5405
5488
  onClick: () => setDismissedFailures((prev) => ({ ...prev, [job.jobId]: true })),
5406
5489
  }, "✕")),
5407
- 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),
5493
+ // 子目录插件(2026-10-13 加法,真机 Fei2411/DeepSeek-Bot):服务端已判定"插件在
5494
+ // <子目录>/ 下",这里把结论**单独显示**(不用用户从一整段报错里找路径),
5495
+ // 0.5.47 起再给**可点的一步**(不只是可复制的路径)。用 styles.message、不加新 class。
5496
+ job.subplugin !== null && job.subplugin !== undefined
5497
+ ? el("p", { className: styles.message, "data-warn": "true" },
5498
+ t("subpluginFound")
5499
+ .replace("{dir}", String(job.subplugin.dir ?? "?"))
5500
+ .replace("{pkg}", String(job.subplugin.packageName ?? "?"))
5501
+ .replace("{markers}", Array.isArray(job.subplugin.markers) ? job.subplugin.markers.join(" / ") : "?"))
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,
5512
+ job.subplugin !== null && job.subplugin !== undefined && typeof job.subplugin.dir === "string" && job.subplugin.dir !== ""
5513
+ ? el("div", { className: styles.rowTop },
5514
+ el("button", { type: "button", className: styles.toggle, onClick: () => copyInstallCommand(String(job.subplugin.dir)) }, t("subpluginCopyDir")),
5515
+ typeof job.subplugin.packageName === "string" && job.subplugin.packageName !== ""
5516
+ ? el("button", { type: "button", className: styles.toggle, onClick: () => copyInstallCommand(String(job.subplugin.packageName)) }, t("subpluginCopyName"))
5517
+ : null)
5518
+ : null,
5408
5519
  ...diagLines(job).map(diagLineEl),
5409
5520
  el("div", { className: styles.rowTop },
5410
5521
  el("button", { type: "button", className: styles.toggle, onClick: () => startJob(job.repo, job.packageName, job.source ?? "github") }, t("retry")),
5411
- job.hint === "repo-land"
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,
5531
+ // 「仓库落地」一键克隆:hint=repo-land(旧结论)与 hint=subdir-plugin(插件在子目录里)
5532
+ // 都要给 —— 后者的文案本身就写着"先按「仓库落地」克隆到本地查看该子目录",
5533
+ // 按钮若只在 repo-land 时渲染,那句话就指向一个不存在的入口。
5534
+ job.hint === "repo-land" || job.hint === "subdir-plugin"
5412
5535
  ? el("button", { type: "button", className: styles.toggle, title: repoLandTitleText(), onClick: () => { setRepoLandOpen(true); repoLandEntry(job.repo); } }, t("repoLandEntry"))
5413
- : null)),
5414
- ),
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
+ }),
5415
5545
  repoInfo !== null
5416
5546
  ? el("aside", { className: styles.floatPanel },
5417
5547
  el("div", { className: styles.descTopbar },
@@ -9,7 +9,10 @@
9
9
  // 注册前先按本模块读出的引用逐个 `resolvePackageJson` 核实,缺失即拒绝注册。
10
10
 
11
11
  import { existsSync, readFileSync } from 'node:fs'
12
- import { join } from 'node:path'
12
+ import { readFile } from 'node:fs/promises'
13
+ import { dirname, join } from 'node:path'
14
+ import { DEFAULT_BUNDLES } from './install.js'
15
+ import { resolvePackageJson } from '../infra/paths.js'
13
16
 
14
17
  /** 读取聚合包 `cordis.patch.yml` 的 insert 行 name 列表(注册前校验用)。
15
18
  * 解析形状与 install.js#ensureBundlePatchIntegrity 同构:`- insert:` 块下的
@@ -45,4 +48,41 @@ function readBundlePatchRefNames(profileDir, pkgName) {
45
48
  } catch { return [] }
46
49
  }
47
50
 
48
- export { readBundlePatchRefNames }
51
+ /** 读取用户额外 bundle(非官方模板)的补丁插入行 id 与包名,用于"额外插件"判定。
52
+ * 2026-10-13 从 install-job.js 原样搬出(只搬移未改逻辑;那边贴着 600 行硬顶,
53
+ * 而这块与上面的 insert 行解析是同一件事),调用方经 install-job.js 的 re-export 取用,
54
+ * 对外导出名与行为一字未变(tests/test-architecture-guard 的接线断言要求导出名仍在)。 */
55
+ async function readExtraBundleRows(profileDir) {
56
+ const rows = new Set()
57
+ try {
58
+ const manifest = JSON.parse(await readFile(join(profileDir, 'package.json'), 'utf8'))
59
+ const bundles = manifest.dsh?.profile?.bundles ?? []
60
+ for (const pkg of bundles) {
61
+ if (DEFAULT_BUNDLES.includes(pkg)) continue
62
+ try {
63
+ const pk = resolvePackageJson(pkg, profileDir)
64
+ const dir = dirname(pk ?? join(profileDir, 'node_modules', ...String(pkg).split('/'), 'package.json'))
65
+ const text = await readFile(join(dir, 'cordis.patch.yml'), 'utf8')
66
+ const lines = text.split(/\r?\n/u)
67
+ let inInsert = false
68
+ for (let index = 0; index < lines.length; index += 1) {
69
+ const line = lines[index]
70
+ if (/^- insert:\s*$/u.test(line)) {
71
+ inInsert = true
72
+ continue
73
+ }
74
+ if (/^- /u.test(line)) inInsert = false
75
+ if (!inInsert) continue
76
+ const idMatch = line.match(/^ {4}- id: ([A-Za-z0-9_.-]+)\s*$/u)
77
+ if (!idMatch) continue
78
+ rows.add(idMatch[1])
79
+ const nameMatch = (lines[index + 1] ?? '').match(/^ {6}name: ['"]([^'"]+)['"]\s*$/u)
80
+ if (nameMatch) rows.add(nameMatch[1])
81
+ }
82
+ } catch {}
83
+ }
84
+ } catch {}
85
+ return rows
86
+ }
87
+
88
+ export { readBundlePatchRefNames, readExtraBundleRows }
@@ -2,22 +2,24 @@
2
2
  // 分层 Step 8c-2 从 lib/index.js 搬出;形参由 ctx 收窄为 ports(调用方注入的窄接口),只搬移未改逻辑。
3
3
  // 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言)
4
4
 
5
- import { readFile } from 'node:fs/promises'
6
5
  import { dirname, join, resolve } from 'node:path'
7
6
  import { aiRepair } from './ai-run.js'
8
7
  import { maybeAutoAdaptCompat } from './compat.js'
9
8
  import { getReposDir } from './repoland.js'
10
9
  import { aiConsentFailureText, cleanupAttemptedCandidates, pnpmRemove } from './install-cleanup.js'
11
10
  import { GIT_CHANNEL_BUDGET_MS, GIT_MIN_ATTEMPT_MS, GIT_SPEC_TIMEOUT_MS, gitChannelBudgetMs, noteChannel, formatBudgetMs, tryGitChannel } from './git-channel.js'
12
- import { DEFAULT_BUNDLES, AI_CONSENT_TIMEOUT_MS, PNPM_INSTALL_TIMEOUT_MS, addBundleToManifest, backfillMissingDeps, curlManualInstall, detectBundleOnly, ensureBundlePatchIntegrity, githubReleaseInstall, pnpmInstall, raceInstallChannels, readGithubAuth, syncAggregateSubpackageVersions } from './install.js'
13
- import { readBundlePatchRefNames } from './bundle-refs.js' // 0.5.26 搬家:install.js 贴着 600 行硬顶
11
+ import { AI_CONSENT_TIMEOUT_MS, PNPM_INSTALL_TIMEOUT_MS, addBundleToManifest, backfillMissingDeps, curlManualInstall, detectBundleOnly, ensureBundlePatchIntegrity, githubReleaseInstall, pnpmInstall, raceInstallChannels, readGithubAuth, syncAggregateSubpackageVersions } from './install.js'
12
+ import { readBundlePatchRefNames, readExtraBundleRows } from './bundle-refs.js' // re-export(0.5.46 搬入;0.5.26 已搬 readBundlePatchRefNames)
14
13
  import { classifyInstallFailure, retryPnpmOnce } from './install-diagnose.js'
15
14
  import { jobLog } from './jobs.js'
16
15
  import { gatePrivateRoot } from './repo-size.js'
17
16
  import { tryPresetSourceChannel } from './preset-source.js'
18
17
  import { settlePresetOutcome } from './preset-in-package.js'
19
18
  import { alreadyInstalledTarget, verifyInstalledEntry } from './install-verify.js'
20
- import { applyNpmNameHint, expandSubpackageCandidates, fetchRepoPackage, fetchRepoPackageEx, fetchSubpackageNames, npmNameHintForRepo, packageProbeErrorText, subpackageCandidates } from './market.js'
19
+ import { applyNpmNameHint, composeProbeFailureText, expandSubpackageCandidates, fetchRepoPackage, fetchRepoPackageEx, fetchSubdirPlugins, fetchSubpackageNames, npmNameHintForRepo, packageProbeErrorText, subpackageCandidates } from './market.js'
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 行架构硬顶)
@@ -222,8 +221,12 @@ async function runInstallJob(job, ports, deps = {}) {
222
221
  fetchRepoPackage,
223
222
  fetchRepoPackageEx,
224
223
  fetchSubpackageNames,
224
+ fetchSubdirPlugins,
225
225
  subpackageCandidates,
226
226
  npmNameHint: npmNameHintForRepo,
227
+ // 技能仓库探测 + 默认分支探测也进袋子(2026-10-13,**测试安全**):这两处原本是直接调用,离线测试
228
+ // 拦不住(本机实测会真的连 api.github.com/ghproxy/ghfast)。生产默认值与改动前逐字相同。
229
+ detectSkillRepo, fetchRepoMeta,
227
230
  ...(deps.marketProbes ?? {}),
228
231
  }
229
232
  // 展开实现必须跟随**注入的** fetchSubpackageNames(否则注入的探测桩会被绕过、悄悄去打真网络 ——
@@ -231,10 +234,22 @@ async function runInstallJob(job, ports, deps = {}) {
231
234
  if (deps.marketProbes?.expandSubpackages === undefined) {
232
235
  probes.expandSubpackages = (opts = {}) => expandSubpackageCandidates({ ...opts, deps: { fetchSubpackageNames: probes.fetchSubpackageNames } })
233
236
  }
237
+ // ★ 0.5.47:额外通道的 deps **绑在袋子上**(套装判定先探一次 2 参调用并缓存,只在调用点传 deps 会被绕过)
238
+ bindMarketDeps(probes, job, deps)
234
239
  const jobBudgetMs = Number.isFinite(deps.jobBudgetMs) && deps.jobBudgetMs > 0 ? deps.jobBudgetMs : 8 * 60 * 1000
235
240
  // 源码通道(体积门禁 + 稀疏取源码 + 预设装配)的注入缝:与 marketProbes 同一风格,生产调用方不传。
236
241
  const sourceDeps = deps.sourceDeps ?? {}
237
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
+ }
238
253
  try {
239
254
  job.stage = 'preparing'
240
255
  // 2026-09-06 事故(室友机器):对 deepseek-ai/deepseek-harness(框架本体仓库)点安装会按 bundle 规则
@@ -283,21 +298,35 @@ async function runInstallJob(job, ports, deps = {}) {
283
298
  const meta = job.repoMeta !== undefined ? job.repoMeta : await fetchRepoMeta(job.repo)
284
299
  const branch = meta?.default_branch ?? 'main'
285
300
  job.defaultBranch = branch // 批次 B-⑥:后续读子包一律以它打头(默认分支是 dev 的仓库不再读不到子包)
286
- 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 接线:额外通道 + 通道身份
287
302
  const { pkg, reason } = rootProbe
288
303
  if (pkg === null) {
289
304
  // 无 package.json:先探测是否技能仓库(含 SKILL.md)→ 自动转技能安装;
290
- // 否则标记 hint=repo-land,前端给出「仓库落地」一键入口(克隆到本地目录)。
291
- const skillProbe = await detectSkillRepo(job.repo, branch)
305
+ // 2026-10-13 改错(用户实测 Fei2411/DeepSeek-Bot):**根目录没有清单 ≠ 这个仓库不是插件**。
306
+ // 真机该仓库根目录只有 docs/ + ds-bot/,而 ds-bot/package.json 同时带 dsh.client 与
307
+ // dsh.bundle.patch —— 旧代码在这里直接下"不是插件"的结论,结论错、也没给用户下一步。
308
+ // 现在先向下扫 1–2 层子目录(与 fetchSubpackageNames 的路径口径一致):
309
+ // · 扫到带 dsh 标记的清单 → 明说"插件在 <子目录>/ 下"并给出可复制的安装写法;
310
+ // · 扫完确实没有 → 保持旧结论,但文案补上"已检查根目录与 N 层子目录"(诚实)。
311
+ const skillProbe = await (probes.detectSkillRepo ?? detectSkillRepo)(job.repo, branch)
292
312
  if (skillProbe.hasSkill) {
293
313
  job.kind = 'skill'
294
314
  await runSkillInstallJob(job, ports)
295
315
  return
296
316
  }
317
+ const decided = await decideNoRootManifest({
318
+ repo: job.repo,
319
+ branch,
320
+ reason,
321
+ probe: probes.fetchSubdirPlugins,
322
+ baseText: composeProbeFailureText(rootProbe, packageProbeErrorText(job.repo, branch, reason)),
323
+ source: job.source,
324
+ })
297
325
  job.status = 'failed'
298
- job.hint = 'repo-land'
299
- // 文案区分「抓取超时/不可达」与「真的没有」——旧代码两者共用一个出口,报错永远说"文件不存在"
300
- job.error = packageProbeErrorText(job.repo, branch, reason)
326
+ job.hint = decided.hint
327
+ job.error = decided.error
328
+ job.subplugin = decided.subplugin
329
+ if (decided.hint === 'subdir-plugin') return
301
330
  job.probeReason = reason
302
331
  return
303
332
  }
@@ -562,37 +591,9 @@ async function runInstallJob(job, ports, deps = {}) {
562
591
  job.finishedAt = Date.now()
563
592
  }
564
593
  }
565
- /** 读取用户额外 bundle(非官方模板)的补丁插入行 id 与包名,用于"额外插件"判定。 */
566
- async function readExtraBundleRows(profileDir) {
567
- const rows = new Set()
568
- try {
569
- const manifest = JSON.parse(await readFile(join(profileDir, 'package.json'), 'utf8'))
570
- const bundles = manifest.dsh?.profile?.bundles ?? []
571
- for (const pkg of bundles) {
572
- if (DEFAULT_BUNDLES.includes(pkg)) continue
573
- try {
574
- const pk = resolvePackageJson(pkg, profileDir)
575
- const dir = dirname(pk ?? join(profileDir, 'node_modules', ...String(pkg).split('/'), 'package.json'))
576
- const text = await readFile(join(dir, 'cordis.patch.yml'), 'utf8')
577
- const lines = text.split(/\r?\n/u)
578
- let inInsert = false
579
- for (let index = 0; index < lines.length; index += 1) {
580
- const line = lines[index]
581
- if (/^- insert:\s*$/u.test(line)) {
582
- inInsert = true
583
- continue
584
- }
585
- if (/^- /u.test(line)) inInsert = false
586
- if (!inInsert) continue
587
- const idMatch = line.match(/^ {4}- id: ([A-Za-z0-9_.-]+)\s*$/u)
588
- if (!idMatch) continue
589
- rows.add(idMatch[1])
590
- const nameMatch = (lines[index + 1] ?? '').match(/^ {6}name: ['"]([^'"]+)['"]\s*$/u)
591
- if (nameMatch) rows.add(nameMatch[1])
592
- }
593
- } catch {}
594
- }
595
- } catch {}
596
- return rows
597
- }
598
- export { runInstallJob, pnpmRemove, readExtraBundleRows, cleanupAttemptedCandidates, aiConsentFailureText, tryCandidateChannels, channelImpls, RELEASE_CHANNEL_BUDGET, GIT_CHANNEL_BUDGET_MS, GIT_SPEC_TIMEOUT_MS, GIT_MIN_ATTEMPT_MS, gitChannelBudgetMs, noteChannel, formatBudgetMs }
594
+ /** 读取用户额外 bundle(非官方模板)的补丁插入行 id 与包名,用于"额外插件"判定。
595
+ * 实现在 domain/bundle-refs.js(2026-10-13 搬出,只搬移未改逻辑:那边与 insert 行解析同属一件事,
596
+ * 这边腾出 600 行硬顶);这里保留同名 re-export —— **对外导出名与调用点一个字都没变**。 */
597
+ export { readExtraBundleRows }
598
+
599
+ export { runInstallJob, pnpmRemove, cleanupAttemptedCandidates, aiConsentFailureText, tryCandidateChannels, channelImpls, RELEASE_CHANNEL_BUDGET, GIT_CHANNEL_BUDGET_MS, GIT_SPEC_TIMEOUT_MS, GIT_MIN_ATTEMPT_MS, gitChannelBudgetMs, noteChannel, formatBudgetMs }
@@ -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,6 +536,9 @@ 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 文案 —— 面板要能**单独显示**(哪个子目录、包名、哪些 dsh 标记)。
541
+ subplugin: job.subplugin ?? null,
539
542
  // 子包级/套装级进度(2026-09-20 真装实测缺口:11 个子包的聚合仓库跑了 19 分钟,面板只显示
540
543
  // "安装中",用户不知道在装第几个、还剩几个)。子包通道来自 install-job.js 候选循环维护的
541
544
  // candidateXxx;套装通道来自 suite.js 维护的 suiteProgress(clone/装配两阶段)。
@@ -554,6 +557,11 @@ function installJobView(job) {
554
557
  // 持久化判据(0.5.38 加法,见 domain/persist.js):`persisted:false` ⇒ 面板必须报「未持久化」
555
558
  // (重启/任何一次 pnpm 操作后可能消失)并给出「一键钉住」动作 —— 绝不报"安装成功"。
556
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,
557
565
  }
558
566
  }
559
567