@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.
- package/lib/adapters/credentials.d.ts +1 -1
- package/lib/adapters/index.d.ts +1 -1
- package/lib/adapters/index.js +1 -1
- package/lib/adapters/mcp.js +4 -4
- package/lib/adapters/plugins.d.ts +0 -1
- package/lib/adapters/plugins.js +35 -19
- package/lib/adapters/prompts.js +6 -6
- package/lib/adapters/test-helpers.d.ts +27 -0
- package/lib/adapters/test-helpers.js +40 -0
- package/lib/client.d.ts +1 -13
- package/lib/client.js +297 -304
- package/lib/core/analyzer.js +5 -4
- package/lib/core/backup.js +5 -3
- package/lib/core/exporter.d.ts +1 -4
- package/lib/core/exporter.js +14 -45
- package/lib/core/importer.d.ts +1 -1
- package/lib/core/index.d.ts +1 -1
- package/lib/core/messages.d.ts +1 -5
- package/lib/core/messages.js +2 -10
- package/lib/core/patch-layers.d.ts +32 -0
- package/lib/core/patch-layers.js +39 -0
- package/lib/core/rollback.js +4 -4
- package/lib/core/types.d.ts +8 -12
- package/lib/index.d.ts +23 -14
- package/lib/index.js +31 -92
- package/lib/security/index.d.ts +1 -3
- package/lib/security/index.js +1 -3
- package/lib/sync/transport.d.ts +2 -2
- package/lib/sync/transport.js +1 -1
- package/lib/ui/i18n.d.ts +0 -3
- package/lib/ui/i18n.js +0 -6
- package/lib/ui/report.js +1 -2
- package/lib/ui/test-helpers.d.ts +0 -6
- package/lib/ui/test-helpers.js +1 -5
- package/lib/ui/types.d.ts +0 -13
- package/lib/utils/env-lock.d.ts +16 -0
- package/lib/utils/env-lock.js +27 -6
- package/package.json +2 -2
- package/src/adapters/credentials.ts +1 -1
- package/src/adapters/index.ts +1 -1
- package/src/adapters/mcp.ts +4 -4
- package/src/adapters/patch-layers.test.ts +201 -0
- package/src/adapters/plugins.test.ts +13 -5
- package/src/adapters/plugins.ts +33 -17
- package/src/adapters/prompts.ts +6 -6
- package/src/adapters/test-helpers.ts +48 -0
- package/src/client/api.ts +1 -10
- package/src/client/config-manager.module.css +1 -1
- package/src/client/run-store.test.ts +1 -1
- package/src/client/sync/SyncSettingsView.tsx +5 -5
- package/src/client/sync/sync-api.ts +0 -1
- package/src/client/sync/sync-locales.ts +0 -1
- package/src/core/analyzer.ts +5 -4
- package/src/core/backup.ts +5 -3
- package/src/core/config-lifecycle.test.ts +4 -1
- package/src/core/exporter.ts +15 -47
- package/src/core/importer.ts +1 -1
- package/src/core/index.ts +1 -1
- package/src/core/messages.ts +2 -10
- package/src/core/patch-layers.ts +42 -0
- package/src/core/rollback.ts +4 -4
- package/src/core/smoke.test.ts +5 -5
- package/src/core/types.ts +8 -12
- package/src/index.facade.test.ts +25 -2
- package/src/index.ts +30 -104
- package/src/security/index.ts +1 -3
- package/src/security/security.test.ts +0 -361
- package/src/sync/transport.ts +3 -3
- package/src/ui/i18n.ts +0 -6
- package/src/ui/report.ts +1 -2
- package/src/ui/test-helpers.ts +3 -7
- package/src/ui/types.ts +0 -10
- package/src/utils/env-lock.test.ts +29 -0
- package/src/utils/env-lock.ts +27 -6
- package/lib/security/encryption.d.ts +0 -85
- package/lib/security/encryption.js +0 -279
- 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
|
|
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
|
-
|
|
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('
|
|
239
|
-
assert.equal(byId.get('
|
|
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('
|
|
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 } });
|
package/src/adapters/plugins.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* plugins 分区 adapter(设计 §3.3/§8):
|
|
3
|
-
* 数据源 = ctx.plugins.listInstalled()(插件清单)+
|
|
3
|
+
* 数据源 = ctx.plugins.listInstalled()(插件清单)+ 两个 patch 层(home 与 profile 的 cordis.patch.yml)。
|
|
4
4
|
*
|
|
5
5
|
* 安全不变量:绝不打包插件二进制;导入走 DSH 官方机制(dsh plugin CLI → needsRestart 提示)。
|
|
6
|
-
* patch
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
421
|
-
const
|
|
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
|
|
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
|
|
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 ===
|
|
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(
|
|
568
|
-
{ lineId
|
|
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> {
|
package/src/adapters/prompts.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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 = {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* run-store 单测:模块级单例 store + sessionStorage 恢复(同步面板专属)。
|
|
3
3
|
*
|
|
4
4
|
* 覆盖:
|
|
5
|
-
* - 敏感字段(token/webdavPassword
|
|
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
|
|
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
|
|
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',
|
package/src/core/analyzer.ts
CHANGED
|
@@ -526,8 +526,9 @@ export class Analyzer {
|
|
|
526
526
|
throw new ImportNotConfirmedError(this.msg);
|
|
527
527
|
}
|
|
528
528
|
|
|
529
|
-
// 10b.
|
|
530
|
-
//
|
|
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
|
|
729
|
-
//
|
|
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');
|
package/src/core/backup.ts
CHANGED
|
@@ -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
|
|
210
|
-
|
|
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 ===
|
|
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
|
-
|
|
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) => {
|