@logictan/dsh-config-manager 0.1.60 → 0.1.62

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 (77) hide show
  1. package/lib/adapters/credentials.d.ts +1 -1
  2. package/lib/adapters/index.d.ts +1 -1
  3. package/lib/adapters/index.js +1 -1
  4. package/lib/adapters/mcp.js +4 -4
  5. package/lib/adapters/plugins.d.ts +0 -1
  6. package/lib/adapters/plugins.js +35 -19
  7. package/lib/adapters/prompts.js +6 -6
  8. package/lib/adapters/test-helpers.d.ts +27 -0
  9. package/lib/adapters/test-helpers.js +40 -0
  10. package/lib/client.d.ts +1 -13
  11. package/lib/client.js +297 -304
  12. package/lib/core/analyzer.js +5 -4
  13. package/lib/core/backup.js +5 -3
  14. package/lib/core/exporter.d.ts +1 -4
  15. package/lib/core/exporter.js +14 -45
  16. package/lib/core/importer.d.ts +1 -1
  17. package/lib/core/index.d.ts +1 -1
  18. package/lib/core/messages.d.ts +1 -5
  19. package/lib/core/messages.js +2 -10
  20. package/lib/core/patch-layers.d.ts +32 -0
  21. package/lib/core/patch-layers.js +39 -0
  22. package/lib/core/rollback.js +4 -4
  23. package/lib/core/types.d.ts +8 -12
  24. package/lib/index.d.ts +23 -14
  25. package/lib/index.js +31 -92
  26. package/lib/security/index.d.ts +1 -3
  27. package/lib/security/index.js +1 -3
  28. package/lib/sync/transport.d.ts +2 -2
  29. package/lib/sync/transport.js +1 -1
  30. package/lib/ui/i18n.d.ts +0 -3
  31. package/lib/ui/i18n.js +0 -6
  32. package/lib/ui/report.js +1 -2
  33. package/lib/ui/test-helpers.d.ts +0 -6
  34. package/lib/ui/test-helpers.js +1 -5
  35. package/lib/ui/types.d.ts +0 -13
  36. package/lib/utils/env-lock.d.ts +16 -0
  37. package/lib/utils/env-lock.js +27 -6
  38. package/package.json +2 -2
  39. package/src/adapters/credentials.ts +1 -1
  40. package/src/adapters/index.ts +1 -1
  41. package/src/adapters/mcp.ts +4 -4
  42. package/src/adapters/patch-layers.test.ts +201 -0
  43. package/src/adapters/plugins.test.ts +13 -5
  44. package/src/adapters/plugins.ts +33 -17
  45. package/src/adapters/prompts.ts +6 -6
  46. package/src/adapters/test-helpers.ts +48 -0
  47. package/src/client/api.ts +1 -10
  48. package/src/client/config-manager.module.css +1 -1
  49. package/src/client/run-store.test.ts +1 -1
  50. package/src/client/sync/SyncSettingsView.tsx +5 -5
  51. package/src/client/sync/sync-api.ts +0 -1
  52. package/src/client/sync/sync-locales.ts +0 -1
  53. package/src/core/analyzer.ts +5 -4
  54. package/src/core/backup.ts +5 -3
  55. package/src/core/config-lifecycle.test.ts +4 -1
  56. package/src/core/exporter.ts +15 -47
  57. package/src/core/importer.ts +1 -1
  58. package/src/core/index.ts +1 -1
  59. package/src/core/messages.ts +2 -10
  60. package/src/core/patch-layers.ts +42 -0
  61. package/src/core/rollback.ts +4 -4
  62. package/src/core/smoke.test.ts +5 -5
  63. package/src/core/types.ts +8 -12
  64. package/src/index.facade.test.ts +25 -2
  65. package/src/index.ts +30 -104
  66. package/src/security/index.ts +1 -3
  67. package/src/security/security.test.ts +0 -361
  68. package/src/sync/transport.ts +3 -3
  69. package/src/ui/i18n.ts +0 -6
  70. package/src/ui/report.ts +1 -2
  71. package/src/ui/test-helpers.ts +3 -7
  72. package/src/ui/types.ts +0 -10
  73. package/src/utils/env-lock.test.ts +29 -0
  74. package/src/utils/env-lock.ts +27 -6
  75. package/lib/security/encryption.d.ts +0 -85
  76. package/lib/security/encryption.js +0 -279
  77. package/src/security/encryption.ts +0 -335
@@ -0,0 +1,201 @@
1
+ /**
2
+ * cordis patch 的**层寻址**契约测试(S1–S5):
3
+ * - 导出同时覆盖 home 层与 profile 层,`file` 字段如实标注来源层;
4
+ * - diff 阶段按行自带的层读目标端(profile 层已有同值 → Skip,不是 Create);
5
+ * - 计划项 id / target.ref 是层限定复合键,跨层同名 lineId 各自落各自层;
6
+ * - 快照与回滚按层落位(回滚 profile 层的行不得污染 home 层)。
7
+ */
8
+ import test from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+
11
+ import { PluginsAdapter } from './plugins.ts';
12
+ import { makeContext, makeImportContext, MemSnapshotStore } from './test-helpers.ts';
13
+ import { createSnapshot } from '../core/backup.ts';
14
+ import { rollback } from '../core/rollback.ts';
15
+ import { HOME_PATCH_FILE, PROFILE_PATCH_FILE, patchLayerKey } from '../core/patch-layers.ts';
16
+ import { SECTION_IDS } from '../schema/config.ts';
17
+ import type { ImportPlan, PlanItem } from '../core/types.ts';
18
+
19
+ /** 只含一项的导入计划(estimatedActions 需覆盖全部分区,故按 SECTION_IDS 补零)。 */
20
+ function importPlan(item: PlanItem): ImportPlan {
21
+ return {
22
+ items: [item],
23
+ globalStrategy: 'merge',
24
+ pathMappings: [],
25
+ missingSecrets: [],
26
+ needsRestart: false,
27
+ estimatedActions: Object.fromEntries(SECTION_IDS.map((id) => [id, 0])) as ImportPlan['estimatedActions'],
28
+ };
29
+ }
30
+
31
+ test('plugins.export: 两层 patch 行都进 section,file 字段区分来源层', async () => {
32
+ const ctx = makeContext('linux', '/home/alice', 'web');
33
+ const layered = ctx.useLayeredPatch();
34
+ layered.set(HOME_PATCH_FILE, 'home-a', { id: 'home-a', disabled: true });
35
+ layered.set(HOME_PATCH_FILE, 'home-b', { id: 'home-b', disabled: true });
36
+ layered.set(PROFILE_PATCH_FILE, 'profile-a', { id: 'profile-a', name: 'pkg-a' });
37
+ layered.set(PROFILE_PATCH_FILE, 'profile-b', { id: 'profile-b', name: 'pkg-b' });
38
+
39
+ const out = await new PluginsAdapter().export(ctx, { includeSecrets: false });
40
+ assert.equal(out.data.patch.length, 4, `两层并集必须齐全: ${JSON.stringify(out.data.patch)}`);
41
+ assert.deepEqual(
42
+ [...new Set(out.data.patch.map((p) => p.file))].sort(),
43
+ [HOME_PATCH_FILE, PROFILE_PATCH_FILE].sort(),
44
+ 'file 必须区分来源层',
45
+ );
46
+ assert.deepEqual(
47
+ out.data.patch.filter((p) => p.file === PROFILE_PATCH_FILE).map((p) => p.lineId),
48
+ ['profile-a', 'profile-b'],
49
+ );
50
+ });
51
+
52
+ test('plugins.export: profile 层文件缺失时降级为仅 home 层(不产生 error 级告警)', async () => {
53
+ const ctx = makeContext('linux', '/home/alice', 'web');
54
+ const layered = ctx.useLayeredPatch();
55
+ layered.set(HOME_PATCH_FILE, 'home-a', { id: 'home-a', disabled: true });
56
+
57
+ const out = await new PluginsAdapter().export(ctx, { includeSecrets: false });
58
+ assert.equal(out.data.patch.length, 1);
59
+ assert.deepEqual([...new Set(out.data.patch.map((p) => p.file))], [HOME_PATCH_FILE]);
60
+ assert.equal(out.warnings.length, 0, `缺 profile 层不是故障: ${out.warnings.join(' | ')}`);
61
+ });
62
+
63
+ test('plugins.analyzeImport: 目标端 profile 层已有同行 → Skip(不是 Create)', async () => {
64
+ const src = makeContext('linux', '/home/alice', 'web');
65
+ const srcLayered = src.useLayeredPatch();
66
+ const raw = { id: 'webserver', config: { port: 3080 } };
67
+ srcLayered.set(PROFILE_PATCH_FILE, 'webserver', raw);
68
+
69
+ const adapter = new PluginsAdapter();
70
+ const exported = await adapter.export(src, { includeSecrets: false });
71
+ const sections = new Map([['plugins', exported.data]]);
72
+
73
+ // 目标机 profile 层已有同值行 → Skip(旧实现只读 home 层,会误判成 Create)
74
+ const dst = makeContext('linux', '/home/bob', 'web');
75
+ dst.useLayeredPatch().set(PROFILE_PATCH_FILE, 'webserver', raw);
76
+ const items = await adapter.analyzeImport(exported.data, makeImportContext(dst, sections));
77
+ const item = items.find((i) => i.id === `patch:${patchLayerKey(PROFILE_PATCH_FILE, 'webserver')}`);
78
+ assert.equal(item?.kind, 'Skip', JSON.stringify(items));
79
+
80
+ // 目标机 profile 层同 id 但不同值 → Conflict(旧实现只读 home 层,会误判成 Create)
81
+ const dst2 = makeContext('linux', '/home/bob2', 'web');
82
+ dst2.useLayeredPatch().set(PROFILE_PATCH_FILE, 'webserver', { id: 'webserver', config: { port: 9999 } });
83
+ const items2 = await adapter.analyzeImport(exported.data, makeImportContext(dst2, sections));
84
+ const item2 = items2.find((i) => i.id === `patch:${patchLayerKey(PROFILE_PATCH_FILE, 'webserver')}`);
85
+ assert.equal(item2?.kind, 'Conflict', JSON.stringify(items2));
86
+ });
87
+
88
+ test('plugins: 跨层同名 lineId → 两个独立计划项,各自落各自层', async () => {
89
+ const src = makeContext('linux', '/home/alice', 'web');
90
+ const srcLayered = src.useLayeredPatch();
91
+ srcLayered.set(HOME_PATCH_FILE, 'dup', { id: 'dup', config: { from: 'home' } });
92
+ srcLayered.set(PROFILE_PATCH_FILE, 'dup', { id: 'dup', config: { from: 'profile' } });
93
+
94
+ const adapter = new PluginsAdapter();
95
+ const exported = await adapter.export(src, { includeSecrets: false });
96
+ const sections = new Map([['plugins', exported.data]]);
97
+ assert.equal(exported.data.patch.length, 2);
98
+
99
+ const dst = makeContext('linux', '/home/bob', 'web');
100
+ dst.useLayeredPatch();
101
+ const items = await adapter.analyzeImport(exported.data, makeImportContext(dst, sections));
102
+ const patchItems = items.filter((i) => i.id.startsWith('patch:'));
103
+ assert.equal(patchItems.length, 2, `同名 lineId 必须是两个独立项: ${JSON.stringify(items)}`);
104
+
105
+ for (const item of patchItems) {
106
+ const r = await adapter.applyItem(item, makeImportContext(dst, sections));
107
+ assert.equal(r.ok, true, JSON.stringify(r));
108
+ }
109
+ const layered = dst.patchFile as unknown as { has: (f: string, id: string) => boolean };
110
+ assert.equal(layered.has(HOME_PATCH_FILE, 'dup'), true, 'home 层的行必须落在 home 层');
111
+ assert.equal(layered.has(PROFILE_PATCH_FILE, 'dup'), true, 'profile 层的行必须落在 profile 层');
112
+ assert.deepEqual(
113
+ (dst.patchFile as unknown as { rawOf: (f: string, id: string) => unknown }).rawOf(HOME_PATCH_FILE, 'dup'),
114
+ { id: 'dup', config: { from: 'home' } },
115
+ );
116
+ assert.deepEqual(
117
+ (dst.patchFile as unknown as { rawOf: (f: string, id: string) => unknown }).rawOf(PROFILE_PATCH_FILE, 'dup'),
118
+ { id: 'dup', config: { from: 'profile' } },
119
+ );
120
+ });
121
+
122
+ test('rollback: profile 层 patchLine 回滚写回 profile 层(home 层文件不受影响)', async () => {
123
+ const ctx = makeContext('linux', '/home/bob', 'web');
124
+ const layered = ctx.useLayeredPatch();
125
+ const original = { id: 'webserver', config: { port: 3080 } };
126
+ layered.set(PROFILE_PATCH_FILE, 'webserver', original);
127
+ layered.set(HOME_PATCH_FILE, 'webserver', { id: 'webserver', config: { port: 1111 } });
128
+
129
+ const item: PlanItem = {
130
+ id: `patch:${patchLayerKey(PROFILE_PATCH_FILE, 'webserver')}`,
131
+ kind: 'Update',
132
+ adapter: 'plugins',
133
+ description: 'write profile patch row',
134
+ severity: 'info',
135
+ target: { adapter: 'plugins', ref: patchLayerKey(PROFILE_PATCH_FILE, 'webserver') },
136
+ };
137
+ const plan = importPlan(item);
138
+ const store = new MemSnapshotStore();
139
+ const snapshot = await createSnapshot({ ctx, plan, sourceZip: '/tmp/x.zip', store, adapters: [] });
140
+ const entry = snapshot.entries.find((e) => e.kind === 'patchLine');
141
+ assert.ok(entry !== undefined, `快照必须登记 patchLine 条目: ${JSON.stringify(snapshot.entries)}`);
142
+ assert.deepEqual(entry.before, original, '快照必须记 profile 层原值(旧实现固定读 home 层,会记成 home 层的值)');
143
+
144
+ // 模拟导入改写 profile 层
145
+ await ctx.patchFile.applyPatchChanges(PROFILE_PATCH_FILE, [
146
+ { lineId: 'webserver', raw: { id: 'webserver', config: { port: 9999 } }, action: 'update' },
147
+ ]);
148
+ const report = await rollback({ ctx, snapshot, store, adapters: [] });
149
+ assert.equal(report.full, true, JSON.stringify(report));
150
+ assert.deepEqual(layered.rawOf(PROFILE_PATCH_FILE, 'webserver'), original, '回滚必须写回 profile 层原值');
151
+ assert.deepEqual(
152
+ layered.rawOf(HOME_PATCH_FILE, 'webserver'),
153
+ { id: 'webserver', config: { port: 1111 } },
154
+ 'home 层的行不得被 profile 层回滚污染',
155
+ );
156
+ });
157
+
158
+ test('rollback: 旧快照的裸 lineId ref 兼容为 home 层', async () => {
159
+ const ctx = makeContext('linux', '/home/bob', 'web');
160
+ const layered = ctx.useLayeredPatch();
161
+ layered.set(HOME_PATCH_FILE, 'legacy', { id: 'legacy', disabled: false });
162
+
163
+ const item: PlanItem = {
164
+ id: 'patch:legacy',
165
+ kind: 'Update',
166
+ adapter: 'plugins',
167
+ description: 'legacy ref',
168
+ severity: 'info',
169
+ target: { adapter: 'plugins', ref: 'legacy' },
170
+ };
171
+ const plan = importPlan(item);
172
+ const store = new MemSnapshotStore();
173
+ const snapshot = await createSnapshot({ ctx, plan, sourceZip: '/tmp/x.zip', store, adapters: [] });
174
+ assert.deepEqual(snapshot.entries.find((e) => e.kind === 'patchLine')?.before, { id: 'legacy', disabled: false });
175
+
176
+ await ctx.patchFile.applyPatchChanges(HOME_PATCH_FILE, [
177
+ { lineId: 'legacy', raw: { id: 'legacy', disabled: true }, action: 'update' },
178
+ ]);
179
+ const report = await rollback({ ctx, snapshot, store, adapters: [] });
180
+ assert.equal(report.full, true, JSON.stringify(report));
181
+ assert.deepEqual(layered.rawOf(HOME_PATCH_FILE, 'legacy'), { id: 'legacy', disabled: false });
182
+ });
183
+
184
+ test('plugins.export: 存量快照的 file=home token 仍解析到 home 层', async () => {
185
+ const src = makeContext('linux', '/home/alice', 'web');
186
+ const layered = src.useLayeredPatch();
187
+ layered.set(HOME_PATCH_FILE, 'legacy-home', { id: 'legacy-home', disabled: true });
188
+
189
+ const adapter = new PluginsAdapter();
190
+ const exported = await adapter.export(src, { includeSecrets: false });
191
+ assert.equal(exported.data.patch[0]?.file, HOME_PATCH_FILE, 'home 层 token 与历史快照取值一致');
192
+
193
+ const sections = new Map([['plugins', exported.data]]);
194
+ const dst = makeContext('linux', '/home/bob', 'web');
195
+ dst.useLayeredPatch();
196
+ const items = await adapter.analyzeImport(exported.data, makeImportContext(dst, sections));
197
+ const item = items.find((i) => i.id.startsWith('patch:'));
198
+ assert.equal(item?.target?.ref, patchLayerKey(HOME_PATCH_FILE, 'legacy-home'));
199
+ await adapter.applyItem(item!, makeImportContext(dst, sections));
200
+ assert.equal((dst.patchFile as unknown as { has: (f: string, id: string) => boolean }).has(HOME_PATCH_FILE, 'legacy-home'), true);
201
+ });
@@ -4,10 +4,16 @@
4
4
  */
5
5
  import test from 'node:test';
6
6
  import assert from 'node:assert/strict';
7
- import { PluginsAdapter, USER_PATCH_FILE } from './plugins.ts';
7
+ import { PluginsAdapter } from './plugins.ts';
8
8
  import { makeContext, makeImportContext } from './test-helpers.ts';
9
+ import { HOME_PATCH_FILE, PROFILE_PATCH_FILE, patchLayerKey } from '../core/patch-layers.ts';
9
10
  import type { PlanItem } from '../core/types.ts';
10
11
 
12
+ /** 计划项 id:层限定复合键的投影(`patch:<file>#<lineId>`)。 */
13
+ function patchItemId(file: string, lineId: string): string {
14
+ return `patch:${patchLayerKey(file, lineId)}`;
15
+ }
16
+
11
17
  test('issue #35:patch 文件随分区迁移;目标缺失的 patchedDependencies 声明导入时剔除', async () => {
12
18
  const ws = [
13
19
  'allowBuilds:',
@@ -167,7 +173,9 @@ test('plugins: 导出清单与 patch 行', async () => {
167
173
  ctx.plugins.installed.set('@linxin666/dsh-ssh', { name: '@linxin666/dsh-ssh', version: '0.1.12', enabled: true, isBundle: true, inBundles: ['@linxin666/dsh-web-ui-all'] });
168
174
  ctx.plugins.installed.set('dsh-memory-evolve', { name: 'dsh-memory-evolve', version: '1.0.0', enabled: true, spec: 'github:csyangwen/dsh-memory-evolve' });
169
175
  ctx.plugins.installed.set('@deepseek-ai/dsh-base', { name: '@deepseek-ai/dsh-base', version: '0.1.0-rc.6', enabled: true });
170
- ctx.patchFile.lines.set('skill-badge', { lineId: 'skill-badge', raw: { id: 'skill-badge', disabled: true } });
176
+ // 层寻址契约:导出同时读 home profile 两层,单层的 MemPatch 对任何 file 都返回同一批行,
177
+ // 会让同一行被当成两层的两行。此处改用按层键控的门面,夹具才与真实门面同语义。
178
+ ctx.useLayeredPatch().set(HOME_PATCH_FILE, 'skill-badge', { id: 'skill-badge', disabled: true });
171
179
 
172
180
  const adapter = new PluginsAdapter();
173
181
  const out = await adapter.export(ctx, { includeSecrets: false });
@@ -235,11 +243,11 @@ test('plugins: 已装同版本 Skip / 未装 Install / 版本不同 Conflict / p
235
243
  const byId = new Map(items.map((i) => [i.id, i]));
236
244
  assert.equal(byId.get('plugin:pkg-a')?.kind, 'Skip');
237
245
  assert.equal(byId.get('plugin:pkg-b')?.kind, 'Conflict');
238
- assert.equal(byId.get('patch:my-line')?.kind, 'Create');
239
- assert.equal(byId.get('patch:my-line')?.target?.ref, 'my-line');
246
+ assert.equal(byId.get(patchItemId(HOME_PATCH_FILE, 'my-line'))?.kind, 'Create');
247
+ assert.equal(byId.get(patchItemId(HOME_PATCH_FILE, 'my-line'))?.target?.ref, patchLayerKey(HOME_PATCH_FILE, 'my-line'));
240
248
 
241
249
  // patch 行写入(Create)
242
- const r = await adapter.applyItem(byId.get('patch:my-line')!, makeImportContext(dst, sections));
250
+ const r = await adapter.applyItem(byId.get(patchItemId(HOME_PATCH_FILE, 'my-line'))!, makeImportContext(dst, sections));
243
251
  assert.equal(r.ok, true);
244
252
  assert.equal(r.needsRestart, true);
245
253
  assert.deepEqual(dst.patchFile.lines.get('my-line')?.raw, { id: 'my-line', name: 'pkg-c', config: { x: 1 } });
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * plugins 分区 adapter(设计 §3.3/§8):
3
- * 数据源 = ctx.plugins.listInstalled()(插件清单)+ 用户 patch 层(profile cordis.patch.yml)。
3
+ * 数据源 = ctx.plugins.listInstalled()(插件清单)+ 两个 patch 层(home 与 profile cordis.patch.yml)。
4
4
  *
5
5
  * 安全不变量:绝不打包插件二进制;导入走 DSH 官方机制(dsh plugin CLI → needsRestart 提示)。
6
- * patch 行导入(用户自定义行:启用/禁用/插入)写回 cordis.patch.yml,同样 needsRestart。
6
+ * patch 行导入(用户自定义行:启用/禁用/插入)写回该行自带的层(home profile),同样 needsRestart。
7
7
  *
8
8
  * T1(本地源插件迁移):`link:` / `file:` 来源的插件指向本机路径,换机后必然不可达
9
9
  * (曾导致插件被静默丢失)。导出时经注入的 `localPack` 执行 `npm pack`,把 tarball 作为
@@ -19,6 +19,7 @@ import { msgOf, zhMsg } from '../core/messages.ts';
19
19
  import type { MsgFunc } from '../core/messages.ts';
20
20
  import { isPathSafe, normalizePath } from '../utils/paths.ts';
21
21
  import { PLUGIN_PATCH_REF_PREFIX } from '../core/backup.ts';
22
+ import { HOME_PATCH_FILE, PROFILE_PATCH_FILE, patchLayerKey, parsePatchLayerKey } from '../core/patch-layers.ts';
22
23
  import { parsePnpmPatchedDependencies, sanitizePnpmWorkspacePatches } from './pnpm-workspace.ts';
23
24
  import type { LocalPluginTarball, PatchLine, PluginEntry, PluginsSection, PnpmPatchFile } from '../schema/types.ts';
24
25
  import type {
@@ -26,7 +27,6 @@ import type {
26
27
  ImportContext, PlanItem, ValidationResult,
27
28
  } from '../core/types.ts';
28
29
 
29
- export const USER_PATCH_FILE = 'cordis.patch.yml';
30
30
 
31
31
  /**
32
32
  * 本地源插件打包钩子(由宿主注入,见 src/index.ts createAdapters)。
@@ -170,12 +170,16 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
170
170
  } catch (err) {
171
171
  warnings.push(msgOf(ctx)('adapter.pluginListReadFailed', { reason: err instanceof Error ? err.message : String(err) }));
172
172
  }
173
+ // 两层都读:home 层(全机偏好)与 profile 层(该 profile 专属)。缺 profile 层文件不是故障
174
+ // (readPatchLines 对不存在的文件返回空数组),只有真读失败才告警。
173
175
  const patch: PatchLine[] = [];
174
- try {
175
- const lines = await ctx.patchFile.readPatchLines(USER_PATCH_FILE);
176
- for (const l of lines) patch.push({ file: USER_PATCH_FILE, lineId: l.lineId, raw: l.raw });
177
- } catch (err) {
178
- warnings.push(msgOf(ctx)('adapter.patchReadFailed', { reason: err instanceof Error ? err.message : String(err) }));
176
+ for (const file of [HOME_PATCH_FILE, PROFILE_PATCH_FILE]) {
177
+ try {
178
+ const lines = await ctx.patchFile.readPatchLines(file);
179
+ for (const l of lines) patch.push({ file, lineId: l.lineId, raw: l.raw });
180
+ } catch (err) {
181
+ warnings.push(msgOf(ctx)('adapter.patchReadFailed', { reason: err instanceof Error ? err.message : String(err) }));
182
+ }
179
183
  }
180
184
  // pnpm-workspace.yaml(allowBuilds / minimumReleaseAgeExclude 等):随插件分区迁移,
181
185
  // 否则目标 profile 的 pnpm 可能因构建白名单/冷静期拒绝安装插件(§34.17 同款语义)。
@@ -414,16 +418,25 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
414
418
 
415
419
  // 用户 patch 行:lineId 唯一键;存在且同 → Skip;存在不同 → Conflict;不存在 → Create。
416
420
  // mcp-client 行与 systemPrompt/planMode 行由 mcp/prompts adapter 管理,此处跳过(避免重复写入覆盖)。
417
- const targetLines = await ctx.target.patchFile.readPatchLines(USER_PATCH_FILE);
421
+ // 逐层读目标端:行自带的 file 决定比对哪一层(只读 home 层会让 profile 层行在目标机
422
+ // 已有同值时被误判成 Create,也会让目标机 profile 层的不同值判不出 Conflict)。
423
+ const targetLines = new Map<string, { lineId: string; raw: unknown }[]>();
424
+ for (const pl of data.patch) {
425
+ if (!targetLines.has(pl.file)) {
426
+ targetLines.set(pl.file, await ctx.target.patchFile.readPatchLines(pl.file));
427
+ }
428
+ }
418
429
  for (const pl of data.patch) {
419
430
  if (isManagedElsewhere(pl.raw)) continue;
420
- const id = `patch:${pl.lineId}`;
421
- const tl = targetLines.find((l) => l.lineId === pl.lineId);
431
+ // 层限定复合键:两层可存在同名 lineId,裸 lineId 无法指向「哪一层的哪一行」。
432
+ const ref = patchLayerKey(pl.file, pl.lineId);
433
+ const id = `patch:${ref}`;
434
+ const tl = targetLines.get(pl.file)?.find((l) => l.lineId === pl.lineId);
422
435
  if (!tl) {
423
436
  items.push({
424
437
  id, kind: 'Create', adapter: 'plugins',
425
438
  description: msg('adapter.patchLineCreate', { lineId: pl.lineId }), severity: 'info',
426
- target: { adapter: 'plugins', ref: pl.lineId },
439
+ target: { adapter: 'plugins', ref },
427
440
  });
428
441
  } else if (isDeepStrictEqual(tl.raw, pl.raw)) {
429
442
  items.push({ id, kind: 'Skip', adapter: 'plugins', description: msg('adapter.patchLineSame', { lineId: pl.lineId }), severity: 'info' });
@@ -431,7 +444,7 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
431
444
  items.push({
432
445
  id, kind: 'Conflict', adapter: 'plugins',
433
446
  description: msg('adapter.patchLineDiff', { lineId: pl.lineId }), severity: 'warning',
434
- target: { adapter: 'plugins', ref: pl.lineId },
447
+ target: { adapter: 'plugins', ref },
435
448
  });
436
449
  }
437
450
  }
@@ -561,13 +574,16 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
561
574
  // patch 行:Create → insert,Update/Conflict(useImported) → update
562
575
  const ref = item.target?.ref;
563
576
  if (!ref) return { ok: false, message: msg('adapter.missingTargetRef') };
577
+ // 复合键解析出层与 lineId:只按 lineId 查找会命中数组里先出现的 home 层行,
578
+ // 把 profile 层的行写进 home 层(回滚同理)。
579
+ const { file, lineId } = parsePatchLayerKey(ref);
564
580
  const data = ctx.sections.get('plugins') as PluginsSection | undefined;
565
- const pl = data?.patch.find((p) => p.lineId === ref);
581
+ const pl = data?.patch.find((p) => p.file === file && p.lineId === lineId);
566
582
  if (!pl) return { ok: false, message: msg('adapter.patchMissing', { ref }) };
567
- await ctx.target.patchFile.applyPatchChanges(pl.file, [
568
- { lineId: ref, raw: pl.raw, action: item.kind === 'Create' ? 'insert' : 'update' },
583
+ await ctx.target.patchFile.applyPatchChanges(file, [
584
+ { lineId, raw: pl.raw, action: item.kind === 'Create' ? 'insert' : 'update' },
569
585
  ]);
570
- return { ok: true, needsRestart: true, message: msg('adapter.patchWritten', { ref }) };
586
+ return { ok: true, needsRestart: true, message: msg('adapter.patchWritten', { ref: lineId }) };
571
587
  }
572
588
 
573
589
  async validate(data: PluginsSection, msg: MsgFunc = zhMsg): Promise<ValidationResult> {
@@ -17,7 +17,7 @@ import type {
17
17
  ApplyResult, ConfigAdapter, ExportOptions, ExportSection, HostContext,
18
18
  ImportContext, PlanItem, ValidationResult,
19
19
  } from '../core/types.ts';
20
- import { USER_PATCH_FILE } from './plugins.ts';
20
+ import { HOME_PATCH_FILE } from '../core/patch-layers.ts';
21
21
 
22
22
  /** 导出记录:PromptEntry 之外记录来源行名(导入需要重建行时使用) */
23
23
  export interface PromptExportEntry extends PromptEntry {
@@ -116,7 +116,7 @@ export class PromptsAdapter implements ConfigAdapter<PromptsExportSection> {
116
116
  const warnings: string[] = [];
117
117
  let lines: { lineId: string; raw: unknown }[] = [];
118
118
  try {
119
- lines = await ctx.patchFile.readPatchLines(USER_PATCH_FILE);
119
+ lines = await ctx.patchFile.readPatchLines(HOME_PATCH_FILE);
120
120
  } catch (err) {
121
121
  warnings.push(msgOf(ctx)('adapter.patchReadFailedPrompts', { reason: err instanceof Error ? err.message : String(err) }));
122
122
  }
@@ -132,7 +132,7 @@ export class PromptsAdapter implements ConfigAdapter<PromptsExportSection> {
132
132
  async analyzeImport(data: PromptsExportSection, ctx: ImportContext): Promise<PlanItem[]> {
133
133
  const msg = ctx.msg;
134
134
  const items: PlanItem[] = [];
135
- const targetLines = await ctx.target.patchFile.readPatchLines(USER_PATCH_FILE);
135
+ const targetLines = await ctx.target.patchFile.readPatchLines(HOME_PATCH_FILE);
136
136
  const targetPrompts = extractPrompts(targetLines);
137
137
  for (const p of data.prompts) {
138
138
  const id = p.id;
@@ -192,17 +192,17 @@ export class PromptsAdapter implements ConfigAdapter<PromptsExportSection> {
192
192
  // Create:目标无来源行 → 用记录的行名重建 patch 行(insert)
193
193
  if (item.kind === 'Create') {
194
194
  const raw = buildPromptLine(ref, prompt);
195
- await ctx.target.patchFile.applyPatchChanges(USER_PATCH_FILE, [
195
+ await ctx.target.patchFile.applyPatchChanges(HOME_PATCH_FILE, [
196
196
  { lineId: ref, raw, action: 'insert' },
197
197
  ]);
198
198
  return { ok: true, needsRestart: true, message: msg('adapter.promptCreated', { name: prompt.name, ref }) };
199
199
  }
200
200
  // Update / Conflict(useImported):合并进目标行 config
201
- const lines = await ctx.target.patchFile.readPatchLines(USER_PATCH_FILE);
201
+ const lines = await ctx.target.patchFile.readPatchLines(HOME_PATCH_FILE);
202
202
  const line = lines.find((l) => l.lineId === ref);
203
203
  if (!line) return { ok: false, message: msg('adapter.patchLineMissing', { ref }) };
204
204
  const newRaw = mergePromptIntoLine(line.raw, prompt);
205
- await ctx.target.patchFile.applyPatchChanges(USER_PATCH_FILE, [
205
+ await ctx.target.patchFile.applyPatchChanges(HOME_PATCH_FILE, [
206
206
  { lineId: ref, raw: newRaw, action: 'update' },
207
207
  ]);
208
208
  return { ok: true, needsRestart: true, message: msg('adapter.promptWritten', { name: prompt.name, ref }) };
@@ -158,6 +158,46 @@ export class MemPatch implements PatchFileFacade {
158
158
  }
159
159
  }
160
160
 
161
+ /**
162
+ * 分层 patch 门面 mock:patch 行按 `file`(层 token)分桶。
163
+ * 单层的 `MemPatch` 无法表达「同名 lineId 分布在两层」——而那正是层寻址契约的核心,
164
+ * 故另立一个按层键控的实现,供 plugins 适配器与快照/回滚的契约测试使用。
165
+ */
166
+ export class MemLayeredPatch implements PatchFileFacade {
167
+ files = new Map<string, Map<string, { lineId: string; raw: unknown }>>();
168
+
169
+ /** 直接落一行(测试夹具;等价于目标机该层已存在该行)。 */
170
+ set(file: string, lineId: string, raw: unknown): void {
171
+ const layer = this.files.get(file) ?? new Map();
172
+ layer.set(lineId, { lineId, raw });
173
+ this.files.set(file, layer);
174
+ }
175
+
176
+ has(file: string, lineId: string): boolean {
177
+ return this.files.get(file)?.has(lineId) ?? false;
178
+ }
179
+
180
+ rawOf(file: string, lineId: string): unknown {
181
+ return this.files.get(file)?.get(lineId)?.raw;
182
+ }
183
+
184
+ async readPatchLines(file: string): Promise<{ lineId: string; raw: unknown }[]> {
185
+ return [...(this.files.get(file)?.values() ?? [])];
186
+ }
187
+
188
+ async applyPatchChanges(
189
+ file: string,
190
+ changes: { lineId: string; raw: unknown; action: 'insert' | 'update' | 'remove' }[],
191
+ ): Promise<void> {
192
+ const layer = this.files.get(file) ?? new Map<string, { lineId: string; raw: unknown }>();
193
+ this.files.set(file, layer);
194
+ for (const c of changes) {
195
+ if (c.action === 'remove') layer.delete(c.lineId);
196
+ else layer.set(c.lineId, { lineId: c.lineId, raw: c.raw });
197
+ }
198
+ }
199
+ }
200
+
161
201
  export class MemSnapshotStore implements SnapshotStore {
162
202
  snapshots = new Map<string, Snapshot>();
163
203
  blobs = new Map<string, Uint8Array>();
@@ -203,6 +243,14 @@ export class MockHostContext implements HostContext {
203
243
  this.fs = new MemFs(homeDir);
204
244
  this.log = createLogger({ level: 'error', sink: () => {} });
205
245
  }
246
+
247
+ /** 换成按层键控的 patch 门面(层寻址契约测试用)。
248
+ * 字段的声明类型仍是单层的 MemPatch,以免既有夹具的 `.lines` 用法失型。 */
249
+ useLayeredPatch(): MemLayeredPatch {
250
+ const layered = new MemLayeredPatch();
251
+ this.patchFile = layered as unknown as MemPatch;
252
+ return layered;
253
+ }
206
254
  }
207
255
 
208
256
  export function makeContext(platform: string, homeDir: string, profile?: string): MockHostContext {
package/src/client/api.ts CHANGED
@@ -4,19 +4,10 @@
4
4
  */
5
5
  import { zhUiT, type UiT } from '../ui/i18n.ts'
6
6
 
7
- /** Host 半健康检查响应(plugin 版本 / DSH 版本 / 平台,用于主页横幅与兼容性说明) */
7
+ /** Host 半状态响应:设置页页脚版本行的两个字段(消费方只有 ConfigManagerSection) */
8
8
  export interface ServiceStatus {
9
- ready: boolean
10
9
  pluginVersion: string
11
10
  dshVersion: string
12
- platform: string
13
- arch: string
14
- homeDir?: string
15
- profile?: string
16
- profileManifestReadable?: boolean
17
- installedPluginCount?: number
18
- installedPluginNames?: string[]
19
- bundles?: string[]
20
11
  }
21
12
 
22
13
  export const CONFIG_MANAGER_API = {
@@ -2284,7 +2284,7 @@ button.statCard:focus-visible {
2284
2284
  padding: 1px 0;
2285
2285
  }
2286
2286
 
2287
- /* 选项行(加密/导出密钥复选内联) */
2287
+ /* 选项行(导出密钥复选内联) */
2288
2288
  .optionsRow {
2289
2289
  display: flex;
2290
2290
  align-items: center;
@@ -2,7 +2,7 @@
2
2
  * run-store 单测:模块级单例 store + sessionStorage 恢复(同步面板专属)。
3
3
  *
4
4
  * 覆盖:
5
- * - 敏感字段(token/webdavPassword/encryptPassword 等)绝不写入 sessionStorage(白名单剔除);
5
+ * - 敏感字段(token/webdavPassword 等)绝不写入 sessionStorage(白名单剔除);
6
6
  * - 内存瞬态(busy/savingConfig)不写入 sessionStorage,刷新后复位;
7
7
  * - 非敏感状态序列化/反序列化往返(新实例 + 同存储 = 模拟刷新);
8
8
  * - 损坏/版本不符数据回退默认并清除脏键;
@@ -8,7 +8,7 @@
8
8
  * dialogMask + dialogCard dialogWide + dialogHeaderRow + dialogClose +
9
9
  * dialogBodyScroll,零新增样式);
10
10
  * - **通道配置弹窗**:通道子 tab(GitHub(git)/ WebDAV)切换,两个通道的
11
- * 配置表单、自动同步、同步模式、是否加密、远端快照**各自独立**;关闭弹窗
11
+ * 配置表单、自动同步、同步模式、远端快照**各自独立**;关闭弹窗
12
12
  * = 放弃本次操作(GitHub 登录流程进行中则一并取消,§8.12 约定);
13
13
  * - GitHub 子 tab:repoUrl(必填)+ 认证 token(可选,写入 DSH credentials 的提示)
14
14
  * + **GitHub OAuth device flow 登录**(登录块跟随 git 通道配置放在弹窗内:
@@ -24,7 +24,7 @@
24
24
  * 全部渲染模型来自 ./sync-view.ts 纯函数(node 单测覆盖),组件只做装配;
25
25
  * 状态组件内自持(useState),同时经 toSyncStoreSlice() 镜像进模块级 runStore:
26
26
  * 模块级单例保证「切 tab 不丢」,sessionStorage 白名单保证「刷新恢复」;
27
- * token/webdav 密码/加密与解密密码仅内存(state),成功后清空(已写入 DSH
27
+ * token/webdav 密码仅内存(state),成功后清空(已写入 DSH
28
28
  * credentials),持久化白名单硬性剔除(含 byChannel 内密码类字段),刷新后
29
29
  * 清空、需要时重新输入。
30
30
  */
@@ -89,7 +89,7 @@ interface SyncUiState {
89
89
  webdavUsername: string
90
90
  /** 仅内存:成功后清空(已写入 DSH credentials),绝不持久化/回显 */
91
91
  webdavPassword: string
92
- /** git/webdav 各自独立的设置状态(自动同步 / 同步模式 / 加密 / 快照) */
92
+ /** git/webdav 各自独立的设置状态(自动同步 / 同步模式 / 快照) */
93
93
  byChannel: {
94
94
  git: ChannelSyncState
95
95
  webdav: ChannelSyncState
@@ -174,7 +174,7 @@ const initial: SyncUiState = {
174
174
 
175
175
  /**
176
176
  * 从 runStore 恢复上次的同步 UI 状态(切 tab 回 / 刷新后挂载)。
177
- * 敏感字段(token/webdav 密码/加密与解密密码)只在内存切片里保留:切 tab 保留;
177
+ * 敏感字段(token/webdav 密码)只在内存切片里保留:切 tab 保留;
178
178
  * 刷新后已被持久化白名单清空(applyPersisted 强制归零)→ 需要时重新输入。
179
179
  * busy/savingConfig 为瞬态:切 tab 由模块级单例保留(切回仍显示进行中);
180
180
  * 刷新后白名单剔除 → 回复空闲。
@@ -240,7 +240,7 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
240
240
  })
241
241
  /** 更新当前激活通道的 byChannel 状态。 */
242
242
  const patchChannel = (p: Partial<ChannelSyncState>): void => patchChannelState(state.channel, p)
243
- /** 当前激活通道的设置状态(自动同步/模式/加密/快照)。 */
243
+ /** 当前激活通道的设置状态(自动同步/模式/快照)。 */
244
244
  const chState: ChannelSyncState = state.byChannel[state.channel]
245
245
  /** GitHub 流程态(不进 store 切片;commit 的镜像写幂等无害)。 */
246
246
  const patchGithub = (p: Partial<GithubUiState>): void => commit({
@@ -30,7 +30,6 @@ import { zhUiT, type UiT } from '../../ui/i18n.ts';
30
30
 
31
31
  /** 同步端点常量(与 Host 半 src/index.ts API 常量保持一致) */
32
32
  export const SYNC_API = {
33
- base: '/api/dsh-config-manager/sync',
34
33
  status: '/api/dsh-config-manager/sync/status',
35
34
  push: '/api/dsh-config-manager/sync/push',
36
35
  pull: '/api/dsh-config-manager/sync/pull',
@@ -331,7 +331,6 @@ export const en: Record<keyof typeof zh, string> = {
331
331
  'mode.defaultCount': 'Will sync {n} recommended section(s)',
332
332
  'mode.sectionRecommended': 'Recommended',
333
333
  'mode.persistHint': 'The mode and section selection are saved locally: both auto sync and manual push use this configuration (persists across restarts).',
334
- // Encryption & secret export (manual push only; auto sync always pushes plain snapshots and skips encrypted ones)
335
334
  'autosync.title': 'Auto Sync',
336
335
  'autosync.description': 'When enabled, DSH keeps config in sync in the background: uploads locally-changed config automatically, pulls and overwrites only when it detects a new remote snapshot; the timer is just a fallback poll and no-ops when nothing changed. Local writes happen only when there are no conflicts or manual-decision items.',
337
336
  'autosync.enable': 'Enable auto sync',
@@ -526,8 +526,9 @@ export class Analyzer {
526
526
  throw new ImportNotConfirmedError(this.msg);
527
527
  }
528
528
 
529
- // 10b. 加密不变量:加密备份必须已成功解密(decryptedCredentials 由宿主用备份密码
530
- // 解开 security/secrets.enc 后注入)。未解密(undefined)一律拒绝执行——
529
+ // 10b. 旧版加密备份的兼容不变量:本插件已无加密层,但历史产物仍可能带
530
+ // manifest.security.encrypted=true。这类备份的凭据必须由宿主解密后注入
531
+ // (decryptedCredentials);未注入(undefined)一律拒绝执行——
531
532
  // 不允许把加密凭据静默降级为「缺凭据照常导入」,否则加密备份与普通备份无区别。
532
533
  if (bundle.manifest.security.encrypted && opts.decryptedCredentials === undefined) {
533
534
  throw new Error(this.msg('import.encryptedPasswordRequired'));
@@ -725,8 +726,8 @@ export class Analyzer {
725
726
  // M1:导入成功 → 快照标记 done(元数据写失败只告警,不改变导入结论)
726
727
  await this.markSnapshotStatus(snapshot.id, 'done');
727
728
 
728
- // F1 vault 回填:导出时敏感文件(.credentials.yaml 等)明文未进备份(includeSecrets=false
729
- // 时镜像到 <dataDir>/vault),导入成功后从本机 vault 回填 $DSH_HOME;vault 缺失
729
+ // F1 vault 回填:includeSecrets=false 的导出会把敏感文件(.credentials.yaml 等)镜像到
730
+ // <dataDir>/vault 而不进备份,导入成功后从本机 vault 回填 $DSH_HOME;vault 缺失
730
731
  // (跨机恢复 / 从未镜像过)记入警告提示用户重填。尽力而为:失败仅警告,不影响导入结论。
731
732
  try {
732
733
  const vaultDataDir = path.join(this.ctx.homeDir, 'dsh-config-manager');
@@ -9,6 +9,7 @@ import fs from 'node:fs/promises';
9
9
  import path from 'node:path';
10
10
  import crypto from 'node:crypto';
11
11
  import { parseJsonSafe } from '../utils/json.ts';
12
+ import { parsePatchLayerKey } from './patch-layers.ts';
12
13
  import { sha256Hex } from '../utils/hashing.ts';
13
14
  import { normalizePath } from '../utils/paths.ts';
14
15
  import { atomicWriteFile } from '../utils/atomic-write.ts';
@@ -206,10 +207,11 @@ async function engineSnapshotEntry(ctx: HostContext, target: SnapshotTarget): Pr
206
207
  copiedTo: `blobs/${crypto.randomUUID()}`,
207
208
  };
208
209
  }
209
- // patchLine:从组合 patch 文件读取原行(file 为必填的 file 字段约定为 'cordis.patch.yml')
210
- const file = 'cordis.patch.yml';
210
+ // patchLine:ref 是层限定复合键(<file>#<lineId>),据此到**对应层**读原行。
211
+ // 旧快照的裸 lineId 由 parsePatchLayerKey 兼容为 home 层。
212
+ const { file, lineId } = parsePatchLayerKey(target.ref);
211
213
  const lines = await ctx.patchFile.readPatchLines(file);
212
- const line = lines.find((l) => l.lineId === target.ref);
214
+ const line = lines.find((l) => l.lineId === lineId);
213
215
  return { kind: 'patchLine', adapter: target.adapter, ref: target.ref, before: line?.raw ?? null, existed: line !== undefined };
214
216
  }
215
217
  case 'skills':
@@ -474,7 +474,10 @@ test('自动快照端到端:真实变更 → 防抖到期 → 落盘一份 aut
474
474
  const metas = await h.lifecycle.list();
475
475
  assert.equal(metas[0]!.kind, 'auto');
476
476
  assert.equal(metas[0]!.trigger, 'watcher');
477
- assert.equal(h.autoMetas.length, 1, '宿主回调必须被触发');
477
+ // 回调在 saveSnapshot 落盘**之后**才触发,所以「等文件出现」不等于「回调已跑」:
478
+ // 直接断言会在负载高时偶发失败。等回调本身,而不是等它的副作用。
479
+ const fired = await waitFor(async () => h.autoMetas.length === 1);
480
+ assert.equal(fired, true, '宿主回调必须被触发');
478
481
  });
479
482
 
480
483
  test('自动快照端到端:恢复自写文件是回声 → 不产生快照(否则会挡住重做)', async (t) => {