dsh-config-manager 0.1.76 → 0.1.78

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 (84) hide show
  1. package/lib/adapters/plugin-data-declared.d.ts +12 -0
  2. package/lib/adapters/plugin-data-declared.js +57 -0
  3. package/lib/adapters/plugin-data.d.ts +4 -6
  4. package/lib/adapters/plugin-data.js +8 -36
  5. package/lib/adapters/plugins.d.ts +16 -0
  6. package/lib/adapters/plugins.js +223 -16
  7. package/lib/client.d.ts +104 -9
  8. package/lib/client.js +1800 -718
  9. package/lib/core/analyzer.js +3 -1
  10. package/lib/core/backup.d.ts +9 -0
  11. package/lib/core/backup.js +16 -1
  12. package/lib/core/messages.d.ts +11 -0
  13. package/lib/core/messages.js +22 -0
  14. package/lib/core/plugin-cli.d.ts +26 -0
  15. package/lib/core/plugin-cli.js +62 -2
  16. package/lib/core/rollback-attribution.d.ts +81 -0
  17. package/lib/core/rollback-attribution.js +236 -0
  18. package/lib/core/rollback-merge.d.ts +22 -0
  19. package/lib/core/rollback-merge.js +203 -0
  20. package/lib/core/rollback-point.d.ts +114 -0
  21. package/lib/core/rollback-point.js +534 -17
  22. package/lib/core/types.d.ts +28 -0
  23. package/lib/index.d.ts +26 -3
  24. package/lib/index.js +129 -16
  25. package/lib/routes/recovery.js +11 -0
  26. package/lib/schema/section-registry.js +5 -5
  27. package/lib/schema/types.d.ts +14 -0
  28. package/lib/ui/badge-tone.d.ts +16 -0
  29. package/lib/ui/badge-tone.js +20 -0
  30. package/lib/ui/i18n.d.ts +4 -4
  31. package/lib/ui/i18n.js +8 -8
  32. package/lib/ui/rollback-view.d.ts +95 -0
  33. package/lib/ui/rollback-view.js +55 -0
  34. package/lib/ui/selection-model.d.ts +140 -1
  35. package/lib/ui/selection-model.js +241 -9
  36. package/package.json +1 -1
  37. package/src/adapters/plugin-data-declared.ts +60 -0
  38. package/src/adapters/plugin-data.ts +7 -33
  39. package/src/adapters/plugins.test.ts +162 -0
  40. package/src/adapters/plugins.ts +236 -17
  41. package/src/adapters/units.test.ts +26 -0
  42. package/src/client/chat/ChatEntryView.tsx +147 -46
  43. package/src/client/chat/chat-entry-view.test.ts +277 -1
  44. package/src/client/chat/chat-entry-view.ts +98 -4
  45. package/src/client/chat/chat-node-registration.ts +71 -0
  46. package/src/client/chat/chat-slots.ts +30 -12
  47. package/src/client/common/ContentPicker.tsx +581 -175
  48. package/src/client/common/host-primitives.ts +64 -1
  49. package/src/client/common/ui.tsx +17 -3
  50. package/src/client/config-manager.module.css +630 -397
  51. package/src/client/css-scale-guard.test.ts +41 -3
  52. package/src/client/css-token-guard.test.ts +10 -4
  53. package/src/client/export/ExportView.tsx +2 -0
  54. package/src/client/index.ts +15 -3
  55. package/src/client/locales.ts +54 -10
  56. package/src/client/recovery/RollbackCard.tsx +56 -2
  57. package/src/client/recovery/RollbackDiffPane.tsx +5 -0
  58. package/src/client/recovery/RollbackFileChanges.tsx +46 -2
  59. package/src/client/recovery/recovery-locales.ts +22 -0
  60. package/src/client/recovery/rollback-file-changes.test.ts +1 -1
  61. package/src/core/analyzer.ts +3 -1
  62. package/src/core/backup.ts +16 -1
  63. package/src/core/messages.ts +22 -0
  64. package/src/core/plugin-cli.fs.test.ts +65 -3
  65. package/src/core/plugin-cli.ts +55 -2
  66. package/src/core/rollback-attribution.test.ts +120 -0
  67. package/src/core/rollback-attribution.ts +267 -0
  68. package/src/core/rollback-merge.test.ts +133 -0
  69. package/src/core/rollback-merge.ts +213 -0
  70. package/src/core/rollback-point.test.ts +412 -0
  71. package/src/core/rollback-point.ts +560 -20
  72. package/src/core/types.ts +29 -0
  73. package/src/index.facade.test.ts +60 -0
  74. package/src/index.ts +133 -19
  75. package/src/routes/recovery.ts +11 -0
  76. package/src/schema/section-registry.ts +5 -5
  77. package/src/schema/types.ts +14 -0
  78. package/src/ui/badge-tone.test.ts +23 -0
  79. package/src/ui/badge-tone.ts +27 -0
  80. package/src/ui/i18n.ts +8 -8
  81. package/src/ui/rollback-view.test.ts +27 -0
  82. package/src/ui/rollback-view.ts +139 -0
  83. package/src/ui/selection-model.test.ts +240 -2
  84. package/src/ui/selection-model.ts +349 -1
@@ -13,7 +13,7 @@
13
13
  import { join } from 'node:path';
14
14
  import { isDeepStrictEqual } from 'node:util';
15
15
  import { sha256Hex } from '../utils/hashing.ts';
16
- import { installSpecFor, resolveProcessProfileName } from '../core/plugin-cli.ts';
16
+ import { installSpecFor, isBundlesOnlyEntry, mergeBundleDeclaration, resolveProcessProfileName } from '../core/plugin-cli.ts';
17
17
  import { classifyPluginSpec, LOCAL_PLUGIN_DIR, resolveLocalPluginPath } from '../core/local-plugin-pack.ts';
18
18
  import type { PackLocalPluginsResult } from '../core/local-plugin-pack.ts';
19
19
  import { msgOf, zhMsg } from '../core/messages.ts';
@@ -22,14 +22,17 @@ import { engineMsg } from './messages.ts';
22
22
  import { stringifyJsonSafe } from '../utils/json.ts';
23
23
  import { isPathSafe, normalizePath } from '../utils/paths.ts';
24
24
  import { isENOENT } from '../utils/guards.ts';
25
- import { PLUGIN_PATCH_REF_PREFIX } from '../core/backup.ts';
25
+ import { PLUGIN_PATCH_REF_PREFIX, PLUGIN_PROFILE_MANIFEST_REF } from '../core/backup.ts';
26
26
  import { readEffectivePatchLines, resolveWriteLayer, USER_PATCH_FILE } from '../core/patch-layers.ts';
27
27
  import { parsePnpmPatchedDependencies, sanitizePnpmWorkspacePatches } from './pnpm-workspace.ts';
28
28
  import type { LocalPluginTarball, PatchLine, PluginEntry, PluginsSection, PnpmPatchFile } from '../schema/types.ts';
29
29
  import type {
30
30
  ApplyResult, ConfigAdapter, ExportOptions, ExportSection, ExportUnit, HostContext,
31
- ImportContext, PlanItem, SectionPreview, ValidationResult,
31
+ ImportContext, PlanItem, PluginAreaRef, SectionPreview, ValidationResult,
32
32
  } from '../core/types.ts';
33
+ import { BUILTIN_PLUGIN_DATA_SOURCES, areasForSection } from '../core/plugin-data-registry.ts';
34
+ import type { PluginDataSource } from '../core/plugin-data-registry.ts';
35
+ import { declaredProfileName, scanDeclaredDataSources } from './plugin-data-declared.ts';
33
36
  import { sectionMeta } from '../schema/section-registry.ts';
34
37
  import { validateJsonSection } from './json-section.ts';
35
38
 
@@ -156,6 +159,68 @@ function entriesOfRaw(raw: unknown): { config?: unknown }[] {
156
159
  */
157
160
  const previewTarballBytes = new WeakMap<object, Map<string, number>>();
158
161
 
162
+ /**
163
+ * 「插件包名 → 它声明的数据区域」映射(只有 \`plugin:<包名>\` 单元用;导出选择器「按插件看」的树骨架)。
164
+ *
165
+ * 为什么挂 WeakMap 而不是给 PluginsSection 加字段:这是**展示用旁路信息** —— 既不是备份格式的
166
+ * 一部分(schema 校验与市场防线都不该看见它),也与「导出真的带什么」无关;挂在载荷对象上随对象
167
+ * 一起回收,载荷本身逐字节不变(与 previewTarballBytes 同一套理由)。
168
+ */
169
+ const relatedAreasByData = new WeakMap<object, Map<string, PluginAreaRef[]>>();
170
+
171
+ /** 内置来源 + 自述声明的扫描缓存(TTL 内的同一 home/profile 只扫一次;/export-preview 一趟会问两次) */
172
+ const declaredSourcesMemo: { key: string; at: number; sources: readonly PluginDataSource[] } =
173
+ { key: '', at: 0, sources: [] };
174
+ const DECLARED_MEMO_TTL_MS = 5_000;
175
+
176
+ /**
177
+ * 本机可归属的插件数据来源 = 内置来源表 + 已安装插件自述声明。
178
+ *
179
+ * 缓存键必须带 homeDir + profile:同一台机器换 profile 就是另一批已安装插件,拿旧声明画树
180
+ * 会让用户看到不属于当前 profile 的数据区域。
181
+ */
182
+ async function pluginDataSources(ctx: HostContext): Promise<readonly PluginDataSource[]> {
183
+ const key = ctx.homeDir + '|' + declaredProfileName(ctx.profile);
184
+ const now = Date.now();
185
+ if (declaredSourcesMemo.key === key && now - declaredSourcesMemo.at < DECLARED_MEMO_TTL_MS) {
186
+ return declaredSourcesMemo.sources;
187
+ }
188
+ const sources = [...BUILTIN_PLUGIN_DATA_SOURCES, ...await scanDeclaredDataSources(ctx)];
189
+ declaredSourcesMemo.key = key;
190
+ declaredSourcesMemo.at = now;
191
+ declaredSourcesMemo.sources = sources;
192
+ return sources;
193
+ }
194
+
195
+ /**
196
+ * 包名 → 该插件的数据区域引用。
197
+ *
198
+ * 口径与 pluginData / pluginAssets 的收集面**同源**(同一个 areasForSection):role=config 进
199
+ * pluginData、role=assets 进 pluginAssets;role=cache 的区域不属于任何分区,因此**绝不进树**
200
+ * (缓存是可再生产物,列出来只会诱导用户把它带走)。
201
+ *
202
+ * 只产出「注册表说它应该在哪儿」,不代表磁盘上真有 —— 界面必须把两者分开渲染。
203
+ */
204
+ async function relatedAreasOf(ctx: HostContext): Promise<Map<string, PluginAreaRef[]>> {
205
+ const sources = await pluginDataSources(ctx);
206
+ const map = new Map<string, PluginAreaRef[]>();
207
+ for (const source of sources) {
208
+ const refs: PluginAreaRef[] = [];
209
+ for (const section of ['pluginData', 'pluginAssets'] as const) {
210
+ for (const entry of areasForSection([source], section)) {
211
+ refs.push({ section, relPath: entry.area.relPath, role: entry.area.role === 'assets' ? 'assets' : 'config' });
212
+ }
213
+ }
214
+ if (refs.length === 0) continue;
215
+ for (const pkg of source.packages) {
216
+ const cur = map.get(pkg);
217
+ if (cur === undefined) map.set(pkg, [...refs]);
218
+ else cur.push(...refs);
219
+ }
220
+ }
221
+ return map;
222
+ }
223
+
159
224
  /**
160
225
  * 零 spawn 推导本地源插件的体积(预览专用)。
161
226
  *
@@ -208,7 +273,11 @@ async function measureLocalSources(plugins: PluginEntry[], ctx: HostContext): Pr
208
273
  * `tarballBytes` = 预览路径登记到的本地源体积(见 measureLocalSources,0 = 体积未知但仍随包携带);
209
274
  * 缺省时体积与文件数完全按导出产物里的 base64 计算 —— 与改造前逐字一致。
210
275
  */
211
- function unitsOf(data: PluginsSection, tarballBytes?: Map<string, number>): ExportUnit[] {
276
+ function unitsOf(
277
+ data: PluginsSection,
278
+ tarballBytes?: Map<string, number>,
279
+ related?: Map<string, PluginAreaRef[]>,
280
+ ): ExportUnit[] {
212
281
  const wsId = 'plugins:pnpm-workspace';
213
282
  const patchFileIds = (data.patchFiles ?? []).map((pf) => `plugins:patch:${normalizePath(pf.relativePath)}`);
214
283
  const group = [wsId, ...patchFileIds];
@@ -216,6 +285,7 @@ function unitsOf(data: PluginsSection, tarballBytes?: Map<string, number>): Expo
216
285
  for (const pl of data.plugins) {
217
286
  const tarball = tarballOf(data, pl.name);
218
287
  const measured = tarballBytes?.get(pl.name);
288
+ const areas = related?.get(pl.name);
219
289
  units.push({
220
290
  id: `plugin:${pl.name}`,
221
291
  label: pl.name,
@@ -223,6 +293,8 @@ function unitsOf(data: PluginsSection, tarballBytes?: Map<string, number>): Expo
223
293
  // base64 长度 → 原始字节数(仅用于展示体积,不求精确);预览路径没有字节,用 stat 量到的源体积
224
294
  sizeBytes: tarball !== undefined ? Math.floor((tarball.base64.length * 3) / 4) : measured ?? 0,
225
295
  ...(tarball !== undefined || measured !== undefined ? { fileCount: 1 } : {}),
296
+ // 「按插件看」的树骨架:该插件声明的数据区域(零数据目录 I/O)
297
+ ...(areas !== undefined && areas.length > 0 ? { relatedAreas: areas } : {}),
226
298
  });
227
299
  }
228
300
  for (const line of data.patch) {
@@ -249,6 +321,17 @@ function unitsOf(data: PluginsSection, tarballBytes?: Map<string, number>): Expo
249
321
  return units;
250
322
  }
251
323
 
324
+ /**
325
+ * 目标机 profile 的 `dsh.profile.bundles` 视图(issue #95)。
326
+ *
327
+ * 三态**必须分开**:`absent`(没有 package.json)、`unreadable`(读失败 / JSON 坏 / 形状不符)、
328
+ * `ok`。把后两者当成「没有」会直接去写一份清单,抹掉 profile 的其它字段。
329
+ */
330
+ type TargetBundleView =
331
+ | { state: 'ok'; names: Set<string>; text: string }
332
+ | { state: 'absent' }
333
+ | { state: 'unreadable'; reason: string };
334
+
252
335
  export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
253
336
  readonly id = 'plugins' as const;
254
337
  // 元数据唯一来源 = 注册表(t31):不再与 ui/export-flow.ts 的导出目录各写一份
@@ -291,7 +374,7 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
291
374
  section: collected,
292
375
  // 与 src/index.ts 对 JSON 分区现算体积的口径一致(utf8 字节数)
293
376
  sizeBytes: new TextEncoder().encode(stringifyJsonSafe(data)).length,
294
- items: unitsOf(data, tarballBytes),
377
+ items: unitsOf(data, tarballBytes, relatedAreasByData.get(data)),
295
378
  };
296
379
  }
297
380
 
@@ -317,6 +400,9 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
317
400
  isBundle: p.isBundle ?? false,
318
401
  inBundles: p.inBundles ?? [],
319
402
  enabled: p.enabled,
403
+ // issue #95:只在 dsh.profile.bundles 里声明的层(源机无依赖声明)必须带上这个标记,
404
+ // 导入端据此走「只声明」而不是 npm 安装。缺省不写,普通依赖的字节与改造前逐字一致。
405
+ ...(p.bundlesOnly === true ? { bundlesOnly: true } : {}),
320
406
  });
321
407
  }
322
408
  } catch (err) {
@@ -432,16 +518,28 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
432
518
  const alive = new Set(keptPlugins.map((pl) => pl.name));
433
519
  keptTarballs = localTarballs?.filter((t) => alive.has(t.packageName));
434
520
  }
521
+ const data: PluginsSection = {
522
+ version: 1,
523
+ plugins: keptPlugins,
524
+ patch: keptPatch,
525
+ pnpmWorkspace: keptWorkspace,
526
+ ...(keptTarballs !== undefined ? { localTarballs: keptTarballs } : {}),
527
+ ...(keptPatchFiles !== undefined ? { patchFiles: keptPatchFiles } : {}),
528
+ };
529
+ /**
530
+ * 「按插件看」的树骨架(导出选择器):登记每个插件声明的数据区域。
531
+ * 只登记「至少有一个区域」的插件 —— 其余插件单元保持无该字段,界面据此不显示空壳节点。
532
+ * 归属信息是**附加**信息:扫描失败不得影响导出/预览本身(与 pluginData 同一纪律)。
533
+ */
534
+ try {
535
+ const related = await relatedAreasOf(ctx);
536
+ if (related.size > 0) relatedAreasByData.set(data, related);
537
+ } catch {
538
+ // 读不到声明就不登记(界面按「没有已知关联」渲染,绝不猜)
539
+ }
435
540
  return {
436
541
  sectionId: 'plugins',
437
- data: {
438
- version: 1,
439
- plugins: keptPlugins,
440
- patch: keptPatch,
441
- pnpmWorkspace: keptWorkspace,
442
- ...(keptTarballs !== undefined ? { localTarballs: keptTarballs } : {}),
443
- ...(keptPatchFiles !== undefined ? { patchFiles: keptPatchFiles } : {}),
444
- },
542
+ data,
445
543
  counts: {
446
544
  plugins: keptPlugins.length,
447
545
  patchLines: keptPatch.length,
@@ -461,8 +559,9 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
461
559
  * 由选择模型保证勾选/取消同步。
462
560
  */
463
561
  listUnits(section: ExportSection<PluginsSection>): ExportUnit[] {
464
- // 预览产物没有 tarball 字节,体积来自 measureLocalSources 挂上的统计(导出产物走 base64 分支)
465
- return unitsOf(section.data, previewTarballBytes.get(section.data));
562
+ // 预览产物没有 tarball 字节,体积来自 measureLocalSources 挂上的统计(导出产物走 base64 分支);
563
+ // 「按插件看」的数据区域骨架同样由 collect() 登记在载荷对象上(两条路径逐字一致,units.test 钉住)
564
+ return unitsOf(section.data, previewTarballBytes.get(section.data), relatedAreasByData.get(section.data));
466
565
  }
467
566
 
468
567
  /**
@@ -492,6 +591,81 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
492
591
  return set;
493
592
  }
494
593
 
594
+ /**
595
+ * 读目标机 profile 的 `dsh.profile.bundles`(issue #95)。
596
+ *
597
+ * 相对路径与写入目标同源(`profiles/<profile>/package.json`),走 `ctx.target.fs` 而不是
598
+ * 宿主私有通道:与 pnpm-workspace.yaml / patch 文件同一条 I/O 纪律(受限、可被 MemFs 测试)。
599
+ */
600
+ private async targetBundles(ctx: ImportContext): Promise<TargetBundleView> {
601
+ const rel = `${PNPM_PROFILE_DIR(ctx.target.profile)}/package.json`;
602
+ try {
603
+ if (!(await ctx.target.fs.exists(rel))) return { state: 'absent' };
604
+ const text = new TextDecoder().decode(await ctx.target.fs.readFile(rel));
605
+ let parsed: unknown;
606
+ try {
607
+ parsed = JSON.parse(text);
608
+ } catch {
609
+ return { state: 'unreadable', reason: ctx.msg('adapter.pluginHostBundleBadJson') };
610
+ }
611
+ const bundles = (parsed as { dsh?: { profile?: { bundles?: unknown } } } | null)?.dsh?.profile?.bundles;
612
+ if (!Array.isArray(bundles) || bundles.some((entry) => typeof entry !== 'string')) {
613
+ return { state: 'unreadable', reason: ctx.msg('adapter.pluginHostBundleBadShape') };
614
+ }
615
+ return { state: 'ok', names: new Set(bundles as string[]), text };
616
+ } catch (err) {
617
+ return { state: 'unreadable', reason: err instanceof Error ? err.message : String(err) };
618
+ }
619
+ }
620
+
621
+ /**
622
+ * 把 host 自带的层声明进目标机 profile 的 `dsh.profile.bundles`(issue #95)。
623
+ *
624
+ * 为什么**不走安装通道**:这类层的包由 DSH 从自己的安装位置解析;按裸包名 `dsh plugin add`
625
+ * 会把 npm `dist-tags.latest` 的副本装进 profile,与 host 的版本线脱节 —— 真机故障:
626
+ * host 0.2.1-rc.2 + 包 0.2.1-alpha.2 的隔离组契约对不上 → `session-controller` 一直 pending
627
+ * → 新建会话 service-unavailable。声明不了时只报非致命 warning(§34.17),不拖垮其余配置。
628
+ */
629
+ private async declareHostBundle(name: string, ctx: ImportContext): Promise<ApplyResult> {
630
+ const msg = ctx.msg;
631
+ const rel = `${PNPM_PROFILE_DIR(ctx.target.profile)}/package.json`;
632
+ const view = await this.targetBundles(ctx);
633
+ if (view.state === 'absent') {
634
+ return { ok: false, warning: true, message: msg('adapter.pluginHostBundleNoManifest', { name }) };
635
+ }
636
+ if (view.state === 'unreadable') {
637
+ return {
638
+ ok: false,
639
+ warning: true,
640
+ message: msg('adapter.pluginHostBundleManifestUnreadable', { name, reason: view.reason }),
641
+ };
642
+ }
643
+ if (view.names.has(name)) {
644
+ return { ok: true, needsRestart: false, message: msg('adapter.pluginHostBundleSame', { name }) };
645
+ }
646
+ const merged = mergeBundleDeclaration(view.text, name);
647
+ if (merged === null) {
648
+ return {
649
+ ok: false,
650
+ warning: true,
651
+ message: msg('adapter.pluginHostBundleManifestUnreadable', {
652
+ name,
653
+ reason: msg('adapter.pluginHostBundleBadShape'),
654
+ }),
655
+ };
656
+ }
657
+ try {
658
+ await ctx.target.fs.writeFile(rel, new TextEncoder().encode(merged.text));
659
+ } catch (err) {
660
+ return {
661
+ ok: false,
662
+ warning: true,
663
+ message: msg('adapter.pluginHostBundleWriteFailed', { name, reason: err instanceof Error ? err.message : String(err) }),
664
+ };
665
+ }
666
+ return { ok: true, needsRestart: true, message: msg('adapter.pluginHostBundleDeclared', { name }) };
667
+ }
668
+
495
669
  async analyzeImport(data: PluginsSection, ctx: ImportContext): Promise<PlanItem[]> {
496
670
  const msg = ctx.msg;
497
671
  const items: PlanItem[] = [];
@@ -582,8 +756,42 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
582
756
 
583
757
  // 插件:包名唯一键;同版本 Skip / 未装 Install / 版本不同 Conflict
584
758
  const targetInstalled = await ctx.target.plugins.listInstalled();
759
+ // issue #95:只在 dsh.profile.bundles 里声明的层(源机没有依赖声明)**不装 npm 包** ——
760
+ // 它的名字只声明进目标机的 bundles,由 DSH 从自己的安装位置解析。只在真出现这类条目时才读
761
+ // 目标清单(其余场景零额外 I/O,行为与改造前逐字一致)。
762
+ const bundleView = data.plugins.some((p) => isBundlesOnlyEntry(p)) ? await this.targetBundles(ctx) : null;
585
763
  for (const p of data.plugins) {
586
764
  const id = `plugin:${p.name}`;
765
+ if (isBundlesOnlyEntry(p)) {
766
+ if (bundleView !== null && bundleView.state === 'ok' && bundleView.names.has(p.name)) {
767
+ items.push({
768
+ id, kind: 'Skip', adapter: 'plugins',
769
+ description: msg('adapter.pluginHostBundleSame', { name: p.name }),
770
+ detail: msg('adapter.pluginHostBundleDetail'),
771
+ severity: 'info',
772
+ });
773
+ } else if (bundleView !== null && bundleView.state === 'ok') {
774
+ items.push({
775
+ id, kind: 'Create', adapter: 'plugins',
776
+ description: msg('adapter.pluginHostBundleDeclare', { name: p.name }),
777
+ detail: msg('adapter.pluginHostBundleDetail'),
778
+ severity: 'info',
779
+ target: { adapter: 'plugins', ref: PLUGIN_PROFILE_MANIFEST_REF },
780
+ });
781
+ } else {
782
+ // 清单缺 / 读不到 / 形态不符:**不猜也不重建**,如实告警(非致命,其余分区照常导入)
783
+ items.push({
784
+ id,
785
+ kind: 'Warning',
786
+ adapter: 'plugins',
787
+ description: bundleView === null || bundleView.state === 'absent'
788
+ ? msg('adapter.pluginHostBundleNoManifest', { name: p.name })
789
+ : msg('adapter.pluginHostBundleManifestUnreadable', { name: p.name, reason: bundleView.reason }),
790
+ severity: 'warning',
791
+ });
792
+ }
793
+ continue;
794
+ }
587
795
  const tp = targetInstalled.find((t) => t.name === p.name);
588
796
  // T1:该插件在备份内自带 tarball(本地源 link:/file:)→ 目标机无需原始路径即可安装。
589
797
  const hasTarball = tarballOf(data, p.name) !== undefined;
@@ -717,10 +925,16 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
717
925
  // 拖垮已成功导入的其余配置——与 workspaces 的「目标不可达 → warning」同款语义。
718
926
  if (item.id.startsWith('plugin:')) {
719
927
  const name = item.id.replace(/^plugin:/, '');
928
+ const data = ctx.sections.get('plugins') as PluginsSection | undefined;
929
+ // issue #95:host 自带的层(源机只在 dsh.profile.bundles 声明、没有依赖声明)走「只声明」,
930
+ // 绝不进 npm 安装通道(按裸包名装 = 把 dist-tag latest 的副本塞进 profile)。
931
+ const entry = data?.plugins.find((p) => p.name === name);
932
+ if (entry !== undefined && isBundlesOnlyEntry(entry)) {
933
+ return await this.declareHostBundle(name, ctx);
934
+ }
720
935
  try {
721
936
  // 非 registry 来源(github:/file: 等)按来源 spec 安装,registry 包按裸包名装 npm 最新版
722
- const data = ctx.sections.get('plugins') as PluginsSection | undefined;
723
- let spec = data?.plugins.find((p) => p.name === name)?.spec;
937
+ let spec = entry?.spec;
724
938
 
725
939
  // T1:备份内自带 tarball → 解包到 homeDir 内的本地插件缓存目录,把 spec 重写为绝对路径。
726
940
  // 这是「换机后本地插件不再丢失」的关键:原始绝对路径在目标机不存在,解包出的 tgz 一定存在。
@@ -796,6 +1010,11 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
796
1010
  if (typeof name !== 'string' || name === '') {
797
1011
  issues.push({ path: `plugins[${i}].name`, message: msg('adapter.validate.requiredField', { subject: 'plugins[].name' }), severity: 'error' });
798
1012
  }
1013
+ // issue #95:`bundlesOnly` 决定走不走 npm 安装通道,写错类型必须拦下(缺省合法)
1014
+ const bundlesOnly = (p as { bundlesOnly?: unknown }).bundlesOnly;
1015
+ if (bundlesOnly !== undefined && typeof bundlesOnly !== 'boolean') {
1016
+ issues.push({ path: `plugins[${i}].bundlesOnly`, message: msg('adapter.validate.boolean', { subject: 'plugins[].bundlesOnly' }), severity: 'error' });
1017
+ }
799
1018
  }
800
1019
  }
801
1020
  if (section.patch !== undefined && !Array.isArray(section.patch)) {
@@ -147,6 +147,32 @@ test('plugins:按包名过滤,cordis 补丁行独立成单元', async () =>
147
147
  assert.deepEqual(filtered.data.patch, [], '未勾选的补丁行不得被带出');
148
148
  });
149
149
 
150
+ test('plugins:「按插件看」的树骨架 —— 单元带上它声明的数据区域(内置来源表,缓存永不进树)', async () => {
151
+ const ctx = makeContext('win32', HOME);
152
+ // 内置来源表:dsh-mnemon → runtime(config) + data/documents(assets);
153
+ // dsh-plugin-wallpaper-engine → 数据目录(config) + uploads(assets) + cache(**不属于任何分区**)
154
+ ctx.plugins.installed.set('dsh-mnemon', { name: 'dsh-mnemon', version: '1.0.0', enabled: true });
155
+ ctx.plugins.installed.set('dsh-plugin-wallpaper-engine', {
156
+ name: 'dsh-plugin-wallpaper-engine', version: '1.0.0', enabled: true,
157
+ });
158
+ ctx.plugins.installed.set('pkg-a', { name: 'pkg-a', version: '1.0.0', enabled: true });
159
+ const adapter = new PluginsAdapter();
160
+
161
+ const previewed = await adapter.preview(ctx, { includeSecrets: false });
162
+ assert.deepEqual(previewed.items.find((u) => u.id === 'plugin:dsh-mnemon')?.relatedAreas, [
163
+ { section: 'pluginData', relPath: '.mnemon/runtime', role: 'config' },
164
+ { section: 'pluginAssets', relPath: '.mnemon/data', role: 'assets' },
165
+ { section: 'pluginAssets', relPath: '.mnemon/documents', role: 'assets' },
166
+ ]);
167
+ const we = previewed.items.find((u) => u.id === 'plugin:dsh-plugin-wallpaper-engine')?.relatedAreas ?? [];
168
+ assert.deepEqual(we.map((r) => r.relPath), ['.dsh-wallpaper-engine', '.dsh-wallpaper-engine/uploads'],
169
+ 'cache 区域可再生产物、不属于任何分区,绝不进树');
170
+ // 没有声明的插件不得凭空带上区域(不猜)
171
+ assert.equal(previewed.items.find((u) => u.id === 'plugin:pkg-a')?.relatedAreas, undefined);
172
+ // 预览与导出两条路径的单元清单必须逐项相同(本文件既有纪律)
173
+ assert.deepEqual(previewed.items, adapter.listUnits(previewed.section));
174
+ });
175
+
150
176
  test('plugins:pnpm-workspace.yaml ↔ patch 文件是原子组(lockedWith 双向声明)', () => {
151
177
  const adapter = new PluginsAdapter();
152
178
  const section: ExportSection<PluginsSection> = {