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
@@ -0,0 +1,60 @@
1
+ /**
2
+ * 「已安装插件自述数据目录(dsh.dataDirs)」的**单一扫描实现**。
3
+ *
4
+ * 为什么单独一个模块:这份扫描有两个消费者 ——
5
+ * - \`pluginData\` / \`pluginAssets\` adapter(决定要收集哪些区域);
6
+ * - \`plugins\` adapter(导出选择器「按插件看」需要知道**每个插件自己声明的数据区域**,
7
+ * 才能在不读任何数据目录的前提下画出树骨架)。
8
+ * 两处各写一份必然漂移(同一条铁律:同一事实只有一份实现)。
9
+ *
10
+ * 语义(与改造前 plugin-data.ts 的私有实现逐字一致):
11
+ * - 读的是 **profile 实际安装**的那份 manifest(\`profiles/<profile>/node_modules/<pkg>/package.json\`),
12
+ * 不是全局目录 —— 同一个包在不同 profile 可以是不同版本,声明必须取本 profile 装的;
13
+ * - 读不到(未安装 / 无声明 / 形状非法)一律跳过:本机制是**附加**信息,绝不因为一个包读不出来
14
+ * 就影响内置来源或整次导出;
15
+ * - 本函数**不缓存**(缓存策略由调用方决定:pluginData 有实例级 TTL 缓存,plugins 有模块级缓存)。
16
+ */
17
+ import { parseDeclaredDataDirs } from '../core/plugin-data-registry.ts';
18
+ import type { PluginDataSource } from '../core/plugin-data-registry.ts';
19
+ import type { HostContext } from '../core/types.ts';
20
+
21
+ /** 单次扫描最多读取多少个已安装插件的 manifest(防御性上限:profile 依赖数量异常时不无限读) */
22
+ export const MAX_DECLARED_PACKAGES = 300;
23
+
24
+ /**
25
+ * 解析声明时用的 profile 目录名。
26
+ *
27
+ * 与 DSH 的 profile 解析同口径:空 / undefined = \`web\`(与改造前的私有实现一致)。
28
+ */
29
+ export function declaredProfileName(profile: string | undefined): string {
30
+ return profile !== undefined && profile !== '' ? profile : 'web';
31
+ }
32
+
33
+ /** 扫描本机已安装插件自述的数据目录声明(零副作用;读不到的一律跳过)。 */
34
+ export async function scanDeclaredDataSources(
35
+ ctx: HostContext,
36
+ maxPackages: number = MAX_DECLARED_PACKAGES,
37
+ ): Promise<PluginDataSource[]> {
38
+ const base = 'profiles/' + declaredProfileName(ctx.profile);
39
+ const out: PluginDataSource[] = [];
40
+ let deps: string[] = [];
41
+ try {
42
+ const raw = await ctx.fs.readFile(base + '/package.json');
43
+ const parsed: unknown = JSON.parse(new TextDecoder().decode(raw));
44
+ const dep = (parsed as { dependencies?: unknown } | null)?.dependencies;
45
+ if (dep !== null && typeof dep === 'object') deps = Object.keys(dep).slice(0, maxPackages);
46
+ } catch {
47
+ deps = [];
48
+ }
49
+ for (const name of deps) {
50
+ try {
51
+ const raw = await ctx.fs.readFile(base + '/node_modules/' + name + '/package.json');
52
+ const manifest: unknown = JSON.parse(new TextDecoder().decode(raw));
53
+ const source = parseDeclaredDataDirs(name, manifest);
54
+ if (source !== null) out.push(source);
55
+ } catch {
56
+ // 未安装 / 无 manifest → 无声明,跳过
57
+ }
58
+ }
59
+ return out;
60
+ }
@@ -33,8 +33,8 @@ import type { RecursiveListing } from '../utils/recursive-walk.ts';
33
33
  import { sectionMeta } from '../schema/section-registry.ts';
34
34
  import {
35
35
  BUILTIN_PLUGIN_DATA_SOURCES, areaOfPath, areasForSection, isExcludedRel, normalizeHomeRel,
36
- parseDeclaredDataDirs,
37
36
  } from '../core/plugin-data-registry.ts';
37
+ import { declaredProfileName, scanDeclaredDataSources } from './plugin-data-declared.ts';
38
38
  import type { PluginDataArea, PluginDataSource, PluginDataSection } from '../core/plugin-data-registry.ts';
39
39
  import { osHomeDirOf } from '../core/backup.ts';
40
40
  import { FileCollectionAdapter } from './file-collection.ts';
@@ -50,9 +50,6 @@ export type PluginDataEntry = { source: PluginDataSource; area: PluginDataArea }
50
50
  /** 声明来源缓存的 TTL(毫秒):一次导出/导入/执行里会多次解析,逐次读 15+ 个 package.json 是净开销 */
51
51
  const DECLARED_TTL_MS = 5_000;
52
52
 
53
- /** 单次解析里最多读取多少个已安装插件的 manifest(防御性上限:profile 依赖数量异常时不无限读) */
54
- const MAX_DECLARED_PACKAGES = 300;
55
-
56
53
  export interface PluginDataAdapterOptions {
57
54
  /** 内置来源(缺省 = BUILTIN_PLUGIN_DATA_SOURCES;测试注入假来源) */
58
55
  readonly sources?: readonly PluginDataSource[];
@@ -111,18 +108,15 @@ export class PluginDataAdapter extends FileCollectionAdapter {
111
108
  }
112
109
 
113
110
  /**
114
- * 已安装插件的自述声明(dsh.dataDirs)。
115
- *
116
- * 为什么读 profile 的 node_modules 而不是全局目录:DSH 的插件装在 \`profiles/<profile>/node_modules\`
117
- * 下(pnpm 隔离布局),且同一个包在不同 profile 可以是不同版本 —— 声明必须取**本 profile 实际装的**那份。
111
+ * 已安装插件的自述声明(dsh.dataDirs)—— 本方法只做**缓存**,扫描实现见 plugin-data-declared.ts。
118
112
  *
119
- * 读不到(未安装 / 无声明 / 形状非法)一律跳过:本机制是**附加**信息,绝不因为一个包读不出来
120
- * 就影响内置来源或整次导出。
113
+ * 为什么缓存必须带 ctx 键(homeDir + profile):DSH 的插件装在 \`profiles/<profile>/node_modules\`
114
+ * 下(pnpm 隔离布局),而「这台机器装了某插件」不代表另一台装了 —— 缓存若按实例共享,
115
+ * 导入到「没装该插件」的目标机时会拿源机的声明去放行写入,白名单就变成「谁先导入谁说了算」。
121
116
  */
122
117
  private async declaredSources(ctx: HostContext): Promise<PluginDataSource[]> {
123
118
  const now = Date.now();
124
- const profile = ctx.profile !== undefined && ctx.profile !== '' ? ctx.profile : 'web';
125
- const memoKey = ctx.homeDir + '|' + profile;
119
+ const memoKey = ctx.homeDir + '|' + declaredProfileName(ctx.profile);
126
120
  if (
127
121
  this.declaredMemo !== undefined
128
122
  && this.declaredMemo.key === memoKey
@@ -130,27 +124,7 @@ export class PluginDataAdapter extends FileCollectionAdapter {
130
124
  ) {
131
125
  return this.declaredMemo.sources;
132
126
  }
133
- const base = 'profiles/' + profile;
134
- const out: PluginDataSource[] = [];
135
- let deps: string[] = [];
136
- try {
137
- const raw = await ctx.fs.readFile(base + '/package.json');
138
- const parsed: unknown = JSON.parse(new TextDecoder().decode(raw));
139
- const dep = (parsed as { dependencies?: unknown } | null)?.dependencies;
140
- if (dep !== null && typeof dep === 'object') deps = Object.keys(dep).slice(0, MAX_DECLARED_PACKAGES);
141
- } catch {
142
- deps = [];
143
- }
144
- for (const name of deps) {
145
- try {
146
- const raw = await ctx.fs.readFile(base + '/node_modules/' + name + '/package.json');
147
- const manifest: unknown = JSON.parse(new TextDecoder().decode(raw));
148
- const source = parseDeclaredDataDirs(name, manifest);
149
- if (source !== null) out.push(source);
150
- } catch {
151
- // 未安装 / 无 manifest → 无声明,跳过
152
- }
153
- }
127
+ const out = await scanDeclaredDataSources(ctx);
154
128
  this.declaredMemo = { key: memoKey, at: now, sources: out };
155
129
  return out;
156
130
  }
@@ -6,6 +6,8 @@ import test from 'node:test';
6
6
  import assert from 'node:assert/strict';
7
7
  import { PluginsAdapter } from './plugins.ts';
8
8
  import { makeContext, makeImportContext } from './test-helpers.ts';
9
+ import { PLUGIN_PROFILE_MANIFEST_REF } from '../core/backup.ts';
10
+ import type { PluginsSection } from '../schema/types.ts';
9
11
  import type { PlanItem } from '../core/types.ts';
10
12
 
11
13
  test('issue #35:patch 文件随分区迁移;目标缺失的 patchedDependencies 声明导入时剔除', async () => {
@@ -567,3 +569,163 @@ test('t81 / t23 口径:patch 文件存在性探测读失败(非 ENOENT)必
567
569
  'ENOENT 仍应视为「不存在」(保留既有剔除语义与可见告警)',
568
570
  );
569
571
  });
572
+
573
+ /**
574
+ * issue #95:`@deepseek-ai/*` 的实验层在 DSH 里只写进 dsh.profile.bundles(包由 DSH 自己的
575
+ * 安装位置解析,profile 的 dependencies / node_modules 里都没有它)。导入端把它当 registry 包去
576
+ * `dsh plugin add`,会把 npm `dist-tags.latest` 的副本装进 profile —— 真机故障:
577
+ * host 0.2.1-rc.2 + 包 0.2.1-alpha.2 的隔离组契约对不上 → `session-controller` 一直 pending
578
+ * → 新建会话报 service-unavailable。
579
+ * 正确行为:只把名字声明进目标机的 dsh.profile.bundles,绝不走安装通道。
580
+ */
581
+ test('issue #95:host 自带的 bundle 层只声明 bundles,不装 npm 包', async () => {
582
+ const src = makeContext('win32', 'C:\\Users\\alice', 'web');
583
+ src.plugins.installed.set('@deepseek-ai/dsh-experimental-session-search', {
584
+ name: '@deepseek-ai/dsh-experimental-session-search',
585
+ version: '',
586
+ enabled: true,
587
+ isBundle: true,
588
+ inBundles: ['@deepseek-ai/dsh-experimental-session-search'],
589
+ bundlesOnly: true,
590
+ });
591
+ src.plugins.installed.set('dshmarket', { name: 'dshmarket', version: '1.0.3', enabled: true, spec: '^1.0.3' });
592
+
593
+ const adapter = new PluginsAdapter();
594
+ const exported = await adapter.export(src, { includeSecrets: false });
595
+ const layerName = '@deepseek-ai/dsh-experimental-session-search';
596
+ assert.equal(
597
+ exported.data.plugins.find((p) => p.name === layerName)?.bundlesOnly,
598
+ true,
599
+ '导出必须带上 bundlesOnly 标记',
600
+ );
601
+ assert.equal(
602
+ exported.data.plugins.find((p) => p.name === 'dshmarket')?.bundlesOnly,
603
+ undefined,
604
+ '普通依赖不得带该标记(旧备份字节形态不变)',
605
+ );
606
+
607
+ const dst = makeContext('linux', '/home/bob', 'web');
608
+ const pkgRel = 'profiles/web/package.json';
609
+ await dst.fs.writeFile(pkgRel, new TextEncoder().encode(`${JSON.stringify({
610
+ name: 'dsh-profile-web',
611
+ private: true,
612
+ dependencies: {},
613
+ dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } },
614
+ }, null, 2)}\n`));
615
+ const sections = new Map([['plugins', exported.data]]);
616
+
617
+ const items = await adapter.analyzeImport(exported.data, makeImportContext(dst, sections));
618
+ const item = items.find((i) => i.id === `plugin:${layerName}`);
619
+ assert.equal(item?.kind, 'Create', '缺声明 → Create(绝不是 Install)');
620
+ assert.deepEqual(
621
+ item?.target,
622
+ { adapter: 'plugins', ref: PLUGIN_PROFILE_MANIFEST_REF },
623
+ '写入目标必须进导入前快照(回滚能撤掉这次声明)',
624
+ );
625
+ assert.equal(items.find((i) => i.id === 'plugin:dshmarket')?.kind, 'Install', '普通依赖照旧走安装通道');
626
+
627
+ const r = await adapter.applyItem(item!, makeImportContext(dst, sections));
628
+ assert.equal(r.ok, true);
629
+ const written = JSON.parse(new TextDecoder().decode(dst.fs.files.get('/home/bob/profiles/web/package.json')!)) as {
630
+ dependencies: Record<string, string>;
631
+ dsh: { profile: { bundles: string[] } };
632
+ };
633
+ assert.deepEqual(
634
+ written.dsh.profile.bundles,
635
+ ['@deepseek-ai/dsh-base', layerName],
636
+ '只追加一层,原声明保序',
637
+ );
638
+ assert.deepEqual(written.dependencies, {}, '不得凭空写依赖行');
639
+ assert.equal(dst.plugins.installed.has(layerName), false, '绝不调用 npm 安装通道');
640
+
641
+ // 幂等:再分析一次 → Skip(不重复写、不重复报 Create)
642
+ const again = await adapter.analyzeImport(exported.data, makeImportContext(dst, sections));
643
+ assert.equal(again.find((i) => i.id === item!.id)?.kind, 'Skip');
644
+ });
645
+
646
+ test('issue #95:旧备份(没有 bundlesOnly 字段)按形状识别,绝不误伤普通依赖', async () => {
647
+ const adapter = new PluginsAdapter();
648
+ const layerName = '@deepseek-ai/dsh-experimental-auto-review';
649
+ const data: PluginsSection = {
650
+ version: 1,
651
+ plugins: [
652
+ { name: layerName, version: '', isBundle: true, inBundles: [layerName], enabled: true },
653
+ { name: 'broken-dep', version: '', spec: '^2.0.0', isBundle: false, inBundles: [], enabled: true },
654
+ ],
655
+ patch: [],
656
+ };
657
+ const dst = makeContext('linux', '/home/bob', 'web');
658
+ await dst.fs.writeFile(
659
+ 'profiles/web/package.json',
660
+ new TextEncoder().encode(`${JSON.stringify({ name: 'dsh-profile-web', dsh: { profile: { bundles: [] } } }, null, 2)}\n`),
661
+ );
662
+ const sections = new Map([['plugins', data]]);
663
+ const items = await adapter.analyzeImport(data, makeImportContext(dst, sections));
664
+ assert.equal(
665
+ items.find((i) => i.id === `plugin:${layerName}`)?.kind,
666
+ 'Create',
667
+ '无 spec 且无已装版本 = host 自带层 → 只声明',
668
+ );
669
+ assert.equal(
670
+ items.find((i) => i.id === 'plugin:broken-dep')?.kind,
671
+ 'Install',
672
+ '有依赖声明 → 照旧安装(哪怕本机没装全,也绝不静默跳过)',
673
+ );
674
+
675
+ // 应用后:名字进了 bundles,且没有为该层调用安装通道
676
+ const r = await adapter.applyItem(
677
+ items.find((i) => i.id === `plugin:${layerName}`)!,
678
+ makeImportContext(dst, sections),
679
+ );
680
+ assert.equal(r.ok, true);
681
+ assert.equal(dst.plugins.installed.has(layerName), false);
682
+ const written = JSON.parse(new TextDecoder().decode(dst.fs.files.get('/home/bob/profiles/web/package.json')!)) as {
683
+ dsh: { profile: { bundles: string[] } };
684
+ };
685
+ assert.deepEqual(written.dsh.profile.bundles, [layerName]);
686
+ });
687
+
688
+ test('issue #95:目标清单缺失 / JSON 坏 / 形态不符 → 只告警,绝不重建清单', async () => {
689
+ const adapter = new PluginsAdapter();
690
+ const data: PluginsSection = {
691
+ version: 1,
692
+ plugins: [{ name: 'host-layer', version: '', isBundle: true, inBundles: ['host-layer'], enabled: true, bundlesOnly: true }],
693
+ patch: [],
694
+ };
695
+ // ① 目标机没有 package.json
696
+ const a = makeContext('linux', '/home/a', 'web');
697
+ const sectionsA = new Map([['plugins', data]]);
698
+ const itemsA = await adapter.analyzeImport(data, makeImportContext(a, sectionsA));
699
+ assert.equal(itemsA[0]?.kind, 'Warning', '没有清单 → 告警(不是 Install,也不是静默跳过)');
700
+ assert.match(itemsA[0]?.description ?? '', /package\.json/);
701
+ const applyA = await adapter.applyItem(itemsA[0]!, makeImportContext(a, sectionsA));
702
+ assert.equal(applyA.ok, false);
703
+ assert.equal(applyA.warning, true, '声明不了只记非致命 warning(§34.17)');
704
+ assert.equal(await a.fs.exists('profiles/web/package.json'), false, '绝不凭空造一份清单');
705
+
706
+ // ② JSON 坏:原文必须逐字不动
707
+ const b = makeContext('linux', '/home/b', 'web');
708
+ await b.fs.writeFile('profiles/web/package.json', new TextEncoder().encode('{'));
709
+ const sectionsB = new Map([['plugins', data]]);
710
+ const itemsB = await adapter.analyzeImport(data, makeImportContext(b, sectionsB));
711
+ assert.equal(itemsB[0]?.kind, 'Warning');
712
+ assert.match(itemsB[0]?.description ?? '', /JSON|形态/);
713
+ const applyB = await adapter.applyItem(itemsB[0]!, makeImportContext(b, sectionsB));
714
+ assert.equal(applyB.ok, false);
715
+ assert.equal(
716
+ new TextDecoder().decode(b.fs.files.get('/home/b/profiles/web/package.json')!),
717
+ '{',
718
+ '坏清单绝不被覆写',
719
+ );
720
+
721
+ // ③ dsh.profile.bundles 不是字符串数组
722
+ const c = makeContext('linux', '/home/c', 'web');
723
+ await c.fs.writeFile(
724
+ 'profiles/web/package.json',
725
+ new TextEncoder().encode(JSON.stringify({ dsh: { profile: { bundles: 'x' } } })),
726
+ );
727
+ const sectionsC = new Map([['plugins', data]]);
728
+ const itemsC = await adapter.analyzeImport(data, makeImportContext(c, sectionsC));
729
+ assert.equal(itemsC[0]?.kind, 'Warning');
730
+ assert.match(itemsC[0]?.description ?? '', /形态/);
731
+ });