dsh-config-manager 0.1.59 → 0.1.60

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.
Files changed (128) hide show
  1. package/README.md +3 -2
  2. package/lib/adapters/file-collection.js +7 -2
  3. package/lib/adapters/link-report.d.ts +16 -0
  4. package/lib/adapters/link-report.js +47 -0
  5. package/lib/adapters/plugin-files.js +7 -4
  6. package/lib/adapters/plugins.d.ts +10 -0
  7. package/lib/adapters/plugins.js +191 -4
  8. package/lib/adapters/pnpm-workspace.d.ts +26 -0
  9. package/lib/adapters/pnpm-workspace.js +147 -0
  10. package/lib/adapters/test-helpers.d.ts +2 -0
  11. package/lib/adapters/test-helpers.js +2 -0
  12. package/lib/client.d.ts +213 -0
  13. package/lib/client.js +1898 -946
  14. package/lib/core/analyzer.d.ts +3 -1
  15. package/lib/core/analyzer.js +15 -9
  16. package/lib/core/backup-plan.js +26 -32
  17. package/lib/core/backup.d.ts +13 -2
  18. package/lib/core/backup.js +34 -6
  19. package/lib/core/boot-rescue.d.ts +146 -0
  20. package/lib/core/boot-rescue.js +618 -0
  21. package/lib/core/config-lifecycle.d.ts +180 -0
  22. package/lib/core/config-lifecycle.js +481 -0
  23. package/lib/core/config-snapshot.d.ts +135 -0
  24. package/lib/core/config-snapshot.js +424 -0
  25. package/lib/core/config-state.d.ts +70 -0
  26. package/lib/core/config-state.js +177 -0
  27. package/lib/core/crash-report.d.ts +84 -0
  28. package/lib/core/crash-report.js +317 -0
  29. package/lib/core/journal.d.ts +3 -0
  30. package/lib/core/messages.d.ts +18 -0
  31. package/lib/core/messages.js +36 -0
  32. package/lib/core/phase3-host.js +1 -0
  33. package/lib/core/plugin-cli.d.ts +1 -1
  34. package/lib/core/plugin-cli.js +35 -0
  35. package/lib/core/recovery-orchestrator.d.ts +23 -0
  36. package/lib/core/recovery-orchestrator.js +47 -2
  37. package/lib/core/types.d.ts +8 -0
  38. package/lib/core/undo.d.ts +73 -0
  39. package/lib/core/undo.js +87 -0
  40. package/lib/core/watcher.d.ts +101 -0
  41. package/lib/core/watcher.js +219 -0
  42. package/lib/index.js +403 -32
  43. package/lib/market/prepare.js +5 -0
  44. package/lib/market/security.js +10 -0
  45. package/lib/schema/config.js +25 -0
  46. package/lib/schema/types.d.ts +16 -0
  47. package/lib/sync/autosync-scheduler.js +22 -0
  48. package/lib/sync/backup-scheduler.d.ts +3 -1
  49. package/lib/sync/backup-scheduler.js +19 -7
  50. package/lib/ui/i18n.d.ts +1 -0
  51. package/lib/ui/i18n.js +2 -0
  52. package/lib/ui/types.d.ts +26 -0
  53. package/lib/utils/env-lock.d.ts +17 -0
  54. package/lib/utils/env-lock.js +60 -3
  55. package/lib/utils/recursive-walk.d.ts +24 -0
  56. package/lib/utils/recursive-walk.js +134 -0
  57. package/package.json +1 -1
  58. package/src/adapters/file-collection.ts +8 -2
  59. package/src/adapters/files.test.ts +19 -0
  60. package/src/adapters/link-report.test.ts +65 -0
  61. package/src/adapters/link-report.ts +61 -0
  62. package/src/adapters/plugin-files.ts +8 -4
  63. package/src/adapters/plugins.test.ts +129 -0
  64. package/src/adapters/plugins.ts +185 -5
  65. package/src/adapters/pnpm-workspace.test.ts +87 -0
  66. package/src/adapters/pnpm-workspace.ts +150 -0
  67. package/src/adapters/test-helpers.ts +2 -0
  68. package/src/client/ConfigManagerSection.tsx +37 -2
  69. package/src/client/client-types.ts +2 -0
  70. package/src/client/index.ts +4 -1
  71. package/src/client/lifecycle/LifecyclePanel.tsx +524 -0
  72. package/src/client/lifecycle/lifecycle-api.ts +249 -0
  73. package/src/client/locales.ts +106 -0
  74. package/src/client/recovery/RecoveryPanel.tsx +53 -1
  75. package/src/client/recovery/recovery-api.ts +12 -1
  76. package/src/client/recovery/recovery-locales.ts +19 -0
  77. package/src/client/recovery/recovery-view.test.ts +23 -0
  78. package/src/client/recovery/recovery-view.ts +19 -3
  79. package/src/client/run-store.ts +8 -1
  80. package/src/client/snapshots/SnapshotsPanel.tsx +4 -1
  81. package/src/client/sync/history-model.test.ts +10 -0
  82. package/src/client/sync/history-model.ts +4 -0
  83. package/src/client/sync/sync-view-v2.test.ts +30 -2
  84. package/src/client/sync/sync-view.ts +19 -3
  85. package/src/core/analyzer.ts +15 -9
  86. package/src/core/backup-plan.ts +30 -27
  87. package/src/core/backup.ts +35 -6
  88. package/src/core/boot-rescue.test.ts +847 -0
  89. package/src/core/boot-rescue.ts +737 -0
  90. package/src/core/config-lifecycle.test.ts +727 -0
  91. package/src/core/config-lifecycle.ts +573 -0
  92. package/src/core/config-snapshot.test.ts +369 -0
  93. package/src/core/config-snapshot.ts +522 -0
  94. package/src/core/config-state.test.ts +381 -0
  95. package/src/core/config-state.ts +207 -0
  96. package/src/core/crash-report.test.ts +380 -0
  97. package/src/core/crash-report.ts +369 -0
  98. package/src/core/journal.ts +3 -0
  99. package/src/core/messages.ts +36 -0
  100. package/src/core/phase1-wiring.test.ts +139 -0
  101. package/src/core/phase3-host.ts +1 -0
  102. package/src/core/phase3-p2b-fingerprint.test.ts +8 -0
  103. package/src/core/plugin-cli.fs.test.ts +31 -0
  104. package/src/core/plugin-cli.test.ts +11 -0
  105. package/src/core/plugin-cli.ts +34 -0
  106. package/src/core/recovery-orchestrator.test.ts +180 -0
  107. package/src/core/recovery-orchestrator.ts +60 -2
  108. package/src/core/types.ts +8 -0
  109. package/src/core/undo.ts +116 -0
  110. package/src/core/watcher.test.ts +326 -0
  111. package/src/core/watcher.ts +263 -0
  112. package/src/index.ts +350 -30
  113. package/src/market/market.test.ts +44 -0
  114. package/src/market/prepare.test.ts +47 -0
  115. package/src/market/prepare.ts +8 -1
  116. package/src/market/security.ts +10 -0
  117. package/src/schema/config.ts +24 -0
  118. package/src/schema/types.ts +17 -0
  119. package/src/sync/autosync-scheduler.test.ts +70 -3
  120. package/src/sync/autosync-scheduler.ts +21 -0
  121. package/src/sync/backup-scheduler.test.ts +88 -1
  122. package/src/sync/backup-scheduler.ts +19 -7
  123. package/src/ui/i18n.ts +2 -0
  124. package/src/ui/types.ts +28 -0
  125. package/src/utils/env-lock.test.ts +53 -0
  126. package/src/utils/env-lock.ts +61 -2
  127. package/src/utils/recursive-walk.test.ts +148 -0
  128. package/src/utils/recursive-walk.ts +145 -0
package/README.md CHANGED
@@ -395,14 +395,15 @@ Symptoms and the fix:
395
395
  | Symptom | Meaning | Fix |
396
396
  |---|---|---|
397
397
  | 「另一个任务正在运行,请稍后重试。」 / "Another task is running, please retry." | A live operation holds the lock | Just wait — it clears itself |
398
- | 「检测到上次异常退出残留的配置锁…重试或重启 DSH 均无效」 / relayed in the log as `自动同步已跳过` | The owner process is **proven dead** (leftover lock) | Run the command below, or use GUI **Recovery → 事故恢复** |
398
+ | 「检测到上次异常退出残留的配置锁…重试或重启 DSH 均无效」 / relayed in the log as `自动同步已跳过` | The owner process is **proven dead** (leftover lock) | Run the command below, or use GUI **Recovery → 事故恢复 → 回收残留锁** |
399
+ | Same message, but the owner PID was **reused** by an unrelated process (common on Windows) | The heartbeat has been stale for a very long time, so the lock is still classified as a leftover one | Same fix — a heartbeat that has not been refreshed for far longer than the stale window is now reclaimable |
399
400
 
400
401
  ```bash
401
402
  # safe: it inspects first and refuses unless the owner is proven dead (a live lock is never touched)
402
403
  dsh-config-manager recover-stale-lock
403
404
  ```
404
405
 
405
- **Plugins installed but the backup doesn't see them?** Check **Settings → DSH Config Manager → About**: it now shows which directory / profile the plugin list was read from, and how many plugins were detected. The list comes from `$DSH_HOME/profiles/<profile>/package.json` → `dependencies`, where `<profile>` is resolved as `config.profile` → `--profile` → `web`. If the shown path is not the profile you installed into (Desktop builds may use a different profile or a different `DSH_HOME`), that is the cause — align `--profile` / `DSH_HOME` with it.
406
+ **Plugins installed but the backup doesn't see them?** Check **Settings → DSH Config Manager → About**: it now shows which directory / profile the plugin list was read from, and how many plugins were detected. The list comes from `$DSH_HOME/profiles/<profile>/package.json` → `dependencies` (plus anything declared in `dsh.profile.bundles` that is not a dependency), where `<profile>` is resolved as `config.profile` → `--profile` → `web`. If the shown path is not the profile you installed into (Desktop builds may use a different profile or a different `DSH_HOME`), that is the cause — align `--profile` / `DSH_HOME` with it.
406
407
 
407
408
  ### 🌐 Behind a proxy? (GitHub login / sync)
408
409
 
@@ -11,17 +11,21 @@ import path from 'node:path';
11
11
  import { sha256Hex } from '../utils/hashing.js';
12
12
  import { isReservedInternalRel, normalizePath } from '../utils/paths.js';
13
13
  import { msgOf, zhMsg } from '../core/messages.js';
14
+ import { linkWarnings, listFilesDetailed } from './link-report.js';
14
15
  export class FileCollectionAdapter {
15
16
  async export(ctx, _options) {
16
17
  const files = [];
17
18
  const warnings = [];
18
- let rels = [];
19
+ // issue #37:用「跟随 junction/符号链接」的遍历,并把跟随/跳过的链接写进告警——
20
+ // 此前链接目录及其全部内容被静默排除,备份仍报成功。
21
+ let listing = { paths: [], skippedLinks: [], followedLinks: 0, unreadableDirs: [] };
19
22
  try {
20
- rels = await ctx.fs.listRecursive(this.baseDir);
23
+ listing = await listFilesDetailed(ctx.fs, this.baseDir);
21
24
  }
22
25
  catch {
23
26
  // 目录不存在视为空
24
27
  }
28
+ const rels = listing.paths;
25
29
  for (const rel of rels) {
26
30
  const data = await ctx.fs.readFile(rel);
27
31
  const relPath = this.baseDir === '' ? rel : rel.replace(new RegExp(`^${escapeRegExp(this.baseDir)}[\\/]`), '');
@@ -29,6 +33,7 @@ export class FileCollectionAdapter {
29
33
  }
30
34
  if (rels.length === 0)
31
35
  warnings.push(msgOf(ctx)('adapter.dirEmpty', { type: this.displayName }));
36
+ warnings.push(...linkWarnings(msgOf(ctx), this.displayName, listing));
32
37
  return {
33
38
  sectionId: this.id,
34
39
  data: { version: 1, files },
@@ -0,0 +1,16 @@
1
+ /**
2
+ * 文件类分区的「链接目录」报告(issue #37)。
3
+ *
4
+ * 背景:备份静默跳过 junction/符号链接,用户拿到的仍是「成功」,却少了整块内容。
5
+ * 修复分两步:① `utils/recursive-walk.ts` 跟随链接收集内容;② 本模块把「跟随了什么、
6
+ * 跳过了什么、为什么跳过」变成备份报告里可见的告警 —— 缺了 ②,用户依然无从察觉缺失。
7
+ *
8
+ * 纯函数(除 listFilesDetailed 的 IO 委托外),两个 adapter 共用,避免文案再次漂移。
9
+ */
10
+ import type { FileSystemFacade } from '../core/types.ts';
11
+ import type { MsgFunc } from '../core/messages.ts';
12
+ import type { RecursiveListing } from '../utils/recursive-walk.ts';
13
+ /** 取遍历结果:宿主未实现 listRecursiveDetailed(旧版/测试 mock)时回退,行为与旧版一致。 */
14
+ export declare function listFilesDetailed(fs: FileSystemFacade, dir: string): Promise<RecursiveListing>;
15
+ /** 把遍历结果转成告警行(无链接时返回空数组 —— 不制造噪音)。 */
16
+ export declare function linkWarnings(msg: MsgFunc, type: string, listing: RecursiveListing): string[];
@@ -0,0 +1,47 @@
1
+ /** 跳过原因 → 消息 key(与 SkippedLink.reason 一一对应,穷尽映射) */
2
+ const REASON_KEY = {
3
+ loop: 'adapter.linkReason.loop',
4
+ 'outside-home': 'adapter.linkReason.outsideHome',
5
+ broken: 'adapter.linkReason.broken',
6
+ unreadable: 'adapter.linkReason.unreadable',
7
+ 'too-deep': 'adapter.linkReason.tooDeep',
8
+ };
9
+ /** 单条原因下最多列出的路径数(其余折叠为计数,避免把报告撑爆) */
10
+ const MAX_PATHS_PER_REASON = 5;
11
+ /** 取遍历结果:宿主未实现 listRecursiveDetailed(旧版/测试 mock)时回退,行为与旧版一致。 */
12
+ export async function listFilesDetailed(fs, dir) {
13
+ if (fs.listRecursiveDetailed !== undefined)
14
+ return fs.listRecursiveDetailed(dir);
15
+ return { paths: await fs.listRecursive(dir), skippedLinks: [], followedLinks: 0, unreadableDirs: [] };
16
+ }
17
+ /** 把遍历结果转成告警行(无链接时返回空数组 —— 不制造噪音)。 */
18
+ export function linkWarnings(msg, type, listing) {
19
+ const out = [];
20
+ if (listing.followedLinks > 0) {
21
+ out.push(msg('adapter.linksFollowed', { type, count: String(listing.followedLinks) }));
22
+ }
23
+ if (listing.skippedLinks.length > 0) {
24
+ const byReason = new Map();
25
+ for (const s of listing.skippedLinks) {
26
+ const arr = byReason.get(s.reason) ?? [];
27
+ arr.push(s.path);
28
+ byReason.set(s.reason, arr);
29
+ }
30
+ const detail = [...byReason.entries()].map(([reason, paths]) => {
31
+ const shown = paths.slice(0, MAX_PATHS_PER_REASON).join(', ');
32
+ const more = paths.length > MAX_PATHS_PER_REASON ? ` (+ ${paths.length - MAX_PATHS_PER_REASON})` : '';
33
+ return `${msg(REASON_KEY[reason])} ${paths.length} 个: ${shown}${more}`;
34
+ }).join(';');
35
+ out.push(msg('adapter.linksSkipped', { type, count: String(listing.skippedLinks.length), detail }));
36
+ }
37
+ if (listing.unreadableDirs.length > 0) {
38
+ const shown = listing.unreadableDirs.slice(0, MAX_PATHS_PER_REASON).join(', ');
39
+ const more = listing.unreadableDirs.length > MAX_PATHS_PER_REASON
40
+ ? ` (+ ${listing.unreadableDirs.length - MAX_PATHS_PER_REASON})` : '';
41
+ out.push(msg('adapter.dirsUnreadable', {
42
+ type, count: String(listing.unreadableDirs.length), detail: `${shown}${more}`,
43
+ }));
44
+ }
45
+ return out;
46
+ }
47
+ //# sourceMappingURL=link-report.js.map
@@ -7,7 +7,8 @@
7
7
  * 导入时按同一相对路径写回原位置。默认关闭(defaultIncluded=false,用户显式勾选才导出)。
8
8
  */
9
9
  import { sha256Hex } from '../utils/hashing.js';
10
- import { zhMsg } from '../core/messages.js';
10
+ import { msgOf, zhMsg } from '../core/messages.js';
11
+ import { linkWarnings, listFilesDetailed } from './link-report.js';
11
12
  import { isPathSafe, isReservedInternalRel } from '../utils/paths.js';
12
13
  export const DEFAULT_PLUGIN_FILE_WHITELIST = ['dsh-ssh.json', 'pet.json'];
13
14
  export class PluginFilesAdapter {
@@ -40,14 +41,16 @@ export class PluginFilesAdapter {
40
41
  }
41
42
  }
42
43
  // 2) 约定配置目录递归收集(相对 ~/.dsh 根的完整路径;与白名单文件去重)
44
+ // issue #37:与 skills 等同一条遍历(跟随 junction/符号链接 + 跳过留痕)
45
+ let listing = { paths: [], skippedLinks: [], followedLinks: 0, unreadableDirs: [] };
43
46
  if (this.collectDir !== undefined) {
44
- let rels = [];
45
47
  try {
46
- rels = await ctx.fs.listRecursive(this.collectDir);
48
+ listing = await listFilesDetailed(ctx.fs, this.collectDir);
47
49
  }
48
50
  catch {
49
51
  // 目录不存在视为空
50
52
  }
53
+ const rels = listing.paths;
51
54
  for (const rel of rels) {
52
55
  if (seen.has(rel))
53
56
  continue;
@@ -64,7 +67,7 @@ export class PluginFilesAdapter {
64
67
  sectionId: 'pluginFiles',
65
68
  data: { version: 1, files },
66
69
  counts: { files: files.length },
67
- warnings: [],
70
+ warnings: linkWarnings(msgOf(ctx), this.displayName, listing),
68
71
  };
69
72
  }
70
73
  async analyzeImport(data, ctx) {
@@ -12,8 +12,12 @@ export type LocalPluginPackHook = (plugins: PluginEntry[], ctx: HostContext) =>
12
12
  export declare const LOCAL_TARBALL_CACHE_REL = "dsh-config-manager/local-plugins";
13
13
  /** 归档内相对路径 → 缓存目录下的安全文件名(拒绝任何路径穿越)。 */
14
14
  export declare function localTarballCacheName(relativePath: string): string;
15
+ /** profile 目录相对 $DSH_HOME 的路径(patches/ 与 pnpm-workspace.yaml 都挂在这里)。 */
16
+ export declare const PNPM_PROFILE_DIR: (profile: string | undefined) => string;
15
17
  /** pnpm-workspace.yaml 相对 $DSH_HOME 的路径(plugins 分区内按「插件安装配置」管理)。 */
16
18
  export declare const PNPM_WORKSPACE_REL: (profile: string | undefined) => string;
19
+ /** 单个 patch 文件随备份迁移的体积上限(issue #35;patch 是纯文本,超过说明放错了东西)。 */
20
+ export declare const MAX_PATCH_FILE_BYTES: number;
17
21
  export declare class PluginsAdapter implements ConfigAdapter<PluginsSection> {
18
22
  readonly id: 'plugins';
19
23
  readonly displayName = "Plugins";
@@ -25,6 +29,12 @@ export declare class PluginsAdapter implements ConfigAdapter<PluginsSection> {
25
29
  private readonly localPack;
26
30
  constructor(selfName?: string, localPack?: LocalPluginPackHook);
27
31
  export(ctx: HostContext, _options: ExportOptions): Promise<ExportSection<PluginsSection>>;
32
+ /**
33
+ * 导入后目标机**可能**存在的 patch 文件集合(相对 profile 目录)——issue #35。
34
+ * = 备份携带的 patchFiles ∪ 目标机本来就有的文件。只用于判定「声明能否被满足」,
35
+ * 不做任何写入(写入在 applyItem 内、且先于 pnpm-workspace.yaml)。
36
+ */
37
+ private patchAvailability;
28
38
  analyzeImport(data: PluginsSection, ctx: ImportContext): Promise<PlanItem[]>;
29
39
  applyItem(item: PlanItem, ctx: ImportContext): Promise<ApplyResult>;
30
40
  validate(data: PluginsSection, msg?: MsgFunc): Promise<ValidationResult>;
@@ -11,9 +11,13 @@
11
11
  * **不新增分区 id**);导入时把 spec 重写为 `file:<解包后的绝对路径>` 再交给官方安装通道。
12
12
  */
13
13
  import { isDeepStrictEqual } from 'node:util';
14
+ import { sha256Hex } from '../utils/hashing.js';
14
15
  import { installSpecFor, resolveProfileNameFromArgv } from '../core/plugin-cli.js';
15
16
  import { isLocalPluginSpec, isPackedLocalSpec, LOCAL_PLUGIN_DIR } from '../core/local-plugin-pack.js';
16
17
  import { msgOf, zhMsg } from '../core/messages.js';
18
+ import { isPathSafe, normalizePath } from '../utils/paths.js';
19
+ import { PLUGIN_PATCH_REF_PREFIX } from '../core/backup.js';
20
+ import { parsePnpmPatchedDependencies, sanitizePnpmWorkspacePatches } from './pnpm-workspace.js';
17
21
  export const USER_PATCH_FILE = 'cordis.patch.yml';
18
22
  /** tarball 字节 → base64(零依赖,避免 Buffer 在浏览器侧类型问题) */
19
23
  function bytesToBase64(data) {
@@ -64,8 +68,12 @@ async function writeLocalTarball(ctx, tarball) {
64
68
  const abs = `${ctx.target.homeDir.replace(/[\\/]+$/, '')}/${rel}`;
65
69
  return abs.replace(/\\/g, '/');
66
70
  }
71
+ /** profile 目录相对 $DSH_HOME 的路径(patches/ 与 pnpm-workspace.yaml 都挂在这里)。 */
72
+ export const PNPM_PROFILE_DIR = (profile) => `profiles/${profile !== undefined && profile !== '' ? profile : 'web'}`;
67
73
  /** pnpm-workspace.yaml 相对 $DSH_HOME 的路径(plugins 分区内按「插件安装配置」管理)。 */
68
- export const PNPM_WORKSPACE_REL = (profile) => `profiles/${profile !== undefined && profile !== '' ? profile : 'web'}/pnpm-workspace.yaml`;
74
+ export const PNPM_WORKSPACE_REL = (profile) => `${PNPM_PROFILE_DIR(profile)}/pnpm-workspace.yaml`;
75
+ /** 单个 patch 文件随备份迁移的体积上限(issue #35;patch 是纯文本,超过说明放错了东西)。 */
76
+ export const MAX_PATCH_FILE_BYTES = 2 * 1024 * 1024;
69
77
  /** patch 行 raw 是否由其他 adapter 管理(mcp-client 行 / systemPrompt / planMode 行)。
70
78
  * 这些行的导入归 mcp.ts / prompts.ts,plugins 分区只负责普通用户行(启用/禁用/插入插件等)。 */
71
79
  function isManagedElsewhere(raw) {
@@ -155,6 +163,46 @@ export class PluginsAdapter {
155
163
  catch (err) {
156
164
  warnings.push(msgOf(ctx)('adapter.pnpmReadFailed', { reason: err instanceof Error ? err.message : String(err) }));
157
165
  }
166
+ // issue #35:`patchedDependencies` 引用的 patches/** 必须与声明同进同出。
167
+ // 只搬 pnpm-workspace.yaml 文本会让目标机拿到「声明在、文件不在」的组合,
168
+ // 此后 pnpm 拒绝**一切** add(含插件安装)——实测 13/13 插件安装全灭。
169
+ let patchFiles;
170
+ if (pnpmWorkspace !== null && pnpmWorkspace !== '') {
171
+ const parsed = parsePnpmPatchedDependencies(pnpmWorkspace);
172
+ if (parsed.unsupported !== null) {
173
+ warnings.push(msgOf(ctx)('adapter.pwPatchesUnsupported', { line: parsed.unsupported }));
174
+ }
175
+ const collected = [];
176
+ for (const decl of parsed.declared) {
177
+ const rel = normalizePath(decl.path);
178
+ if (rel === '' || !isPathSafe(rel)) {
179
+ warnings.push(msgOf(ctx)('adapter.patchFileUnsafe', { name: decl.name, path: decl.path }));
180
+ continue;
181
+ }
182
+ const homeRel = `${PNPM_PROFILE_DIR(ctx.profile)}/${rel}`;
183
+ try {
184
+ if (!(await ctx.fs.exists(homeRel))) {
185
+ warnings.push(msgOf(ctx)('adapter.patchFileMissing', { name: decl.name, path: rel }));
186
+ continue;
187
+ }
188
+ const data = await ctx.fs.readFile(homeRel);
189
+ if (data.byteLength > MAX_PATCH_FILE_BYTES) {
190
+ warnings.push(msgOf(ctx)('adapter.patchFileTooLarge', {
191
+ name: decl.name, path: rel, limit: `${Math.floor(MAX_PATCH_FILE_BYTES / 1024)} KiB`,
192
+ }));
193
+ continue;
194
+ }
195
+ collected.push({ relativePath: rel, base64: bytesToBase64(data) });
196
+ }
197
+ catch (err) {
198
+ warnings.push(msgOf(ctx)('adapter.patchFileReadFailed', {
199
+ name: decl.name, path: rel, reason: err instanceof Error ? err.message : String(err),
200
+ }));
201
+ }
202
+ }
203
+ if (collected.length > 0)
204
+ patchFiles = collected;
205
+ }
158
206
  // T1:本地源(link:/file:)插件打包。这些 spec 指向本机路径,换机后必然不可达
159
207
  // (曾导致插件被静默丢失)。钩子由宿主注入;未注入 / 无本地源 / 单个失败一律不中断导出。
160
208
  let localTarballs;
@@ -188,21 +236,88 @@ export class PluginsAdapter {
188
236
  patch,
189
237
  pnpmWorkspace,
190
238
  ...(localTarballs !== undefined ? { localTarballs } : {}),
239
+ ...(patchFiles !== undefined ? { patchFiles } : {}),
191
240
  },
192
241
  counts: {
193
242
  plugins: effectivePlugins.length,
194
243
  patchLines: patch.length,
195
244
  ...(localTarballs !== undefined ? { localTarballs: localTarballs.length } : {}),
245
+ ...(patchFiles !== undefined ? { patchFiles: patchFiles.length } : {}),
196
246
  },
197
247
  warnings,
198
248
  };
199
249
  }
250
+ /**
251
+ * 导入后目标机**可能**存在的 patch 文件集合(相对 profile 目录)——issue #35。
252
+ * = 备份携带的 patchFiles ∪ 目标机本来就有的文件。只用于判定「声明能否被满足」,
253
+ * 不做任何写入(写入在 applyItem 内、且先于 pnpm-workspace.yaml)。
254
+ */
255
+ async patchAvailability(data, ctx) {
256
+ const set = new Set();
257
+ for (const pf of data.patchFiles ?? []) {
258
+ const rel = normalizePath(pf.relativePath);
259
+ if (rel !== '' && isPathSafe(rel))
260
+ set.add(rel);
261
+ }
262
+ const profileDir = PNPM_PROFILE_DIR(ctx.target.profile);
263
+ for (const decl of parsePnpmPatchedDependencies(data.pnpmWorkspace ?? '').declared) {
264
+ const rel = normalizePath(decl.path);
265
+ if (rel === '' || !isPathSafe(rel) || set.has(rel))
266
+ continue;
267
+ try {
268
+ if (await ctx.target.fs.exists(`${profileDir}/${rel}`))
269
+ set.add(rel);
270
+ }
271
+ catch { /* 读不到 = 视为不存在 */ }
272
+ }
273
+ return set;
274
+ }
200
275
  async analyzeImport(data, ctx) {
201
276
  const msg = ctx.msg;
202
277
  const items = [];
203
278
  // pnpm-workspace.yaml:先于插件安装写入(allowBuilds / minimumReleaseAgeExclude 需在
204
279
  // pnpm add 时生效)。与目标不同 → Create/Update;无文件/内容一致 → Skip。
205
280
  if (data.pnpmWorkspace !== undefined && data.pnpmWorkspace !== null && data.pnpmWorkspace !== '') {
281
+ // issue #35:patch 文件项必须**先于** pnpm-workspace.yaml。
282
+ // 原因有二:① 配置里的 patchedDependencies 只有文件已就位才可用,先写配置会留下
283
+ // 「声明在、文件未到」的窗口(中途中断 → 目标机 pnpm 从此拒绝一切 add);
284
+ // ② 它们是独立计划项(可被用户 keepCurrent 否决),配置项的写入不得越权替它们落盘。
285
+ // 作为计划项还有第三个作用:进入导入前快照(回滚可还原被覆盖的原文件)。
286
+ for (const pf of data.patchFiles ?? []) {
287
+ const rel = normalizePath(pf.relativePath);
288
+ if (rel === '' || !isPathSafe(rel))
289
+ continue;
290
+ const id = `plugins:patch:${rel}`;
291
+ const abs = `${PNPM_PROFILE_DIR(ctx.target.profile)}/${rel}`;
292
+ let current = null;
293
+ try {
294
+ current = await ctx.target.fs.exists(abs) ? await ctx.target.fs.readFile(abs) : null;
295
+ }
296
+ catch {
297
+ current = null;
298
+ }
299
+ const target = { adapter: 'plugins', ref: `${PLUGIN_PATCH_REF_PREFIX}${rel}` };
300
+ if (current === null) {
301
+ items.push({
302
+ id, kind: 'Create', adapter: 'plugins',
303
+ description: msg('adapter.patchFileCreate', { path: rel }), severity: 'info', target,
304
+ });
305
+ }
306
+ else if (sha256Hex(current) === sha256Hex(base64ToBytes(pf.base64))) {
307
+ items.push({ id, kind: 'Skip', adapter: 'plugins', description: msg('adapter.fileSame', { path: rel }), severity: 'info' });
308
+ }
309
+ else {
310
+ items.push({
311
+ id, kind: 'Conflict', adapter: 'plugins',
312
+ description: msg('adapter.fileDiff', { path: rel }), severity: 'warning', target,
313
+ });
314
+ }
315
+ }
316
+ // issue #35:目标机满足不了的 patchedDependencies 声明必须剔除——否则写进去之后,
317
+ // 目标机 pnpm 会因为「patch 文件读不到」拒绝**一切** add(含本次要装的插件)。
318
+ const available = await this.patchAvailability(data, ctx);
319
+ const clean = sanitizePnpmWorkspacePatches(data.pnpmWorkspace, (rel) => available.has(rel));
320
+ const droppedNames = clean.dropped.map((d) => d.name).join(', ');
206
321
  let current = null;
207
322
  try {
208
323
  const rel = PNPM_WORKSPACE_REL(ctx.target.profile);
@@ -213,16 +328,38 @@ export class PluginsAdapter {
213
328
  catch {
214
329
  current = null;
215
330
  }
216
- if (current !== data.pnpmWorkspace) {
331
+ if (current !== clean.text) {
217
332
  items.push({
218
333
  id: 'plugins:pnpm-workspace',
219
334
  kind: current === null ? 'Create' : 'Update',
220
335
  adapter: 'plugins',
221
336
  description: current === null ? msg('adapter.pwCreate') : msg('adapter.pwUpdate'),
337
+ ...(clean.dropped.length > 0
338
+ ? { detail: msg('adapter.pwPatchesDroppedDetail', { count: String(clean.dropped.length), names: droppedNames }) }
339
+ : {}),
222
340
  severity: 'info',
223
341
  target: { adapter: 'plugins', ref: 'pnpm-workspace.yaml' },
224
342
  });
225
343
  }
344
+ // 剔除是**可见**的信息项(不静默改变用户配置语义)
345
+ if (clean.dropped.length > 0) {
346
+ items.push({
347
+ id: 'plugins:pnpm-workspace-dropped',
348
+ kind: 'Warning',
349
+ adapter: 'plugins',
350
+ description: msg('adapter.pwPatchesDropped', { count: String(clean.dropped.length), names: droppedNames }),
351
+ severity: 'warning',
352
+ });
353
+ }
354
+ if (clean.unsupported !== null) {
355
+ items.push({
356
+ id: 'plugins:pnpm-workspace-flow',
357
+ kind: 'Warning',
358
+ adapter: 'plugins',
359
+ description: msg('adapter.pwPatchesUnsupported', { line: clean.unsupported }),
360
+ severity: 'warning',
361
+ });
362
+ }
226
363
  }
227
364
  // 插件:包名唯一键;同版本 Skip / 未装 Install / 版本不同 Conflict
228
365
  const targetInstalled = await ctx.target.plugins.listInstalled();
@@ -284,15 +421,65 @@ export class PluginsAdapter {
284
421
  const msg = ctx.msg;
285
422
  // pnpm-workspace.yaml:插件安装的 pnpm 配置(allowBuilds / minimumReleaseAgeExclude),
286
423
  // 必须先于任何插件安装写入,pnpm add 时才能生效。失败为非致命 warning。
424
+ // issue #35:patch 文件项(写入 profile 的 patches/ 目录;同时进入导入前快照,可回滚)
425
+ if (item.id.startsWith('plugins:patch:')) {
426
+ const ref = item.target?.ref;
427
+ if (ref === undefined || !ref.startsWith(PLUGIN_PATCH_REF_PREFIX)) {
428
+ return { ok: false, message: msg('adapter.missingTargetRef') };
429
+ }
430
+ const rel = normalizePath(ref.slice(PLUGIN_PATCH_REF_PREFIX.length));
431
+ if (rel === '' || !isPathSafe(rel)) {
432
+ return { ok: false, message: msg('adapter.patchFileUnsafe', { name: item.id, path: rel }) };
433
+ }
434
+ const data = ctx.sections.get('plugins');
435
+ const pf = data?.patchFiles?.find((p) => normalizePath(p.relativePath) === rel);
436
+ if (pf === undefined)
437
+ return { ok: false, message: msg('adapter.dataMissingFile', { ref: rel }) };
438
+ try {
439
+ await ctx.target.fs.writeFile(`${PNPM_PROFILE_DIR(ctx.target.profile)}/${rel}`, base64ToBytes(pf.base64));
440
+ return { ok: true };
441
+ }
442
+ catch (err) {
443
+ return {
444
+ ok: false,
445
+ warning: true,
446
+ message: msg('adapter.patchFileWriteFailed', { path: rel, reason: err instanceof Error ? err.message : String(err) }),
447
+ };
448
+ }
449
+ }
287
450
  if (item.id === 'plugins:pnpm-workspace') {
288
451
  const data = ctx.sections.get('plugins');
289
452
  const text = data?.pnpmWorkspace;
290
453
  if (text === undefined || text === null || text === '') {
291
454
  return { ok: false, message: msg('adapter.pwMissing') };
292
455
  }
456
+ // issue #35:判定「声明能否被满足」只看**此刻磁盘上的真实状态**——patch 文件项
457
+ // (`plugins:patch:*`)在计划里排在本项之前,已按用户决策落盘或保留原样。
458
+ // 这里**绝不**替它们写文件:那样会绕过用户在冲突项上的 keepCurrent 选择,
459
+ // 正是 issue #35 要消除的「静默覆盖」。
460
+ const profileDir = PNPM_PROFILE_DIR(ctx.target.profile);
461
+ const available = new Set();
462
+ for (const decl of parsePnpmPatchedDependencies(text).declared) {
463
+ const rel = normalizePath(decl.path);
464
+ if (rel === '' || !isPathSafe(rel) || available.has(rel))
465
+ continue;
466
+ try {
467
+ if (await ctx.target.fs.exists(`${profileDir}/${rel}`))
468
+ available.add(rel);
469
+ }
470
+ catch { /* 读不到 = 视为不存在 */ }
471
+ }
472
+ const clean = sanitizePnpmWorkspacePatches(text, (rel) => available.has(rel));
293
473
  try {
294
- await ctx.target.fs.writeFile(PNPM_WORKSPACE_REL(ctx.target.profile), new TextEncoder().encode(text));
295
- return { ok: true, needsRestart: true, message: msg('adapter.pwWritten') };
474
+ await ctx.target.fs.writeFile(PNPM_WORKSPACE_REL(ctx.target.profile), new TextEncoder().encode(clean.text));
475
+ const notes = [msg('adapter.pwWritten')];
476
+ if (clean.dropped.length > 0) {
477
+ notes.push(msg('adapter.pwWrittenPatchesDropped', {
478
+ count: String(clean.dropped.length),
479
+ names: clean.dropped.map((d) => d.name).join(', '),
480
+ }));
481
+ }
482
+ return { ok: true, needsRestart: true, message: notes.join(';') };
296
483
  }
297
484
  catch (err) {
298
485
  const reason = err instanceof Error ? err.message : String(err);
@@ -0,0 +1,26 @@
1
+ export interface PatchedDependency {
2
+ /** 依赖名(patchedDependencies 的键) */
3
+ name: string;
4
+ /** 声明的 patch 路径(相对 profile 目录,已去引号;原始写法保留) */
5
+ path: string;
6
+ }
7
+ export interface ParsePatchedResult {
8
+ declared: PatchedDependency[];
9
+ /** 无法安全改写的形态(如单行 flow 映射)→ 调用方据此告警,绝不猜着改 */
10
+ unsupported: string | null;
11
+ }
12
+ /** 解析 patchedDependencies 的声明(纯函数;不校验文件是否存在)。 */
13
+ export declare function parsePnpmPatchedDependencies(text: string): ParsePatchedResult;
14
+ export interface SanitizeResult {
15
+ /** 清理后的文本(无需修改时与入参逐字节相同) */
16
+ text: string;
17
+ declared: PatchedDependency[];
18
+ /** 被移除的条目(目标机没有对应 patch 文件) */
19
+ dropped: PatchedDependency[];
20
+ unsupported: string | null;
21
+ }
22
+ /**
23
+ * 删除 patchedDependencies 中「目标机不存在对应 patch 文件」的条目。
24
+ * @param available 判断某相对路径(相对 profile 目录)在**导入后**是否存在
25
+ */
26
+ export declare function sanitizePnpmWorkspacePatches(text: string, available: (relPath: string) => boolean): SanitizeResult;
@@ -0,0 +1,147 @@
1
+ /**
2
+ * pnpm-workspace.yaml 的 `patchedDependencies` 处理(issue #35)。
3
+ *
4
+ * 背景:plugins 分区会**整段搬运** pnpm-workspace.yaml,但 `patchedDependencies` 引用的
5
+ * `patches/*.patch` 文件不在任何被同步的分区里。目标机因此拿到「声明存在、文件不存在」的
6
+ * 组合,此后**任何** `pnpm add`(含 dsh plugin add)都失败于
7
+ * `Failed to read patch file ... (os error 2)` —— 用户实测 13/13 插件安装全灭。
8
+ *
9
+ * 本模块做两件事,且**不做整文件重写**(保留用户注释与格式):
10
+ * 1. `parsePnpmPatchedDependencies`:按行解析声明(供导出侧收集 patch 文件);
11
+ * 2. `sanitizePnpmWorkspacePatches`:把「目标机没有对应文件」的条目整行删掉
12
+ * (含其上方注释),全部被删时连键一起删 —— 宁可少迁移一条 patch 声明,
13
+ * 也不让目标机的 pnpm 从此拒绝一切安装。
14
+ */
15
+ import { isPathSafe, normalizePath } from '../utils/paths.js';
16
+ const KEY_RE = /^patchedDependencies\s*:(.*)$/;
17
+ const ENTRY_RE = /^([ \t]+)([^\s#][^:]*?)\s*:\s*(.*)$/;
18
+ /** 去引号 + 去行尾注释(保守:只在引号外找 ` #`) */
19
+ function cleanScalar(raw) {
20
+ let v = raw.trim();
21
+ if (v.startsWith("'") || v.startsWith('"')) {
22
+ const quote = v[0];
23
+ const end = v.indexOf(quote, 1);
24
+ if (end > 0)
25
+ return v.slice(1, end);
26
+ }
27
+ const hash = v.indexOf(' #');
28
+ if (hash >= 0)
29
+ v = v.slice(0, hash);
30
+ return v.trim();
31
+ }
32
+ /** 解析 patchedDependencies 的声明(纯函数;不校验文件是否存在)。 */
33
+ export function parsePnpmPatchedDependencies(text) {
34
+ const declared = [];
35
+ if (text === '')
36
+ return { declared, unsupported: null };
37
+ const lines = text.split(/\r?\n/);
38
+ for (let i = 0; i < lines.length; i++) {
39
+ const m = KEY_RE.exec(lines[i] ?? '');
40
+ if (m === null)
41
+ continue;
42
+ const inline = (m[1] ?? '').trim();
43
+ // 单行 flow 形态(patchedDependencies: {a: b})无法按行安全删条目 → 不改写,交给调用方告警
44
+ if (inline !== '' && !inline.startsWith('#')) {
45
+ return { declared, unsupported: lines[i] ?? '' };
46
+ }
47
+ for (let j = i + 1; j < lines.length; j++) {
48
+ const line = lines[j] ?? '';
49
+ if (line.trim() === '' || /^[ \t]*#/.test(line))
50
+ continue;
51
+ const e = ENTRY_RE.exec(line);
52
+ if (e === null)
53
+ break; // 缩进块结束(回到顶层键)
54
+ declared.push({ name: (e[2] ?? '').trim(), path: cleanScalar(e[3] ?? '') });
55
+ }
56
+ break; // patchedDependencies 只处理第一处(YAML 重复键本就非法)
57
+ }
58
+ return { declared, unsupported: null };
59
+ }
60
+ /**
61
+ * 删除 patchedDependencies 中「目标机不存在对应 patch 文件」的条目。
62
+ * @param available 判断某相对路径(相对 profile 目录)在**导入后**是否存在
63
+ */
64
+ export function sanitizePnpmWorkspacePatches(text, available) {
65
+ const parsed = parsePnpmPatchedDependencies(text);
66
+ if (parsed.unsupported !== null) {
67
+ return { text, declared: parsed.declared, dropped: [], unsupported: parsed.unsupported };
68
+ }
69
+ if (parsed.declared.length === 0)
70
+ return { text, declared: [], dropped: [], unsupported: null };
71
+ const droppedNames = new Set();
72
+ for (const d of parsed.declared) {
73
+ const rel = normalizePath(d.path);
74
+ // 越界/绝对路径的声明在目标机同样不可读(pnpm 也会拒绝)→ 一并移除
75
+ if (rel === '' || !isPathSafe(rel) || !available(rel))
76
+ droppedNames.add(d.name);
77
+ }
78
+ if (droppedNames.size === 0)
79
+ return { text, declared: parsed.declared, dropped: [], unsupported: null };
80
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
81
+ const lines = text.split(/\r?\n/);
82
+ const out = [];
83
+ let removed = 0;
84
+ for (let i = 0; i < lines.length; i++) {
85
+ const line = lines[i] ?? '';
86
+ const m = KEY_RE.exec(line);
87
+ if (m === null) {
88
+ out.push(line);
89
+ continue;
90
+ }
91
+ const inline = (m[1] ?? '').trim();
92
+ if (inline !== '' && !inline.startsWith('#')) {
93
+ out.push(line);
94
+ continue;
95
+ }
96
+ // 收集整块(键 + 缩进条目 + 其上的注释)
97
+ const block = [];
98
+ let j = i + 1;
99
+ for (; j < lines.length; j++) {
100
+ const l = lines[j] ?? '';
101
+ if (l.trim() === '' || /^[ \t]*#/.test(l)) {
102
+ block.push(l);
103
+ continue;
104
+ }
105
+ if (ENTRY_RE.exec(l) === null)
106
+ break;
107
+ block.push(l);
108
+ }
109
+ // 逐条判定:保留/删除(注释与空行随其**下一条**条目一起处置;尾部孤立注释一律保留)
110
+ const kept = [];
111
+ let pending = [];
112
+ let keptEntries = 0;
113
+ for (const bl of block) {
114
+ const e = ENTRY_RE.exec(bl);
115
+ if (e === null) {
116
+ pending.push(bl);
117
+ continue;
118
+ }
119
+ const name = (e[2] ?? '').trim();
120
+ if (droppedNames.has(name)) {
121
+ removed += 1;
122
+ pending = [];
123
+ continue;
124
+ }
125
+ kept.push(...pending, bl);
126
+ pending = [];
127
+ keptEntries += 1;
128
+ }
129
+ kept.push(...pending);
130
+ if (keptEntries > 0) {
131
+ out.push(line); // 键保留
132
+ out.push(...kept);
133
+ }
134
+ else {
135
+ removed += 1; // 键本身也删(没有任何条目留下)
136
+ }
137
+ i = j - 1;
138
+ }
139
+ // 全部条目都没了 → 键与块一起消失;否则写回(保留原 EOL)
140
+ return {
141
+ text: out.join(eol),
142
+ declared: parsed.declared,
143
+ dropped: parsed.declared.filter((d) => droppedNames.has(d.name)),
144
+ unsupported: null,
145
+ };
146
+ }
147
+ //# sourceMappingURL=pnpm-workspace.js.map
@@ -4,6 +4,8 @@ import type { CredentialsFacade, FileSystemFacade, HostContext, ImportContext, N
4
4
  import type { Manifest, WorkspaceRecord } from '../schema/types.ts';
5
5
  export declare class MemFs implements FileSystemFacade {
6
6
  files: Map<string, Uint8Array<ArrayBufferLike>>;
7
+ /** 可选(issue #37):详细的链接遍历。测试按需注入;缺省走 listRecursive 回退路径。 */
8
+ listRecursiveDetailed?: FileSystemFacade['listRecursiveDetailed'];
7
9
  private readonly homeDir;
8
10
  constructor(homeDir: string);
9
11
  private key;
@@ -8,6 +8,8 @@ import { sha256Hex } from '../utils/hashing.js';
8
8
  import { zhMsg } from '../core/messages.js';
9
9
  export class MemFs {
10
10
  files = new Map();
11
+ /** 可选(issue #37):详细的链接遍历。测试按需注入;缺省走 listRecursive 回退路径。 */
12
+ listRecursiveDetailed;
11
13
  homeDir;
12
14
  constructor(homeDir) {
13
15
  this.homeDir = homeDir;