dsh-config-manager 0.1.12 → 0.1.13
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/README.md +30 -0
- package/README.zh-CN.md +30 -0
- package/lib/adapters/test-helpers.d.ts +4 -2
- package/lib/adapters/test-helpers.js +11 -3
- package/lib/adapters/test-helpers.js.map +1 -1
- package/lib/cli/index.d.ts +44 -0
- package/lib/cli/index.js +230 -0
- package/lib/cli/index.js.map +1 -0
- package/lib/client.d.ts +260 -5
- package/lib/client.js +2519 -820
- package/lib/client.js.map +1 -1
- package/lib/core/analyzer.d.ts +18 -1
- package/lib/core/analyzer.js +29 -0
- package/lib/core/analyzer.js.map +1 -1
- package/lib/core/backup.d.ts +3 -1
- package/lib/core/backup.js +70 -0
- package/lib/core/backup.js.map +1 -1
- package/lib/core/exporter.d.ts +11 -0
- package/lib/core/exporter.js +11 -0
- package/lib/core/exporter.js.map +1 -1
- package/lib/core/importer.d.ts +3 -0
- package/lib/core/importer.js +1 -0
- package/lib/core/importer.js.map +1 -1
- package/lib/core/index.d.ts +1 -0
- package/lib/core/index.js +1 -0
- package/lib/core/index.js.map +1 -1
- package/lib/core/restore.d.ts +92 -0
- package/lib/core/restore.js +505 -0
- package/lib/core/restore.js.map +1 -0
- package/lib/core/run-registry.d.ts +65 -0
- package/lib/core/run-registry.js +120 -0
- package/lib/core/run-registry.js.map +1 -0
- package/lib/core/types.d.ts +20 -0
- package/lib/core/types.js.map +1 -1
- package/lib/index.d.ts +45 -0
- package/lib/index.js +442 -8
- package/lib/index.js.map +1 -1
- package/lib/sync/fs.d.ts +18 -0
- package/lib/sync/fs.js +47 -0
- package/lib/sync/fs.js.map +1 -0
- package/lib/sync/git/git-transport.d.ts +87 -0
- package/lib/sync/git/git-transport.js +333 -0
- package/lib/sync/git/git-transport.js.map +1 -0
- package/lib/sync/layout.d.ts +29 -0
- package/lib/sync/layout.js +166 -0
- package/lib/sync/layout.js.map +1 -0
- package/lib/sync/sync-config.d.ts +16 -0
- package/lib/sync/sync-config.js +67 -0
- package/lib/sync/sync-config.js.map +1 -0
- package/lib/sync/sync-engine.d.ts +96 -0
- package/lib/sync/sync-engine.js +236 -0
- package/lib/sync/sync-engine.js.map +1 -0
- package/lib/sync/sync-state.d.ts +31 -0
- package/lib/sync/sync-state.js +103 -0
- package/lib/sync/sync-state.js.map +1 -0
- package/lib/sync/transport.d.ts +50 -0
- package/lib/sync/transport.js +22 -0
- package/lib/sync/transport.js.map +1 -0
- package/package.json +4 -1
- package/src/adapters/test-helpers.ts +10 -3
- package/src/cli/index.ts +278 -0
- package/src/client/ConfigManagerSection.tsx +49 -14
- package/src/client/api.ts +53 -5
- package/src/client/client-types.ts +0 -6
- package/src/client/common/ProgressBar.tsx +31 -19
- package/src/client/common/progress-view-edge.test.ts +60 -0
- package/src/client/common/progress-view.test.ts +79 -0
- package/src/client/common/progress-view.ts +94 -0
- package/src/client/config-manager.module.css +99 -20
- package/src/client/export/ExportView.tsx +63 -49
- package/src/client/import/ImportWizardView.tsx +107 -66
- package/src/client/index.ts +22 -0
- package/src/client/locales.ts +53 -0
- package/src/client/run-store.test.ts +500 -0
- package/src/client/run-store.ts +772 -0
- package/src/client/snapshots/SnapshotsPanel.tsx +240 -0
- package/src/client/sync/SyncSettingsView.tsx +261 -0
- package/src/client/sync/sync-api.test.ts +117 -0
- package/src/client/sync/sync-api.ts +132 -0
- package/src/client/sync/sync-locales.ts +77 -0
- package/src/client/sync/sync-view.test.ts +211 -0
- package/src/client/sync/sync-view.ts +202 -0
- package/src/core/analyzer.ts +50 -1
- package/src/core/backup.ts +78 -2
- package/src/core/exporter.ts +21 -0
- package/src/core/importer.ts +4 -0
- package/src/core/index.ts +5 -0
- package/src/core/restore.ts +587 -0
- package/src/core/run-progress.test.ts +237 -0
- package/src/core/run-registry.test.ts +139 -0
- package/src/core/run-registry.ts +166 -0
- package/src/core/smoke.test.ts +5 -0
- package/src/core/types.ts +22 -0
- package/src/index.ts +476 -8
- package/src/security/security.test.ts +5 -0
- package/src/sync/fs.ts +46 -0
- package/src/sync/git/git-transport.test.ts +456 -0
- package/src/sync/git/git-transport.ts +390 -0
- package/src/sync/layout.test.ts +226 -0
- package/src/sync/layout.ts +183 -0
- package/src/sync/sync-config.ts +72 -0
- package/src/sync/sync-engine.test.ts +299 -0
- package/src/sync/sync-engine.ts +318 -0
- package/src/sync/sync-state.test.ts +118 -0
- package/src/sync/sync-state.ts +123 -0
- package/src/sync/transport.test.ts +110 -0
- package/src/sync/transport.ts +73 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* m-sync-transport:散文件目录布局。
|
|
3
|
+
* 快照根目录 = manifest.json + 按 SECTION_JSON_PATHS 平铺的 JSON 分区
|
|
4
|
+
* + 按 SECTION_FILE_PREFIXES 的文件类分区目录(skills 等真实文件)。
|
|
5
|
+
* 复用 src/schema/config.ts 的路径表;核心逻辑经 SnapshotFs 注入(默认 node:fs/promises)。
|
|
6
|
+
*/
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { parseJsonSafe, stringifyJsonSafe } from '../utils/json.ts';
|
|
9
|
+
import { sha256Hex } from '../utils/hashing.ts';
|
|
10
|
+
import { SECTION_FILE_PREFIXES, SECTION_JSON_PATHS } from '../schema/config.ts';
|
|
11
|
+
import type { FilesSection, SectionData, SectionId } from '../schema/types.ts';
|
|
12
|
+
import { createSnapshotFs, joinFs } from './fs.ts';
|
|
13
|
+
import type { SnapshotFs } from './fs.ts';
|
|
14
|
+
import { hashSection } from './sync-state.ts';
|
|
15
|
+
import type { SyncSnapshot } from './transport.ts';
|
|
16
|
+
|
|
17
|
+
export const SNAPSHOT_MANIFEST_FILE = 'manifest.json';
|
|
18
|
+
|
|
19
|
+
/** 快照根目录下的 manifest.json 结构 */
|
|
20
|
+
export interface SnapshotDirManifest {
|
|
21
|
+
id: string;
|
|
22
|
+
createdAt: string; // ISO-8601 UTC
|
|
23
|
+
manifest: SyncSnapshot['manifest'];
|
|
24
|
+
/** 各分区内容 hash(写入时由 hashSection 计算) */
|
|
25
|
+
sectionHashes: Partial<Record<SectionId, string>>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 布局支持的分区 = SECTION_JSON_PATHS ∪ SECTION_FILE_PREFIXES(secrets 等不在内) */
|
|
29
|
+
const LAYOUT_SECTION_IDS: readonly SectionId[] = [
|
|
30
|
+
...Object.keys(SECTION_JSON_PATHS),
|
|
31
|
+
...Object.keys(SECTION_FILE_PREFIXES),
|
|
32
|
+
] as SectionId[];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 文件相对路径安全检查:拒绝空/'.'/'..'、绝对路径、Windows 盘符、反斜杠、
|
|
36
|
+
* 含 '..' 或 '.' 片段(防穿越快照根目录)。
|
|
37
|
+
*/
|
|
38
|
+
export function isSafeRelPath(rel: string): boolean {
|
|
39
|
+
if (rel === '' || rel === '.' || rel === '..') return false;
|
|
40
|
+
if (rel.startsWith('/') || /^[A-Za-z]:[\\/]/.test(rel)) return false;
|
|
41
|
+
if (rel.includes('\\')) return false;
|
|
42
|
+
const parts = rel.split('/');
|
|
43
|
+
if (parts.some((p) => p === '' || p === '.' || p === '..')) return false;
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 递归列出 dir 下的全部文件(相对 dir 的正斜杠路径,字典序);目录不存在 → [] */
|
|
48
|
+
export async function listSnapshotFiles(fsx: SnapshotFs, dir: string): Promise<string[]> {
|
|
49
|
+
const out: string[] = [];
|
|
50
|
+
const walk = async (abs: string, rel: string): Promise<void> => {
|
|
51
|
+
const names = await fsx.readdir(abs);
|
|
52
|
+
for (const name of names) {
|
|
53
|
+
if (name === '' || name === '.' || name === '..') continue; // 防御异常/恶意 fs
|
|
54
|
+
const childAbs = joinFs(abs, name);
|
|
55
|
+
const childRel = rel === '' ? name : `${rel}/${name}`;
|
|
56
|
+
if (await fsx.isDir(childAbs)) {
|
|
57
|
+
await walk(childAbs, childRel);
|
|
58
|
+
} else {
|
|
59
|
+
out.push(childRel);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
await walk(dir, '');
|
|
64
|
+
return out.sort();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 将快照写入散文件目录。
|
|
69
|
+
* 非法输入(不支持的子分区 / 穿越路径)在任何写入发生前抛错,不留半成品目录。
|
|
70
|
+
*/
|
|
71
|
+
export async function writeSnapshotToDir(
|
|
72
|
+
snapshot: SyncSnapshot,
|
|
73
|
+
dir: string,
|
|
74
|
+
fsx: SnapshotFs = createSnapshotFs(),
|
|
75
|
+
): Promise<SnapshotDirManifest> {
|
|
76
|
+
const sectionHashes: SnapshotDirManifest['sectionHashes'] = {};
|
|
77
|
+
for (const [id, data] of Object.entries(snapshot.sections)) {
|
|
78
|
+
const sid = id as SectionId;
|
|
79
|
+
if (!LAYOUT_SECTION_IDS.includes(sid)) {
|
|
80
|
+
throw new Error(`分区 ${sid} 不在散文件布局支持范围(SECTION_JSON_PATHS / SECTION_FILE_PREFIXES)`);
|
|
81
|
+
}
|
|
82
|
+
sectionHashes[sid] = hashSection(data as SectionData);
|
|
83
|
+
}
|
|
84
|
+
// 预校验全部文件相对路径(不合法 → 抛错且零写入)
|
|
85
|
+
for (const [sid, data] of Object.entries(snapshot.sections)) {
|
|
86
|
+
if (!(sid in SECTION_FILE_PREFIXES)) continue;
|
|
87
|
+
for (const file of (data as FilesSection).files) {
|
|
88
|
+
if (!isSafeRelPath(file.relativePath)) {
|
|
89
|
+
throw new Error(`非法文件相对路径: ${file.relativePath}(分区 ${sid})`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
await fsx.mkdir(dir);
|
|
95
|
+
const manifest: SnapshotDirManifest = {
|
|
96
|
+
id: snapshot.id,
|
|
97
|
+
createdAt: snapshot.createdAt,
|
|
98
|
+
manifest: snapshot.manifest,
|
|
99
|
+
sectionHashes,
|
|
100
|
+
};
|
|
101
|
+
await fsx.writeFile(joinFs(dir, SNAPSHOT_MANIFEST_FILE), new TextEncoder().encode(stringifyJsonSafe(manifest, { space: 2 })));
|
|
102
|
+
|
|
103
|
+
// JSON 分区:按 SECTION_JSON_PATHS 平铺
|
|
104
|
+
for (const [sid, rel] of Object.entries(SECTION_JSON_PATHS)) {
|
|
105
|
+
const data = snapshot.sections[sid as SectionId];
|
|
106
|
+
if (data === undefined) continue;
|
|
107
|
+
const abs = joinFs(dir, rel);
|
|
108
|
+
await fsx.mkdir(path.dirname(abs));
|
|
109
|
+
await fsx.writeFile(abs, new TextEncoder().encode(stringifyJsonSafe(data, { space: 2 })));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 文件类分区:目录前缀 + 真实文件
|
|
113
|
+
for (const [sid, prefix] of Object.entries(SECTION_FILE_PREFIXES)) {
|
|
114
|
+
const data = snapshot.sections[sid as SectionId] as FilesSection | undefined;
|
|
115
|
+
if (data === undefined) continue;
|
|
116
|
+
const baseAbs = joinFs(dir, prefix);
|
|
117
|
+
await fsx.mkdir(baseAbs); // 空文件类分区也保留目录(读回可还原空 files)
|
|
118
|
+
for (const file of data.files) {
|
|
119
|
+
const abs = joinFs(baseAbs, file.relativePath);
|
|
120
|
+
await fsx.mkdir(path.dirname(abs));
|
|
121
|
+
await fsx.writeFile(abs, file.data);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return manifest;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 从散文件目录读回快照。
|
|
129
|
+
* manifest 声明的分区必须可解析:JSON 分区文件缺失 / 文件分区目录缺失 → 抛错(不静默降级)。
|
|
130
|
+
*/
|
|
131
|
+
export async function readSnapshotFromDir(dir: string, fsx: SnapshotFs = createSnapshotFs()): Promise<SyncSnapshot> {
|
|
132
|
+
const manifestAbs = joinFs(dir, SNAPSHOT_MANIFEST_FILE);
|
|
133
|
+
if (!(await fsx.exists(manifestAbs))) {
|
|
134
|
+
throw new Error(`快照目录缺少 ${SNAPSHOT_MANIFEST_FILE}: ${dir}`);
|
|
135
|
+
}
|
|
136
|
+
const parsed = parseJsonSafe(Buffer.from(await fsx.readFile(manifestAbs)).toString('utf8'));
|
|
137
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
138
|
+
throw new Error('manifest.json 损坏:必须是对象');
|
|
139
|
+
}
|
|
140
|
+
const m = parsed as Record<string, unknown>;
|
|
141
|
+
if (typeof m['id'] !== 'string' || typeof m['createdAt'] !== 'string'
|
|
142
|
+
|| m['manifest'] === null || typeof m['manifest'] !== 'object'
|
|
143
|
+
|| m['sectionHashes'] === null || typeof m['sectionHashes'] !== 'object') {
|
|
144
|
+
throw new Error('manifest.json 损坏:缺少 id/createdAt/manifest/sectionHashes');
|
|
145
|
+
}
|
|
146
|
+
const manifest = parsed as SnapshotDirManifest;
|
|
147
|
+
|
|
148
|
+
const sections: SyncSnapshot['sections'] = {};
|
|
149
|
+
const declared = Object.keys(manifest.sectionHashes) as SectionId[];
|
|
150
|
+
for (const sid of declared) {
|
|
151
|
+
if (!LAYOUT_SECTION_IDS.includes(sid)) {
|
|
152
|
+
throw new Error(`manifest 声明了布局不支持的分区 ${sid}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// JSON 分区
|
|
157
|
+
for (const [sid, rel] of Object.entries(SECTION_JSON_PATHS)) {
|
|
158
|
+
if (!(sid in manifest.sectionHashes)) continue;
|
|
159
|
+
const abs = joinFs(dir, rel);
|
|
160
|
+
if (!(await fsx.exists(abs))) {
|
|
161
|
+
throw new Error(`快照缺少 JSON 分区文件 ${rel}(${sid})`);
|
|
162
|
+
}
|
|
163
|
+
sections[sid as SectionId] = parseJsonSafe(Buffer.from(await fsx.readFile(abs)).toString('utf8')) as SectionData;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 文件类分区
|
|
167
|
+
for (const [sid, prefix] of Object.entries(SECTION_FILE_PREFIXES)) {
|
|
168
|
+
if (!(sid in manifest.sectionHashes)) continue;
|
|
169
|
+
const baseAbs = joinFs(dir, prefix);
|
|
170
|
+
if (!(await fsx.isDir(baseAbs))) {
|
|
171
|
+
throw new Error(`快照缺少文件分区目录 ${prefix}(${sid})`);
|
|
172
|
+
}
|
|
173
|
+
const rels = await listSnapshotFiles(fsx, baseAbs);
|
|
174
|
+
const files: FilesSection['files'] = [];
|
|
175
|
+
for (const rel of rels) {
|
|
176
|
+
const data = await fsx.readFile(joinFs(baseAbs, rel));
|
|
177
|
+
files.push({ relativePath: rel, data, contentHash: sha256Hex(data) });
|
|
178
|
+
}
|
|
179
|
+
sections[sid as SectionId] = { version: 1, files };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { id: manifest.id, createdAt: manifest.createdAt, manifest: manifest.manifest, sections };
|
|
183
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* m-sync-ui:同步仓库配置的持久化(sync-config.json)。
|
|
3
|
+
*
|
|
4
|
+
* 与 sync-state.json 的分工:sync-state 记录「同步了哪些分区、何时同步」(t4 拥有),
|
|
5
|
+
* 本文件只记录「上次使用的仓库配置」(repoUrl / gitBin),供 UI 打开设置页时回填表单。
|
|
6
|
+
* - repoUrl 不含 token(token 只存 DSH credentials,见 src/index.ts SYNC_CREDENTIAL_REF);
|
|
7
|
+
* - 文件位于同步状态目录($DSH_HOME/dsh-config-manager/sync/),非敏感(可回显给浏览器);
|
|
8
|
+
* - 纯逻辑 + 可注入 fs(node:fs/promises 直用;测试走临时目录)。
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs/promises';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { parseJsonSafe, stringifyJsonSafe } from '../utils/json.ts';
|
|
14
|
+
|
|
15
|
+
export const SYNC_CONFIG_FILE = 'sync-config.json';
|
|
16
|
+
|
|
17
|
+
/** 持久化的仓库配置(不含任何凭据) */
|
|
18
|
+
export interface SyncConfig {
|
|
19
|
+
repoUrl: string;
|
|
20
|
+
gitBin?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 读取仓库配置;文件不存在/损坏 → null(视为未配置,UI 显示空表单) */
|
|
24
|
+
export async function readSyncConfig(dir: string): Promise<SyncConfig | null> {
|
|
25
|
+
const file = path.join(dir, SYNC_CONFIG_FILE);
|
|
26
|
+
let raw: string;
|
|
27
|
+
try {
|
|
28
|
+
raw = await fs.readFile(file, 'utf8');
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const parsed = parseJsonSafe(raw);
|
|
33
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
34
|
+
const obj = parsed as Record<string, unknown>;
|
|
35
|
+
if (typeof obj['repoUrl'] !== 'string' || obj['repoUrl'] === '') return null;
|
|
36
|
+
const cfg: SyncConfig = { repoUrl: obj['repoUrl'] };
|
|
37
|
+
if (typeof obj['gitBin'] === 'string' && obj['gitBin'] !== '') cfg.gitBin = obj['gitBin'];
|
|
38
|
+
return cfg;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 保存仓库配置(自动创建目录;覆盖旧值) */
|
|
42
|
+
export async function writeSyncConfig(dir: string, cfg: SyncConfig): Promise<void> {
|
|
43
|
+
await fs.mkdir(dir, { recursive: true });
|
|
44
|
+
await fs.writeFile(path.join(dir, SYNC_CONFIG_FILE), stringifyJsonSafe(cfg, { space: 2 }), 'utf8');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 仓库地址合法性校验(返回错误消息;null = 合法)。
|
|
49
|
+
* 安全约束:token 永不拼入 repoUrl —— http(s) 地址带 userinfo(username[:password]@)直接拒绝,
|
|
50
|
+
* 引导用户把 token 放凭据字段(DSH credentials),避免 token 经 URL 泄漏进 git 历史/日志。
|
|
51
|
+
*/
|
|
52
|
+
export function validateRepoUrl(repoUrl: string): string | null {
|
|
53
|
+
if (typeof repoUrl !== 'string' || repoUrl.trim() === '') {
|
|
54
|
+
return 'repoUrl is required';
|
|
55
|
+
}
|
|
56
|
+
const url = repoUrl.trim();
|
|
57
|
+
if (/\s/.test(url)) {
|
|
58
|
+
return '仓库地址不能包含空白字符';
|
|
59
|
+
}
|
|
60
|
+
if (/^https?:\/\//i.test(url)) {
|
|
61
|
+
let parsed: URL;
|
|
62
|
+
try {
|
|
63
|
+
parsed = new URL(url);
|
|
64
|
+
} catch {
|
|
65
|
+
return `无法解析仓库地址: ${url}`;
|
|
66
|
+
}
|
|
67
|
+
if (parsed.username !== '' || parsed.password !== '') {
|
|
68
|
+
return '请勿在仓库地址中包含用户名/密码(认证 token 请使用凭据字段,不会拼入地址)';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* m-sync-flow:SyncEngine push/pull 编排测试。
|
|
3
|
+
* - push:收集 portable 分区 → 组装 SyncSnapshot → 更新 sync-state → 上传 transport
|
|
4
|
+
* - push:secret 断言(凭据值/敏感字段永不进入快照;不含 credentials/secrets 分区)
|
|
5
|
+
* - push:portable 过滤(deviceSpecific/platformSpecific 分区不参与同步)
|
|
6
|
+
* - pull:复用 Importer 预览流程(analyzeImport/createImportPlan),绝不直接写配置、绝不执行导入
|
|
7
|
+
* - pull:无远端快照 / containsSecrets 拒绝 / 冲突 → needsReview
|
|
8
|
+
*/
|
|
9
|
+
import test from 'node:test';
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import fs from 'node:fs/promises';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
import { SyncEngine } from './sync-engine.ts';
|
|
16
|
+
import { hashSection, loadSyncState, SYNC_STATE_FILE } from './sync-state.ts';
|
|
17
|
+
import { computeSnapshotMeta } from './transport.ts';
|
|
18
|
+
import type { SyncSnapshot, SyncSnapshotMeta, SyncTransport } from './transport.ts';
|
|
19
|
+
import { createAdapters } from '../adapters/index.ts';
|
|
20
|
+
import { makeContext, MemSnapshotStore } from '../adapters/test-helpers.ts';
|
|
21
|
+
import { Importer } from '../core/importer.ts';
|
|
22
|
+
import type { SectionId } from '../schema/types.ts';
|
|
23
|
+
|
|
24
|
+
/** 内存 SyncTransport:记录方法调用(spy),供断言「pull 不写远端」 */
|
|
25
|
+
class MemSyncTransport implements SyncTransport {
|
|
26
|
+
readonly type = 'memory';
|
|
27
|
+
snapshots = new Map<string, SyncSnapshot>();
|
|
28
|
+
metas: SyncSnapshotMeta[] = [];
|
|
29
|
+
calls: string[] = [];
|
|
30
|
+
async list(): Promise<SyncSnapshotMeta[]> {
|
|
31
|
+
this.calls.push('list');
|
|
32
|
+
return [...this.metas].sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1));
|
|
33
|
+
}
|
|
34
|
+
async upload(snapshot: SyncSnapshot): Promise<SyncSnapshotMeta> {
|
|
35
|
+
this.calls.push('upload');
|
|
36
|
+
this.snapshots.set(snapshot.id, snapshot);
|
|
37
|
+
this.metas.push(computeSnapshotMeta(snapshot));
|
|
38
|
+
return computeSnapshotMeta(snapshot);
|
|
39
|
+
}
|
|
40
|
+
async download(id: string): Promise<SyncSnapshot> {
|
|
41
|
+
this.calls.push('download');
|
|
42
|
+
const s = this.snapshots.get(id);
|
|
43
|
+
if (!s) throw new Error(`快照不存在: ${id}`);
|
|
44
|
+
return s;
|
|
45
|
+
}
|
|
46
|
+
async delete(id: string): Promise<void> {
|
|
47
|
+
this.calls.push('delete');
|
|
48
|
+
this.snapshots.delete(id);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const NS = ['general', 'theme'];
|
|
53
|
+
|
|
54
|
+
function seedSource(ctx: ReturnType<typeof makeContext>): void {
|
|
55
|
+
ctx.settings.ns.set('general', { value: { theme: 'dark', language: 'zh-CN' }, revision: 3, secrets: [] });
|
|
56
|
+
ctx.settings.ns.set('theme', { value: { mode: 'dark' }, revision: 1, secrets: [] });
|
|
57
|
+
ctx.plugins.installed.set('@deepseek-ai/dsh-ssh', { name: '@deepseek-ai/dsh-ssh', version: '1.0.0', enabled: true });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function makeEngine(opts: {
|
|
61
|
+
ctx: ReturnType<typeof makeContext>;
|
|
62
|
+
transport: MemSyncTransport;
|
|
63
|
+
stateDir: string;
|
|
64
|
+
localSnapshotsDir?: string;
|
|
65
|
+
extra?: ConstructorParameters<typeof SyncEngine>[0];
|
|
66
|
+
}) {
|
|
67
|
+
const adapters = createAdapters({ namespaces: NS });
|
|
68
|
+
const importer = new Importer({ ctx: opts.ctx, adapters, snapshotStore: new MemSnapshotStore() });
|
|
69
|
+
return new SyncEngine({
|
|
70
|
+
ctx: opts.ctx,
|
|
71
|
+
transport: opts.transport,
|
|
72
|
+
stateDir: opts.stateDir,
|
|
73
|
+
localSnapshotsDir: opts.localSnapshotsDir,
|
|
74
|
+
adapters,
|
|
75
|
+
importer,
|
|
76
|
+
now: () => new Date('2026-08-16T12:00:00.000Z'),
|
|
77
|
+
...opts.extra,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
test('push: 收集 portable 分区 → 上传快照 → 更新 sync-state → 本地散文件副本', async () => {
|
|
82
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-push-'));
|
|
83
|
+
try {
|
|
84
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
85
|
+
seedSource(ctx);
|
|
86
|
+
await ctx.fs.writeFile('skills/coding.md', Buffer.from('# Coding\n', 'utf8'));
|
|
87
|
+
const transport = new MemSyncTransport();
|
|
88
|
+
const local = path.join(tmp, 'local-snapshots');
|
|
89
|
+
const engine = makeEngine({ ctx, transport, stateDir: tmp, localSnapshotsDir: local });
|
|
90
|
+
|
|
91
|
+
const report = await engine.push({ snapshotId: 'sync-001' });
|
|
92
|
+
assert.equal(report.ok, true);
|
|
93
|
+
assert.equal(report.snapshotId, 'sync-001');
|
|
94
|
+
assert.ok(report.sections.includes('settings'), 'settings 进入同步');
|
|
95
|
+
assert.ok(report.sections.includes('skills'), 'skills(文件类 portable)进入同步');
|
|
96
|
+
assert.ok(!report.sections.includes('credentialsStatus'), 'credentials 不进入同步');
|
|
97
|
+
|
|
98
|
+
// 上传载荷:内容 + manifest 摘要
|
|
99
|
+
const uploaded = transport.snapshots.get('sync-001')!;
|
|
100
|
+
assert.ok(uploaded, '快照已上传');
|
|
101
|
+
assert.equal(uploaded.createdAt, '2026-08-16T12:00:00.000Z');
|
|
102
|
+
assert.equal(uploaded.manifest.containsSecrets, false);
|
|
103
|
+
const exportedGeneral = (uploaded.sections['settings'] as { namespaces: Record<string, { value: unknown; revision: number; secrets: unknown[] }> }).namespaces['general']!;
|
|
104
|
+
assert.ok(exportedGeneral, 'settings.general 已导出');
|
|
105
|
+
assert.deepEqual(exportedGeneral.value, { theme: 'dark', language: 'zh-CN' });
|
|
106
|
+
assert.equal(exportedGeneral.revision, 3);
|
|
107
|
+
assert.deepEqual(exportedGeneral.secrets, []);
|
|
108
|
+
assert.equal((uploaded.sections['skills'] as { files: unknown[] }).files.length, 1);
|
|
109
|
+
|
|
110
|
+
// sync-state 更新:每分区 hash + updatedAt + lastSyncAt + transport 绑定
|
|
111
|
+
const state = await loadSyncState(tmp);
|
|
112
|
+
assert.equal(state.lastSyncAt, '2026-08-16T12:00:00.000Z');
|
|
113
|
+
assert.equal(state.sections['settings']?.hash, hashSection(uploaded.sections['settings']!));
|
|
114
|
+
assert.equal(state.sections['settings']?.updatedAt, '2026-08-16T12:00:00.000Z');
|
|
115
|
+
assert.deepEqual(state.transport, { type: 'memory', ref: '' });
|
|
116
|
+
const raw = JSON.parse(await fs.readFile(path.join(tmp, SYNC_STATE_FILE), 'utf8'));
|
|
117
|
+
assert.equal(raw.schemaVersion, 1);
|
|
118
|
+
|
|
119
|
+
// 本地散文件副本(复用 t2 layout 布局)
|
|
120
|
+
assert.ok((await fs.stat(path.join(local, 'sync-001', 'manifest.json'))).isFile());
|
|
121
|
+
assert.ok((await fs.stat(path.join(local, 'sync-001', 'config', 'settings.json'))).isFile());
|
|
122
|
+
assert.equal(await fs.readFile(path.join(local, 'sync-001', 'custom', 'skills', 'coding.md'), 'utf8'), '# Coding\n');
|
|
123
|
+
} finally {
|
|
124
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('push: secret 断言——敏感字段值被剥离、凭据分区绝不参与、快照序列化不含秘密值', async () => {
|
|
129
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-secret-'));
|
|
130
|
+
try {
|
|
131
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
132
|
+
ctx.settings.ns.set('general', {
|
|
133
|
+
value: { theme: 'dark', apiToken: 'sk-super-secret-value', password: 'p@ss' },
|
|
134
|
+
revision: 3,
|
|
135
|
+
secrets: [{ path: ['apiToken'], set: true }],
|
|
136
|
+
});
|
|
137
|
+
// deviceSpecific 凭据分区即使有值也不得进入快照
|
|
138
|
+
ctx.credentials.values.set('DEEPSEEK_API_KEY', 'sk-credential-secret');
|
|
139
|
+
const transport = new MemSyncTransport();
|
|
140
|
+
const engine = makeEngine({ ctx, transport, stateDir: tmp });
|
|
141
|
+
|
|
142
|
+
const report = await engine.push({ snapshotId: 'sync-sec' });
|
|
143
|
+
assert.equal(report.ok, true);
|
|
144
|
+
const uploaded = transport.snapshots.get('sync-sec')!;
|
|
145
|
+
const serialized = JSON.stringify(uploaded);
|
|
146
|
+
assert.ok(!serialized.includes('sk-super-secret-value'), '敏感字段值不得进入快照');
|
|
147
|
+
assert.ok(!serialized.includes('p@ss'), '密码值不得进入快照');
|
|
148
|
+
assert.ok(!serialized.includes('sk-credential-secret'), '凭据值不得进入快照');
|
|
149
|
+
for (const forbidden of ['credentials', 'credentialsStatus', 'secrets'] as SectionId[]) {
|
|
150
|
+
assert.ok(!(forbidden in uploaded.sections), `分区 ${forbidden} 不得进入快照`);
|
|
151
|
+
}
|
|
152
|
+
// 剥离后保留字段名与空值(供「需补录」提示)
|
|
153
|
+
const general = (uploaded.sections['settings'] as { namespaces: Record<string, unknown> }).namespaces['general'] as { value: Record<string, unknown> };
|
|
154
|
+
assert.equal(general.value['apiToken'], '');
|
|
155
|
+
} finally {
|
|
156
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('push: portable 过滤——deviceSpecific/platformSpecific 分区不参与同步', async () => {
|
|
161
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-portable-'));
|
|
162
|
+
try {
|
|
163
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
164
|
+
seedSource(ctx);
|
|
165
|
+
ctx.workspace.records.set('w1', { id: 'w1', path: 'C:\\work', title: 'work', sessionIds: [] });
|
|
166
|
+
ctx.credentials.values.set('DEEPSEEK_API_KEY', 'sk-x');
|
|
167
|
+
await ctx.fs.writeFile('dsh-ssh.json', Buffer.from('{"hosts":{}}', 'utf8')); // pluginFiles 白名单
|
|
168
|
+
const transport = new MemSyncTransport();
|
|
169
|
+
const engine = makeEngine({ ctx, transport, stateDir: tmp });
|
|
170
|
+
|
|
171
|
+
await engine.push({ snapshotId: 'sync-p' });
|
|
172
|
+
const uploaded = transport.snapshots.get('sync-p')!;
|
|
173
|
+
for (const forbidden of ['workspaces', 'mcp', 'credentialsStatus', 'credentials', 'pluginFiles', 'sessions'] as SectionId[]) {
|
|
174
|
+
assert.ok(!(forbidden in uploaded.sections), `非 portable 分区 ${forbidden} 不得进入快照`);
|
|
175
|
+
}
|
|
176
|
+
assert.ok(!uploaded.manifest.sectionIds.includes('workspaces' as SectionId));
|
|
177
|
+
assert.ok(uploaded.manifest.sectionIds.includes('settings'));
|
|
178
|
+
} finally {
|
|
179
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('pull: 复用 Importer 预览流程,绝不直接写配置,产出差异报告', async () => {
|
|
184
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-pull-'));
|
|
185
|
+
try {
|
|
186
|
+
const remote: SyncSnapshot = {
|
|
187
|
+
id: 'remote-1',
|
|
188
|
+
createdAt: '2026-08-16T12:00:00.000Z',
|
|
189
|
+
manifest: { schemaVersion: 1, dshVersion: '1.2.3', platform: 'win32', sectionIds: ['settings'], containsSecrets: false },
|
|
190
|
+
sections: {
|
|
191
|
+
settings: { version: 1, namespaces: { general: { value: { theme: 'dark', language: 'zh-CN' }, revision: 5, secrets: [] } } },
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
const transport = new MemSyncTransport();
|
|
195
|
+
transport.snapshots.set('remote-1', remote);
|
|
196
|
+
transport.metas.push(computeSnapshotMeta(remote));
|
|
197
|
+
|
|
198
|
+
// 本地目标:general 已注册但从未配置 → Create(初始化),无需人工决策
|
|
199
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
200
|
+
for (const n of NS) ctx.settings.registered.add(n);
|
|
201
|
+
const engine = makeEngine({ ctx, transport, stateDir: tmp });
|
|
202
|
+
|
|
203
|
+
const report = await engine.pull();
|
|
204
|
+
assert.equal(report.ok, true);
|
|
205
|
+
assert.equal(report.snapshotId, 'remote-1');
|
|
206
|
+
const createItem = report.changes.find((c) => c.id === 'settings:general');
|
|
207
|
+
assert.ok(createItem, '差异报告含 settings:general');
|
|
208
|
+
assert.equal(createItem?.kind, 'Create');
|
|
209
|
+
assert.equal(createItem?.adapter, 'settings');
|
|
210
|
+
assert.equal(report.needsReview, false, '纯 Create 无需人工决策');
|
|
211
|
+
|
|
212
|
+
// 零写入:目标配置未被修改,远端未被写
|
|
213
|
+
assert.equal(ctx.settings.ns.get('general'), undefined, '目标 settings 未被写入');
|
|
214
|
+
assert.deepEqual(transport.calls, ['list', 'download'], 'pull 只读远端(list/download),不 upload/delete');
|
|
215
|
+
} finally {
|
|
216
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('pull: 冲突项 → needsReview=true,仍零写入', async () => {
|
|
221
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-conflict-'));
|
|
222
|
+
try {
|
|
223
|
+
const remote: SyncSnapshot = {
|
|
224
|
+
id: 'remote-2',
|
|
225
|
+
createdAt: '2026-08-16T12:00:00.000Z',
|
|
226
|
+
manifest: { schemaVersion: 1, dshVersion: '1.2.3', platform: 'win32', sectionIds: ['settings'], containsSecrets: false },
|
|
227
|
+
sections: {
|
|
228
|
+
settings: { version: 1, namespaces: { general: { value: { theme: 'dark' }, revision: 5, secrets: [] } } },
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
const transport = new MemSyncTransport();
|
|
232
|
+
transport.snapshots.set('remote-2', remote);
|
|
233
|
+
transport.metas.push(computeSnapshotMeta(remote));
|
|
234
|
+
|
|
235
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
236
|
+
for (const n of NS) ctx.settings.registered.add(n);
|
|
237
|
+
ctx.settings.ns.set('general', { value: { theme: 'light' }, revision: 9, secrets: [] });
|
|
238
|
+
const engine = makeEngine({ ctx, transport, stateDir: tmp });
|
|
239
|
+
|
|
240
|
+
const report = await engine.pull();
|
|
241
|
+
assert.equal(report.ok, true);
|
|
242
|
+
assert.ok(report.changes.some((c) => c.kind === 'Conflict'), '本地与远端不同 → Conflict');
|
|
243
|
+
assert.equal(report.needsReview, true, '冲突需人工决策');
|
|
244
|
+
assert.deepEqual(ctx.settings.ns.get('general')?.value, { theme: 'light' }, '目标未被覆盖');
|
|
245
|
+
} finally {
|
|
246
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('pull: 远端无快照 → 空报告(不报错)', async () => {
|
|
251
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-none-'));
|
|
252
|
+
try {
|
|
253
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
254
|
+
const engine = makeEngine({ ctx, transport: new MemSyncTransport(), stateDir: tmp });
|
|
255
|
+
const report = await engine.pull();
|
|
256
|
+
assert.equal(report.ok, true);
|
|
257
|
+
assert.equal(report.snapshotId, '');
|
|
258
|
+
assert.deepEqual(report.changes, []);
|
|
259
|
+
assert.equal(report.needsReview, false);
|
|
260
|
+
assert.ok(report.message && report.message.includes('无快照'));
|
|
261
|
+
} finally {
|
|
262
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('pull: 远端快照声明 containsSecrets=true → 拒绝(同步通道永不携带秘密)', async () => {
|
|
267
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-leak-'));
|
|
268
|
+
try {
|
|
269
|
+
const remote: SyncSnapshot = {
|
|
270
|
+
id: 'remote-bad',
|
|
271
|
+
createdAt: '2026-08-16T12:00:00.000Z',
|
|
272
|
+
manifest: { schemaVersion: 1, dshVersion: '1.2.3', platform: 'win32', sectionIds: ['settings'], containsSecrets: true },
|
|
273
|
+
sections: { settings: { version: 1, namespaces: {} } },
|
|
274
|
+
};
|
|
275
|
+
const transport = new MemSyncTransport();
|
|
276
|
+
transport.snapshots.set('remote-bad', remote);
|
|
277
|
+
transport.metas.push(computeSnapshotMeta(remote));
|
|
278
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
279
|
+
const engine = makeEngine({ ctx, transport, stateDir: tmp });
|
|
280
|
+
await assert.rejects(() => engine.pull(), /秘密|containsSecrets/);
|
|
281
|
+
} finally {
|
|
282
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('push: 全部 portable 分区导出失败 → ok=false + 明确 message', async () => {
|
|
287
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-fail-'));
|
|
288
|
+
try {
|
|
289
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice'); // 无任何 namespace/文件/插件
|
|
290
|
+
const transport = new MemSyncTransport();
|
|
291
|
+
const engine = makeEngine({ ctx, transport, stateDir: tmp });
|
|
292
|
+
const report = await engine.push({ snapshotId: 'sync-fail' });
|
|
293
|
+
// 空数据仍视为「成功导出(空分区)」还是失败?settings 无 namespace 时 adapter 返回空(不抛错)
|
|
294
|
+
assert.equal(report.ok, true, '空配置导出为空快照而非失败');
|
|
295
|
+
assert.equal(transport.snapshots.has('sync-fail'), true);
|
|
296
|
+
} finally {
|
|
297
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
298
|
+
}
|
|
299
|
+
});
|