dsh-plugin-manager-companion 0.1.1 → 0.1.2

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.
@@ -185,6 +185,7 @@ export declare const zh: {
185
185
  readonly 'config.diagnostics.runtime': "L3 运行时";
186
186
  readonly 'config.diagnostics.consistency': "L4 一致性";
187
187
  readonly 'config.diagnostics.ecosystem': "L5 生态(需要联网)";
188
+ readonly 'config.diagnostics.reportStaleModuleFallbackLinks': "提示过期的依赖兜底链接";
188
189
  readonly 'config.marketplace': "市场";
189
190
  readonly 'config.marketplace.enabled': "启用远程索引";
190
191
  readonly 'config.marketplace.cacheTtlMinutes': "索引缓存有效期(分钟)";
package/dist/client.js CHANGED
@@ -185,6 +185,7 @@ window.__ModuleLoader__.load({
185
185
  "config.diagnostics.runtime": "L3 运行时",
186
186
  "config.diagnostics.consistency": "L4 一致性",
187
187
  "config.diagnostics.ecosystem": "L5 生态(需要联网)",
188
+ "config.diagnostics.reportStaleModuleFallbackLinks": "提示过期的依赖兜底链接",
188
189
  "config.marketplace": "市场",
189
190
  "config.marketplace.enabled": "启用远程索引",
190
191
  "config.marketplace.cacheTtlMinutes": "索引缓存有效期(分钟)",
@@ -637,6 +638,7 @@ window.__ModuleLoader__.load({
637
638
  "config.diagnostics.runtime": "L3 runtime",
638
639
  "config.diagnostics.consistency": "L4 consistency",
639
640
  "config.diagnostics.ecosystem": "L5 ecosystem (network)",
641
+ "config.diagnostics.reportStaleModuleFallbackLinks": "Report stale module-fallback links",
640
642
  "config.marketplace": "Marketplace",
641
643
  "config.marketplace.enabled": "Enable the remote index",
642
644
  "config.marketplace.cacheTtlMinutes": "Index cache TTL (minutes)",
@@ -2385,7 +2387,8 @@ window.__ModuleLoader__.load({
2385
2387
  composition: true,
2386
2388
  runtime: true,
2387
2389
  consistency: true,
2388
- ecosystem: false
2390
+ ecosystem: false,
2391
+ reportStaleModuleFallbackLinks: true
2389
2392
  },
2390
2393
  qualityGate: {
2391
2394
  enabled: true,
@@ -2446,7 +2449,8 @@ window.__ModuleLoader__.load({
2446
2449
  composition: read(diagnostics, "diagnostics.composition", flag(diagnostics["composition"], defaults.diagnostics.composition)),
2447
2450
  runtime: read(diagnostics, "diagnostics.runtime", flag(diagnostics["runtime"], defaults.diagnostics.runtime)),
2448
2451
  consistency: read(diagnostics, "diagnostics.consistency", flag(diagnostics["consistency"], defaults.diagnostics.consistency)),
2449
- ecosystem: read(diagnostics, "diagnostics.ecosystem", flag(diagnostics["ecosystem"], defaults.diagnostics.ecosystem))
2452
+ ecosystem: read(diagnostics, "diagnostics.ecosystem", flag(diagnostics["ecosystem"], defaults.diagnostics.ecosystem)),
2453
+ reportStaleModuleFallbackLinks: read(diagnostics, "diagnostics.reportStaleModuleFallbackLinks", flag(diagnostics["reportStaleModuleFallbackLinks"], defaults.diagnostics.reportStaleModuleFallbackLinks))
2450
2454
  },
2451
2455
  qualityGate: {
2452
2456
  enabled: read(qualityGate, "qualityGate.enabled", flag(qualityGate["enabled"], defaults.qualityGate.enabled)),
@@ -4561,7 +4565,7 @@ window.__ModuleLoader__.load({
4561
4565
  }
4562
4566
  //#endregion
4563
4567
  //#region \0companion-css:/home/sixiao/aicode/test/test8/dsh-plugin-manager-companion/src/client/OfficialSlots.module.css.mjs
4564
- const css$4 = ".Kig3dq_summary{color:var(--dsw-alias-label-tertiary);margin:0;font-size:13px;line-height:20px}.Kig3dq_page{color:var(--dsw-alias-label-primary);flex-direction:column;gap:10px;display:flex}.Kig3dq_upgrade{border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);border-radius:10px;flex-direction:column;gap:6px;padding:10px 12px;display:flex}.Kig3dq_upgradeHead{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.Kig3dq_upgradeTitle{font-size:13px;font-weight:600}.Kig3dq_upgradeVersions{font-family:var(--dsw-font-markdown-code-font-family);color:var(--dsw-alias-label-secondary);font-size:12px}.Kig3dq_upgradeField{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.Kig3dq_upgradeNote{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:18px}.Kig3dq_upgradeWarn{color:var(--dsw-alias-state-warning-primary,var(--dsw-alias-label-secondary));margin:0;font-size:12px;line-height:18px}.Kig3dq_upgradeActions{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.Kig3dq_upgradeReason{flex-direction:column;gap:2px;display:flex}.Kig3dq_upgradeReasonLine{color:var(--dsw-alias-label-secondary);white-space:pre-wrap;margin:0;font-size:12px;line-height:18px}.Kig3dq_upgradeFacts{color:var(--dsw-alias-label-tertiary);margin:0;padding-left:18px;font-size:12px;line-height:18px}.Kig3dq_upgradeOutput{background:var(--dsw-alias-bg-layer-1);max-height:220px;font-family:var(--dsw-font-markdown-code-font-family);color:var(--dsw-alias-label-secondary);white-space:pre-wrap;word-break:break-word;border-radius:8px;margin:0;padding:8px 10px;font-size:12px;line-height:18px;overflow:auto}.Kig3dq_upgradeCommand{font-family:var(--dsw-font-markdown-code-font-family);color:var(--dsw-alias-label-secondary);word-break:break-all;font-size:12px;line-height:18px;display:block}";
4568
+ const css$4 = ".Kig3dq_summary{color:var(--dsw-alias-label-tertiary);margin:0;font-size:13px;line-height:20px}.Kig3dq_page{color:var(--dsw-alias-label-primary);flex-direction:column;gap:10px;display:flex}.Kig3dq_upgrade{flex-direction:column;gap:6px;display:flex}.Kig3dq_upgradeHead{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.Kig3dq_upgradeTitle{font-size:13px;font-weight:600}.Kig3dq_upgradeVersions{font-family:var(--dsw-font-markdown-code-font-family);color:var(--dsw-alias-label-secondary);font-size:12px}.Kig3dq_upgradeField{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.Kig3dq_upgradeNote{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:18px}.Kig3dq_upgradeWarn{color:var(--dsw-alias-state-warning-primary,var(--dsw-alias-label-secondary));margin:0;font-size:12px;line-height:18px}.Kig3dq_upgradeActions{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.Kig3dq_upgradeReason{flex-direction:column;gap:2px;display:flex}.Kig3dq_upgradeReasonLine{color:var(--dsw-alias-label-secondary);white-space:pre-wrap;margin:0;font-size:12px;line-height:18px}.Kig3dq_upgradeFacts{color:var(--dsw-alias-label-tertiary);margin:0;padding-left:18px;font-size:12px;line-height:18px}.Kig3dq_upgradeOutput{background:var(--dsw-alias-bg-layer-1);max-height:220px;font-family:var(--dsw-font-markdown-code-font-family);color:var(--dsw-alias-label-secondary);white-space:pre-wrap;word-break:break-word;border-radius:8px;margin:0;padding:8px 10px;font-size:12px;line-height:18px;overflow:auto}.Kig3dq_upgradeCommand{font-family:var(--dsw-font-markdown-code-font-family);color:var(--dsw-alias-label-secondary);word-break:break-all;font-size:12px;line-height:18px;display:block}";
4565
4569
  const tagId$4 = "dsh-plugin-manager-companion/OfficialSlots.module.css";
4566
4570
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$4) + "]") === null) {
4567
4571
  const tag = document.createElement("style");
@@ -7232,7 +7236,20 @@ select(draft, id) {
7232
7236
  actions.editConfigField(["diagnostics", layer], next);
7233
7237
  }
7234
7238
  })]
7235
- }, layer))
7239
+ }, layer)),
7240
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7241
+ className: ConsolePage_module_css_default.fieldRow,
7242
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
7243
+ className: ConsolePage_module_css_default.metaLabel,
7244
+ children: t("config.diagnostics.reportStaleModuleFallbackLinks")
7245
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Switch, {
7246
+ checked: draft.diagnostics.reportStaleModuleFallbackLinks,
7247
+ label: t("config.diagnostics.reportStaleModuleFallbackLinks"),
7248
+ onChange: (next) => {
7249
+ actions.editConfigField(["diagnostics", "reportStaleModuleFallbackLinks"], next);
7250
+ }
7251
+ })]
7252
+ })
7236
7253
  ]
7237
7254
  }),
7238
7255
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("fieldset", {
@@ -38,7 +38,8 @@ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'n
38
38
  import { createRequire, isBuiltin } from 'node:module';
39
39
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
40
40
  import { fileURLToPath } from 'node:url';
41
- import { profilesRoot, readEnvironmentManifest } from "./paths.js";
41
+ import { dshHome, profilesRoot, readEnvironmentManifest } from "./paths.js";
42
+ import { cleanupDanglingLinks, moduleFallbackDir, readModuleFallbackClosure, scanModuleFallback, scanProfileModuleFallback, } from "./moduleFallback.js";
42
43
  import { readRuntimeInventory } from "./official.js";
43
44
  /** 传给官方装载函数(loadProfileDirectory / loadOptionalPatches)的诊断前缀。 */
44
45
  const DIAG_BIN = 'dsh-plugin-manager-companion';
@@ -148,8 +149,14 @@ export async function analyzeEnvironment(ctx, env, config) {
148
149
  reason: '运行时事实不可用:' + (runtime.reason ?? '原因未知'),
149
150
  });
150
151
  }
152
+ // 依赖兜底目录($DSH_HOME/profiles/node_modules)的陈旧链接检查。
153
+ //
154
+ // 为什么在这里算(而不是放进 collectStaticFacts):它要调**异步**的官方闭包算法。
155
+ // 层的 run() 是同步契约(其余四层都不需要 IO 之外的等待),为它把整条链改成异步
156
+ // 会把四层的调用点全部牵动——所以在进入层循环前算好,作为事实传进依赖层。
157
+ const moduleFallback = await readModuleFallbackFacts(env, diagnostics, skipped);
151
158
  const layers = [
152
- { layer: 'dependency', run: () => dependencyLayer(env, facts, composition) },
159
+ { layer: 'dependency', run: () => dependencyLayer(env, facts, composition, moduleFallback) },
153
160
  { layer: 'composition', run: () => compositionLayer(env, facts, composition, skipped) },
154
161
  { layer: 'runtime', run: () => runtimeLayer(env, facts, composition, runtime) },
155
162
  { layer: 'consistency', run: () => consistencyLayer(env, facts, composition, runtime) },
@@ -408,9 +415,65 @@ function collectStaticFacts(env, config, skipped) {
408
415
  packageDirs, scans, manifests, edges, unscanned,
409
416
  };
410
417
  }
411
- // ── L1 依赖层 ───────────────────────────────────────────────────────────
412
- /** L1:import 图 × 声明 × loader 提供项。 */
413
- function dependencyLayer(env, facts, composition) {
418
+ /**
419
+ * 扫一遍依赖兜底目录(`$DSH_HOME/profiles/node_modules`)。
420
+ *
421
+ * 目录不存在、闭包算不出来都**不是"没问题"**:如实记 skipped,让界面画成"没查",
422
+ * 而不是画成 0(DESIGN:不允许用"没有标记"表达状态)。
423
+ *
424
+ * @param env - 被诊断环境(用它的 installAnchor 算闭包)。
425
+ * @param skipped - 跳过记录收集器。
426
+ * @returns 依赖层用的事实。
427
+ */
428
+ async function readModuleFallbackFacts(env, config, skipped) {
429
+ const dir = moduleFallbackDir(dshHome());
430
+ if (!existsSync(dir)) {
431
+ // 共享层不存在 ≠ 没得查:**私有层是每个 profile 自己的**,仍然要扫。
432
+ // 这条 skipped 只说共享层没扫到,措辞不能写成"这台机器上没有可检查的陈旧链接"——
433
+ // 那会让下面私有层查出来的问题看起来自相矛盾(真机 web profile 就是这样:共享层不存在,
434
+ // 私有层却有 2 条断链)。
435
+ skipped.push({
436
+ check: 'module-fallback',
437
+ layers: ['dependency'],
438
+ reason: '共享依赖兜底目录不存在(' + dir + '):这一类没有可检查的内容。',
439
+ });
440
+ return {
441
+ scan: scanModuleFallback(dir, { ok: false, reason: '目录不存在' }),
442
+ profileScan: scanProfileModuleFallback(env.dir),
443
+ staleMuted: staleMutedIn(config),
444
+ };
445
+ }
446
+ const closure = await readModuleFallbackClosure(env.installAnchor, dshHome());
447
+ if (!closure.ok) {
448
+ // 注意:这**不是**"这一层整层没查"——断链的判据与闭包无关,照常产出(scanModuleFallback 已处理)。
449
+ // 所以只说清"过时这一类没查",并且**不带 layers**:带 layers 会被界面画成整层"没查",
450
+ // 那会把已经查出来的断链一起抹掉。
451
+ skipped.push({
452
+ check: 'module-fallback-stale',
453
+ reason: '依赖兜底目录的当前闭包算不出来(' + closure.reason + '):'
454
+ + '本次没有判定"完好但过时"这一类(断链仍照常检查)。',
455
+ });
456
+ }
457
+ return {
458
+ scan: scanModuleFallback(dir, closure),
459
+ profileScan: scanProfileModuleFallback(env.dir),
460
+ staleMuted: staleMutedIn(config),
461
+ };
462
+ }
463
+ /**
464
+ * 「完好但过时」是否被配置静音。
465
+ *
466
+ * 判据是 `=== false` 而不是 `!value`:这份配置可能来自只写了几个字段的旧配置文件
467
+ * (诊断测试与调用方都可能传部分对象),缺字段必须按**默认上报**处理——
468
+ * 用 `!value` 会把"没写"读成"关掉",正好把默认值反过来。
469
+ *
470
+ * @param config - 诊断配置(可能缺字段)。
471
+ * @returns 是否静音。
472
+ */
473
+ function staleMutedIn(config) {
474
+ return config.reportStaleModuleFallbackLinks === false;
475
+ }
476
+ function dependencyLayer(env, facts, composition, moduleFallback) {
414
477
  const issues = [];
415
478
  const envDir = facts.envDir;
416
479
  const manifestPath = join(envDir, 'package.json');
@@ -553,6 +616,102 @@ function dependencyLayer(env, facts, composition) {
553
616
  }
554
617
  issues.push(...peerIssues(name, manifest, facts));
555
618
  }
619
+ issues.push(...moduleFallbackIssues(moduleFallback));
620
+ return issues;
621
+ }
622
+ /**
623
+ * 依赖兜底目录的陈旧链接 → 两条发现(断链 / 完好但过时)。
624
+ *
625
+ * ## 为什么它归依赖层
626
+ *
627
+ * 它说的是"模块从哪来"这件事:这个目录是官方为所有 profile 铺的共享解析层。
628
+ *
629
+ * ## 文案必须说清的两件事
630
+ *
631
+ * 1. **它已不参与解析**——官方 0.1.6-alpha.2 默认 `resolutionMode = "runtime"`,
632
+ * runtime 走 `materialize: false`,根本不写这个目录,解析也不读它。
633
+ * 不说这句,用户会以为删了会坏。
634
+ * 2. **断链与过时的处置不同**:断链默认自动删(旧代际残骸),完好但过时只报不删
635
+ * (目标还在,用户可能有意保留)。
636
+ *
637
+ * @param facts - 扫描事实。
638
+ * @returns 发现清单(没有问题时为空数组)。
639
+ */
640
+ function moduleFallbackIssues(facts) {
641
+ const { scan } = facts;
642
+ const issues = [];
643
+ const PREVIEW = 20;
644
+ // 共享层与私有层**各自独立**:共享层没扫到(目录不存在)不该连累私有层。
645
+ // 真机就是这样:共享层不存在,而 web profile 的私有层有 2 条断链。
646
+ if (scan.scanned && scan.dangling.length > 0) {
647
+ const preview = scan.dangling.slice(0, PREVIEW);
648
+ issues.push(makeIssue({
649
+ layer: 'dependency',
650
+ severity: 'safe-fix',
651
+ code: 'module-fallback-dangling-link',
652
+ title: '依赖兜底目录里有 ' + String(scan.dangling.length) + ' 条断开的链接',
653
+ detail: '这些链接指向的包已经不在磁盘上了(旧版本留下的残骸)。'
654
+ + '当前版本默认不写这个目录、也不从它解析(官方 0.1.6-alpha.2 的 runtime 解析模式),'
655
+ + '所以删掉它们不会影响任何东西的运行。'
656
+ + '修复只删符号链接本身,不递归删目录、不碰其它条目。'
657
+ + (scan.dangling.length > PREVIEW ? '(下面只列前 ' + String(PREVIEW) + ' 条,共 ' + String(scan.dangling.length) + ' 条。)' : ''),
658
+ subjects: preview.map(link => link.name),
659
+ scope: 'profiles/node_modules',
660
+ evidence: preview.map(link => ({
661
+ kind: 'file',
662
+ at: link.name,
663
+ note: '断链,指向 ' + (link.target ?? '(读不到目标)'),
664
+ })),
665
+ fix: {
666
+ action: 'remove-dangling-module-fallback-links',
667
+ summary: '删除这 ' + String(scan.dangling.length) + ' 条断链(只删符号链接)',
668
+ },
669
+ id: 'module-fallback-dangling-link',
670
+ }));
671
+ }
672
+ if (facts.profileScan.dangling.length > 0) {
673
+ const preview = facts.profileScan.dangling.slice(0, PREVIEW);
674
+ issues.push(makeIssue({
675
+ layer: 'dependency',
676
+ severity: 'report-only',
677
+ code: 'profile-module-fallback-dangling-link',
678
+ title: '这个环境的私有兜底层里有 ' + String(facts.profileScan.dangling.length) + ' 条断开的链接',
679
+ detail: '它们指向的包已经不存在了(多半是卸载插件后留下的)。'
680
+ + '私有兜底层由官方维护,官方**有**清理机制,所以出现断链是官方没清掉——'
681
+ + '这不影响当前运行(解析不读断掉的链接),但它是磁盘上的残留,值得知道。'
682
+ + '本项目**不自动删**它:这个目录归官方管,我们只如实报出来。',
683
+ subjects: preview.map(link => link.name),
684
+ scope: 'profile/.dsh-module-fallback',
685
+ evidence: preview.map(link => ({
686
+ kind: 'file',
687
+ at: '.dsh-module-fallback/node_modules/' + link.name,
688
+ note: '断链,指向 ' + (link.target ?? '(读不到目标)'),
689
+ })),
690
+ id: 'profile-module-fallback-dangling-link',
691
+ }));
692
+ }
693
+ if (scan.scanned && scan.stale.length > 0 && !facts.staleMuted) {
694
+ const preview = scan.stale.slice(0, PREVIEW);
695
+ issues.push(makeIssue({
696
+ layer: 'dependency',
697
+ severity: 'report-only',
698
+ code: 'module-fallback-stale-link',
699
+ title: '依赖兜底目录里有 ' + String(scan.stale.length) + ' 条链接不在当前安装的依赖闭包里',
700
+ detail: '这些链接的目标还在磁盘上,但它们不在当前 dsh 安装的依赖闭包里——'
701
+ + '多半是别的版本留下的。当前版本不从这个目录解析,所以它们不影响运行;'
702
+ + '我们**不自动删**(目标还在,可能是有意保留的),只报出来供你判断。'
703
+ + '不想要这类提示可以在设置里关掉。'
704
+ + (scan.stale.length > PREVIEW ? '(下面只列前 ' + String(PREVIEW) + ' 条,共 ' + String(scan.stale.length) + ' 条。)' : ''),
705
+ subjects: preview.map(link => link.name),
706
+ scope: 'profiles/node_modules',
707
+ evidence: preview.map(link => ({
708
+ kind: 'file',
709
+ at: link.name,
710
+ note: '不在当前闭包里,指向 ' + (link.target ?? '(读不到目标)'),
711
+ })),
712
+ id: 'module-fallback-stale-link',
713
+ }));
714
+ }
556
715
  return issues;
557
716
  }
558
717
  /** 一个包的 peerDependencies 与本环境实际安装版本的对照。 */
@@ -1045,3 +1045,67 @@ export interface RestoreEnvironmentOptions extends CrossEnvironmentOptions {
1045
1045
  * @returns 操作结果。
1046
1046
  */
1047
1047
  export declare function backupRestore(backup: EnvironmentBackup, target: string, options?: RestoreEnvironmentOptions): Promise<EnvironmentResult>;
1048
+ /**
1049
+ * 目录类文件操作的重试。
1050
+ *
1051
+ * @param operation - 要执行的文件操作。
1052
+ * @param attempts - 尝试次数上限。
1053
+ */
1054
+ /**
1055
+ * 删除一棵树的兜底:第一次失败后(Windows 上)清掉只读属性,再重试一次。
1056
+ *
1057
+ * ## 这条兜底真正守的是什么
1058
+ *
1059
+ * **不是只读属性**——见下「修正说明」。它守的是两件与平台无关的事:
1060
+ *
1061
+ * 1. **失败后重试一次**:对**瞬时**失败有效(文件被别的进程短暂占用、索引/杀毒尚未释放、
1062
+ * `EBUSY`/`EPERM` 那一类会自己消失的原因)。这是真实世界里最常见的删除失败形态。
1063
+ * 2. **三层如实报错**:原始错误 + 我们做了什么 + 仍然失败。**这一条才是真正的交付**——
1064
+ * 删除失败时用户能看到「为什么」,而不是一个裸 EPERM。
1065
+ * (变异 M7/M8 钉的正是这两行:删掉它们测试当场红。)
1066
+ *
1067
+ * ## 修正说明(2026-09-20,理由改过,实现保留)
1068
+ *
1069
+ * 本函数原先的理由是「Windows 上 `rmSync` 的 force 不覆盖只读属性 → 删除会永远失败」。
1070
+ * **那个论断是错的**:
1071
+ * · 那句话**不在 Node 文档里**(v16/v18/v20/v22/v24 的 doc/api/fs.md 逐版 grep 零命中);
1072
+ * · 底层 libuv 的 `fs__unlink_rmdir`(`src/win/fs.c`)**主动带**
1073
+ * `FILE_DISPOSITION_IGNORE_READONLY_ATTRIBUTE`(Win10 1607+),老系统 fallback 也自己先清只读。
1074
+ * ⇒ 只读属性在 libuv 那一层就已经被处理掉了,挡不住删除。
1075
+ *
1076
+ * 所以**清只读那一步降级为冗余保险**:不指望它救场,保留只因为成本极低、
1077
+ * 且与 Node 自己的 `rimraf` 做法一致(`lib/internal/fs/rimraf.js` 的 `fixWinEPERM`:
1078
+ * unlink 报 EPERM 时 Windows 上先 `chmod(path, 0o666)` 再重试,且把 chmod 失败当作可接受)。
1079
+ *
1080
+ * **没有真机证据**:POSIX 上造不出这个形态,Windows 上 libuv 已处理——两边都取不到。
1081
+ * 判据因此落在行为上(测试用注入的假 fs)。
1082
+ *
1083
+ * ## 纪律(三条)
1084
+ *
1085
+ * 1. **只在删除这个明确意图下清只读,且只在第一次失败后做**——不是无差别预清。
1086
+ * 先按现状删;删不掉才动属性,避免给正常路径增加副作用。
1087
+ * 2. **只对 win32 做这一步**——POSIX 上 force 本来就够,多一步是无谓的副作用。
1088
+ * 3. **仍失败就如实报错**——带上原始错误(EPERM/EBUSY 原文)、我们做了什么、以及它仍然失败。
1089
+ * 不许静默降级,不许假装删成功。
1090
+ *
1091
+ * ## 清只读的做法(冗余保险)
1092
+ *
1093
+ * `chmodSync(path, 0o666)`:与 Node 的 `fixWinEPERM` 同一个值(POSIX 语义下 0o666 只是去掉写保护位)。
1094
+ * 必须**递归**——只读的可能是 `node_modules` 里某个深层文件,只清顶层目录没用。
1095
+ * 清属性本身失败(比如某个路径已经不存在了)不单独报错:继续清其余的,
1096
+ * 真正的判据是**重试删除**的结果(与 Node 自己的取舍一致)。
1097
+ *
1098
+ * @param dir - 要删的目录。
1099
+ * @param options - 注入点(测试用;生产路径用真实 fs 与 process.platform)。
1100
+ * @returns 删除完成(成功时 resolve;仍失败时抛原始错误)。
1101
+ */
1102
+ export declare function removeTreeWithReadonlyFallback(dir: string, options?: {
1103
+ /** 删除操作(默认 rmSync recursive+force)。 */
1104
+ readonly remove?: (path: string) => void;
1105
+ /** 清只读(默认 chmodSync 0o666)。 */
1106
+ readonly clearReadonly?: (path: string) => void;
1107
+ /** 列出子项(默认 readdirSync;用于递归清只读)。 */
1108
+ readonly list?: (path: string) => readonly string[];
1109
+ /** 平台(默认 process.platform);只有 win32 才走清只读那一步。 */
1110
+ readonly platform?: string;
1111
+ }): Promise<void>;
@@ -24,7 +24,7 @@
24
24
  * 官方 operations —— 差别只是传给 runPluginCommand 的 profile 参数,不是两套实现。
25
25
  */
26
26
  import { execFileSync, spawn, spawnSync } from 'node:child_process';
27
- import { accessSync, appendFileSync, closeSync, constants, copyFileSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, } from 'node:fs';
27
+ import { accessSync, appendFileSync, chmodSync, closeSync, constants, copyFileSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, } from 'node:fs';
28
28
  import { createHash } from 'node:crypto';
29
29
  import { fileURLToPath } from 'node:url';
30
30
  import { request as httpRequest } from 'node:http';
@@ -823,7 +823,8 @@ export async function removeEnvironment(name, options = {}) {
823
823
  }
824
824
  return enqueueMutation(async () => {
825
825
  try {
826
- await retryFs(() => rmSync(dir, { recursive: true, force: true }));
826
+ // Windows 只读兜底:环境目录里 node_modules 可能有只读文件,force Windows 上删不掉。
827
+ await retryFs(async () => { await removeTreeWithReadonlyFallback(dir); });
827
828
  }
828
829
  catch (error) {
829
830
  return failure('io-failed', '删除失败 ' + name + ':' + messageOf(error));
@@ -2141,7 +2142,7 @@ export async function materializeSnapshot(sourceName, targetName, options = {})
2141
2142
  if (!existsSync(path))
2142
2143
  continue;
2143
2144
  try {
2144
- rmSync(path, { recursive: true, force: true });
2145
+ await removeTreeWithReadonlyFallback(path);
2145
2146
  }
2146
2147
  catch (error) {
2147
2148
  throw new EnvironmentError('snapshot-not-shallow', '试装环境里还留着上一次试装的依赖文件,这次删不掉(' + messageOf(error)
@@ -2257,7 +2258,8 @@ export async function removeTrialEnvironment(name, options = {}) {
2257
2258
  }
2258
2259
  return enqueueMutation(async () => {
2259
2260
  try {
2260
- await retryFs(() => rmSync(dir, { recursive: true, force: true }));
2261
+ // Windows 只读兜底:环境目录里 node_modules 可能有只读文件,force Windows 上删不掉。
2262
+ await retryFs(async () => { await removeTreeWithReadonlyFallback(dir); });
2261
2263
  }
2262
2264
  catch (error) {
2263
2265
  return failure('io-failed', '删除测试环境失败 ' + name + ':' + messageOf(error));
@@ -3195,6 +3197,124 @@ async function officialBundlePredicate(installAnchor) {
3195
3197
  * @param operation - 要执行的文件操作。
3196
3198
  * @param attempts - 尝试次数上限。
3197
3199
  */
3200
+ /**
3201
+ * 删除一棵树的兜底:第一次失败后(Windows 上)清掉只读属性,再重试一次。
3202
+ *
3203
+ * ## 这条兜底真正守的是什么
3204
+ *
3205
+ * **不是只读属性**——见下「修正说明」。它守的是两件与平台无关的事:
3206
+ *
3207
+ * 1. **失败后重试一次**:对**瞬时**失败有效(文件被别的进程短暂占用、索引/杀毒尚未释放、
3208
+ * `EBUSY`/`EPERM` 那一类会自己消失的原因)。这是真实世界里最常见的删除失败形态。
3209
+ * 2. **三层如实报错**:原始错误 + 我们做了什么 + 仍然失败。**这一条才是真正的交付**——
3210
+ * 删除失败时用户能看到「为什么」,而不是一个裸 EPERM。
3211
+ * (变异 M7/M8 钉的正是这两行:删掉它们测试当场红。)
3212
+ *
3213
+ * ## 修正说明(2026-09-20,理由改过,实现保留)
3214
+ *
3215
+ * 本函数原先的理由是「Windows 上 `rmSync` 的 force 不覆盖只读属性 → 删除会永远失败」。
3216
+ * **那个论断是错的**:
3217
+ * · 那句话**不在 Node 文档里**(v16/v18/v20/v22/v24 的 doc/api/fs.md 逐版 grep 零命中);
3218
+ * · 底层 libuv 的 `fs__unlink_rmdir`(`src/win/fs.c`)**主动带**
3219
+ * `FILE_DISPOSITION_IGNORE_READONLY_ATTRIBUTE`(Win10 1607+),老系统 fallback 也自己先清只读。
3220
+ * ⇒ 只读属性在 libuv 那一层就已经被处理掉了,挡不住删除。
3221
+ *
3222
+ * 所以**清只读那一步降级为冗余保险**:不指望它救场,保留只因为成本极低、
3223
+ * 且与 Node 自己的 `rimraf` 做法一致(`lib/internal/fs/rimraf.js` 的 `fixWinEPERM`:
3224
+ * unlink 报 EPERM 时 Windows 上先 `chmod(path, 0o666)` 再重试,且把 chmod 失败当作可接受)。
3225
+ *
3226
+ * **没有真机证据**:POSIX 上造不出这个形态,Windows 上 libuv 已处理——两边都取不到。
3227
+ * 判据因此落在行为上(测试用注入的假 fs)。
3228
+ *
3229
+ * ## 纪律(三条)
3230
+ *
3231
+ * 1. **只在删除这个明确意图下清只读,且只在第一次失败后做**——不是无差别预清。
3232
+ * 先按现状删;删不掉才动属性,避免给正常路径增加副作用。
3233
+ * 2. **只对 win32 做这一步**——POSIX 上 force 本来就够,多一步是无谓的副作用。
3234
+ * 3. **仍失败就如实报错**——带上原始错误(EPERM/EBUSY 原文)、我们做了什么、以及它仍然失败。
3235
+ * 不许静默降级,不许假装删成功。
3236
+ *
3237
+ * ## 清只读的做法(冗余保险)
3238
+ *
3239
+ * `chmodSync(path, 0o666)`:与 Node 的 `fixWinEPERM` 同一个值(POSIX 语义下 0o666 只是去掉写保护位)。
3240
+ * 必须**递归**——只读的可能是 `node_modules` 里某个深层文件,只清顶层目录没用。
3241
+ * 清属性本身失败(比如某个路径已经不存在了)不单独报错:继续清其余的,
3242
+ * 真正的判据是**重试删除**的结果(与 Node 自己的取舍一致)。
3243
+ *
3244
+ * @param dir - 要删的目录。
3245
+ * @param options - 注入点(测试用;生产路径用真实 fs 与 process.platform)。
3246
+ * @returns 删除完成(成功时 resolve;仍失败时抛原始错误)。
3247
+ */
3248
+ export async function removeTreeWithReadonlyFallback(dir, options = {}) {
3249
+ const remove = options.remove ?? ((path) => { rmSync(path, { recursive: true, force: true }); });
3250
+ const clearReadonly = options.clearReadonly ?? ((path) => { chmodSync(path, 0o666); });
3251
+ const list = options.list ?? ((path) => readdirSync(path));
3252
+ const platform = options.platform ?? process.platform;
3253
+ try {
3254
+ remove(dir);
3255
+ return;
3256
+ }
3257
+ catch (error) {
3258
+ // 纪律 2:非 Windows 不折腾属性——POSIX 上 force 本来就够。
3259
+ if (platform !== 'win32')
3260
+ throw error;
3261
+ const first = error;
3262
+ // 纪律 1:到这里说明第一次真的失败了,才清只读。
3263
+ clearReadonlyRecursive(dir, { clearReadonly, list });
3264
+ try {
3265
+ // 重试**一次**。
3266
+ remove(dir);
3267
+ return;
3268
+ }
3269
+ catch (retryError) {
3270
+ // 纪律 3:如实报错——原始错误 + 我们做了什么 + 仍然失败。
3271
+ // 文案按 §12.9 的 R2 规则写成**树状**(一层一个因果,每行最多一个冒号):
3272
+ // 结论 → 原始错误 → 我们做了什么 → 仍然失败的原因。
3273
+ throw new Error([
3274
+ '删除失败,Windows 只读属性兜底也没能删掉',
3275
+ ' 原始错误 ' + describeError(first),
3276
+ ' → 已递归清除只读属性,并重试了一次',
3277
+ ' → 仍然失败 ' + describeError(retryError),
3278
+ ].join(String.fromCharCode(10)));
3279
+ }
3280
+ }
3281
+ }
3282
+ /**
3283
+ * 递归清掉一棵树的只读属性(best-effort)。
3284
+ *
3285
+ * 为什么递归:只读的可能是 `node_modules` 里某个深层文件;只清顶层目录没有用。
3286
+ * 单个路径失败不中断——继续清其余的,让**重试删除**去给出最终判据。
3287
+ *
3288
+ * @param root - 根路径。
3289
+ * @param deps - 注入的 fs 操作。
3290
+ */
3291
+ function clearReadonlyRecursive(root, deps) {
3292
+ const walk = (path) => {
3293
+ try {
3294
+ deps.clearReadonly(path);
3295
+ }
3296
+ catch {
3297
+ // 清不掉就往下走:最终判据是重试删除的结果,不是这里。
3298
+ }
3299
+ let children;
3300
+ try {
3301
+ children = deps.list(path);
3302
+ }
3303
+ catch {
3304
+ // 读不到子项(可能是文件,或已不存在):到此为止。
3305
+ return;
3306
+ }
3307
+ for (const child of children)
3308
+ walk(join(path, child));
3309
+ };
3310
+ walk(root);
3311
+ }
3312
+ /** 错误文本(带 code,便于用户排查)。 */
3313
+ function describeError(error) {
3314
+ const code = error.code;
3315
+ const text = error instanceof Error ? error.message : String(error);
3316
+ return code === undefined ? text : code + '(' + text + ')';
3317
+ }
3198
3318
  async function retryFs(operation, attempts = 5) {
3199
3319
  const sleep = (ms) => new Promise((done) => { setTimeout(done, ms); });
3200
3320
  for (let attempt = 0;; attempt += 1) {
package/dist/fix.js CHANGED
@@ -8,11 +8,13 @@
8
8
  * 前提已变:官方现在拥有组合的写权,而它**没有**删行能力。我们不再自己写那个文件,
9
9
  * 改为如实回 needs-manual —— 见 applyFix 的 JSDoc。
10
10
  */
11
- import { existsSync } from "node:fs";
11
+ import { existsSync, unlinkSync } from "node:fs";
12
12
  import { rm } from "node:fs/promises";
13
13
  import { join } from "node:path";
14
- import { environmentDir } from "./paths.js";
14
+ import { dshHome, environmentDir } from "./paths.js";
15
15
  import { requireManager } from "./official.js";
16
+ import { cleanupDanglingLinks, moduleFallbackDir, readModuleFallbackClosure, scanModuleFallback } from "./moduleFallback.js";
17
+ import { readInstallAnchor } from "./diagnostics.js";
16
18
  /**
17
19
  * 需要用户手工处理的两条:它们都要求改 cordis.patch.yml 的**结构**(删行)。
18
20
  *
@@ -85,6 +87,8 @@ export async function applyFix(action, target, deps) {
85
87
  if (target === undefined)
86
88
  return failed(action, "缺少 target(包名)");
87
89
  return await removeOfficialCopy(target, deps);
90
+ case "remove-dangling-module-fallback-links":
91
+ return await removeDanglingModuleFallbackLinks(deps);
88
92
  default:
89
93
  return { ok: false, action, status: "failed", output: "未知修复动作:" + action };
90
94
  }
@@ -122,6 +126,73 @@ function manualOutcome(action, target) {
122
126
  function failed(action, output, target) {
123
127
  return { ok: false, action, ...(target === undefined ? {} : { target }), status: "failed", output };
124
128
  }
129
+ /**
130
+ * 删掉依赖兜底目录(`$DSH_HOME/profiles/node_modules`)里的**断链**。
131
+ *
132
+ * 为什么这条修复自己动手删、而不找官方通道:官方**没有**清理这个目录的通道——
133
+ * `healProfilesModuleFallback` 只增不删(判据 `entries.every(...)` 只问"该有的到位没",
134
+ * 写入路径只遍历 entries、无 readdir/unlink)。旧代际留下的断链因此永远不会自己消失。
135
+ *
136
+ * 安全边界(三条,代码里逐条落实):
137
+ * 1. **只删符号链接**:每条都 `lstatSync().isSymbolicLink()` 确认;绝不递归删目录。
138
+ * 2. **只删断链**:删除前重新确认目标仍不存在(扫描到删除之间可能被别的进程动过)。
139
+ * 3. **结果如实**:删了几条 / 跳过几条 / 为什么 / 哪几条失败,逐条写进 output。
140
+ *
141
+ * 这不是 profile 的组合写入,所以不需要官方文件锁:它不碰 package.json、不碰 cordis.patch.yml,
142
+ * 删的只是共享兜底层里指向已消失目标的链接。
143
+ *
144
+ * @param deps - 修复依赖(需要 ctx 拿 DSH home 与安装锚点)。
145
+ * @returns 执行结果。
146
+ */
147
+ async function removeDanglingModuleFallbackLinks(deps) {
148
+ const action = "remove-dangling-module-fallback-links";
149
+ try {
150
+ const home = dshHome();
151
+ const dir = moduleFallbackDir(home);
152
+ const envDir = environmentDir(deps.environmentName() ?? "");
153
+ const anchor = readInstallAnchor(deps.ctx);
154
+ const closure = await readModuleFallbackClosure(anchor, home);
155
+ const scan = scanModuleFallback(dir, closure);
156
+ if (!scan.scanned) {
157
+ return {
158
+ ok: false, action, status: "failed",
159
+ output: "没有扫到可清理的依赖兜底目录(" + dir + "):"
160
+ + (scan.exists ? "当前安装的依赖闭包算不出来(" + String(scan.closureReason ?? "原因未知") + ")" : "目录不存在"),
161
+ };
162
+ }
163
+ // 注意:**没有断链也要往下走**——上一轮可能已经删完链接,但留下了空的 scope 目录。
164
+ // 真机就是这个状态(Lead 执行过一次清理:516 条链接归零,剩 29 个空 @scope 目录)。
165
+ // 早退会让那些空目录永远清不掉。
166
+ const result = cleanupDanglingLinks(dir, scan, path => { unlinkSync(path); });
167
+ const lines = [
168
+ "依赖兜底目录:" + dir,
169
+ "断链 " + String(scan.dangling.length) + " 条 → 已删 " + String(result.removed)
170
+ + " 条,跳过 " + String(result.skipped) + " 条,失败 " + String(result.failed) + " 条",
171
+ ];
172
+ if (result.removedScopes.length > 0) {
173
+ lines.push("变空的 scope 目录 → 已删 " + String(result.removedScopes.length) + " 个:"
174
+ + result.removedScopes.join("、"));
175
+ }
176
+ else if (result.scopeSkipReasons.length === 0) {
177
+ lines.push("没有变空的 scope 目录");
178
+ }
179
+ if (result.scopeSkipReasons.length > 0) {
180
+ lines.push("", "scope 目录未删:", ...result.scopeSkipReasons.map(r => " - " + r));
181
+ }
182
+ if (result.skipReasons.length > 0)
183
+ lines.push("", "跳过原因:", ...result.skipReasons.map(r => " - " + r));
184
+ if (result.failures.length > 0)
185
+ lines.push("", "失败:", ...result.failures.map(r => " - " + r));
186
+ if (scan.stale.length > 0) {
187
+ lines.push("", "完好但过时的 " + String(scan.stale.length) + " 条**没有动**(目标还在,按约定只报不删)");
188
+ }
189
+ void envDir;
190
+ return { ok: result.failed === 0, action, status: result.failed === 0 ? "executed" : "failed", output: lines.join(String.fromCharCode(10)) };
191
+ }
192
+ catch (error) {
193
+ return failed(action, error instanceof Error ? error.message : String(error));
194
+ }
195
+ }
125
196
  /**
126
197
  * 删掉 profile 里重复的官方包拷贝,并把声明从 dependencies 挪到 peerDependencies。
127
198
  *