dsh-config-manager 0.1.0
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 +216 -0
- package/README.zh-CN.md +218 -0
- package/cordis.patch.yml +45 -0
- package/dsh.bundle.patch +21 -0
- package/dsh.client +39 -0
- package/lib/adapters/agent-presets.d.ts +13 -0
- package/lib/adapters/agent-presets.js +14 -0
- package/lib/adapters/agent-presets.js.map +1 -0
- package/lib/adapters/credentials.d.ts +36 -0
- package/lib/adapters/credentials.js +121 -0
- package/lib/adapters/credentials.js.map +1 -0
- package/lib/adapters/file-collection.d.ts +14 -0
- package/lib/adapters/file-collection.js +102 -0
- package/lib/adapters/file-collection.js.map +1 -0
- package/lib/adapters/index.d.ts +38 -0
- package/lib/adapters/index.js +46 -0
- package/lib/adapters/index.js.map +1 -0
- package/lib/adapters/mcp.d.ts +27 -0
- package/lib/adapters/mcp.js +175 -0
- package/lib/adapters/mcp.js.map +1 -0
- package/lib/adapters/plugin-files.d.ts +15 -0
- package/lib/adapters/plugin-files.js +91 -0
- package/lib/adapters/plugin-files.js.map +1 -0
- package/lib/adapters/plugins.d.ts +13 -0
- package/lib/adapters/plugins.js +177 -0
- package/lib/adapters/plugins.js.map +1 -0
- package/lib/adapters/prompts.d.ts +29 -0
- package/lib/adapters/prompts.js +228 -0
- package/lib/adapters/prompts.js.map +1 -0
- package/lib/adapters/providers.d.ts +33 -0
- package/lib/adapters/providers.js +143 -0
- package/lib/adapters/providers.js.map +1 -0
- package/lib/adapters/sessions.d.ts +14 -0
- package/lib/adapters/sessions.js +15 -0
- package/lib/adapters/sessions.js.map +1 -0
- package/lib/adapters/settings.d.ts +23 -0
- package/lib/adapters/settings.js +142 -0
- package/lib/adapters/settings.js.map +1 -0
- package/lib/adapters/skills.d.ts +13 -0
- package/lib/adapters/skills.js +14 -0
- package/lib/adapters/skills.js.map +1 -0
- package/lib/adapters/test-helpers.d.ts +99 -0
- package/lib/adapters/test-helpers.js +192 -0
- package/lib/adapters/test-helpers.js.map +1 -0
- package/lib/adapters/ui.d.ts +26 -0
- package/lib/adapters/ui.js +88 -0
- package/lib/adapters/ui.js.map +1 -0
- package/lib/adapters/workspaces.d.ts +12 -0
- package/lib/adapters/workspaces.js +92 -0
- package/lib/adapters/workspaces.js.map +1 -0
- package/lib/client.d.ts +377 -0
- package/lib/client.js +2598 -0
- package/lib/client.js.map +1 -0
- package/lib/core/analyzer.d.ts +35 -0
- package/lib/core/analyzer.js +563 -0
- package/lib/core/analyzer.js.map +1 -0
- package/lib/core/backup.d.ts +29 -0
- package/lib/core/backup.js +184 -0
- package/lib/core/backup.js.map +1 -0
- package/lib/core/exporter.d.ts +37 -0
- package/lib/core/exporter.js +222 -0
- package/lib/core/exporter.js.map +1 -0
- package/lib/core/importer.d.ts +42 -0
- package/lib/core/importer.js +25 -0
- package/lib/core/importer.js.map +1 -0
- package/lib/core/index.d.ts +12 -0
- package/lib/core/index.js +11 -0
- package/lib/core/index.js.map +1 -0
- package/lib/core/rollback.d.ts +9 -0
- package/lib/core/rollback.js +136 -0
- package/lib/core/rollback.js.map +1 -0
- package/lib/core/types.d.ts +331 -0
- package/lib/core/types.js +18 -0
- package/lib/core/types.js.map +1 -0
- package/lib/core/validator.d.ts +18 -0
- package/lib/core/validator.js +77 -0
- package/lib/core/validator.js.map +1 -0
- package/lib/index.d.ts +51 -0
- package/lib/index.js +875 -0
- package/lib/index.js.map +1 -0
- package/lib/migrations/index.d.ts +29 -0
- package/lib/migrations/index.js +56 -0
- package/lib/migrations/index.js.map +1 -0
- package/lib/migrations/v1-to-v2.d.ts +9 -0
- package/lib/migrations/v1-to-v2.js +10 -0
- package/lib/migrations/v1-to-v2.js.map +1 -0
- package/lib/profiles/index.d.ts +4 -0
- package/lib/profiles/index.js +5 -0
- package/lib/profiles/index.js.map +1 -0
- package/lib/profiles/profile-manager.d.ts +102 -0
- package/lib/profiles/profile-manager.js +522 -0
- package/lib/profiles/profile-manager.js.map +1 -0
- package/lib/schema/config.d.ts +28 -0
- package/lib/schema/config.js +136 -0
- package/lib/schema/config.js.map +1 -0
- package/lib/schema/manifest.d.ts +28 -0
- package/lib/schema/manifest.js +109 -0
- package/lib/schema/manifest.js.map +1 -0
- package/lib/schema/types.d.ts +169 -0
- package/lib/schema/types.js +8 -0
- package/lib/schema/types.js.map +1 -0
- package/lib/schema/versions.d.ts +27 -0
- package/lib/schema/versions.js +50 -0
- package/lib/schema/versions.js.map +1 -0
- package/lib/security/encryption.d.ts +41 -0
- package/lib/security/encryption.js +152 -0
- package/lib/security/encryption.js.map +1 -0
- package/lib/security/index.d.ts +16 -0
- package/lib/security/index.js +17 -0
- package/lib/security/index.js.map +1 -0
- package/lib/security/integrity.d.ts +30 -0
- package/lib/security/integrity.js +78 -0
- package/lib/security/integrity.js.map +1 -0
- package/lib/security/redaction.d.ts +26 -0
- package/lib/security/redaction.js +95 -0
- package/lib/security/redaction.js.map +1 -0
- package/lib/security/secret-scanner.d.ts +48 -0
- package/lib/security/secret-scanner.js +253 -0
- package/lib/security/secret-scanner.js.map +1 -0
- package/lib/security/zip-security.d.ts +27 -0
- package/lib/security/zip-security.js +154 -0
- package/lib/security/zip-security.js.map +1 -0
- package/lib/ui/conflict-view.d.ts +28 -0
- package/lib/ui/conflict-view.js +49 -0
- package/lib/ui/conflict-view.js.map +1 -0
- package/lib/ui/errors.d.ts +17 -0
- package/lib/ui/errors.js +76 -0
- package/lib/ui/errors.js.map +1 -0
- package/lib/ui/export-flow.d.ts +62 -0
- package/lib/ui/export-flow.js +79 -0
- package/lib/ui/export-flow.js.map +1 -0
- package/lib/ui/import-wizard.d.ts +67 -0
- package/lib/ui/import-wizard.js +169 -0
- package/lib/ui/import-wizard.js.map +1 -0
- package/lib/ui/path-mapping.d.ts +38 -0
- package/lib/ui/path-mapping.js +75 -0
- package/lib/ui/path-mapping.js.map +1 -0
- package/lib/ui/progress.d.ts +27 -0
- package/lib/ui/progress.js +76 -0
- package/lib/ui/progress.js.map +1 -0
- package/lib/ui/report.d.ts +21 -0
- package/lib/ui/report.js +146 -0
- package/lib/ui/report.js.map +1 -0
- package/lib/ui/test-helpers.d.ts +62 -0
- package/lib/ui/test-helpers.js +147 -0
- package/lib/ui/test-helpers.js.map +1 -0
- package/lib/ui/types.d.ts +129 -0
- package/lib/ui/types.js +21 -0
- package/lib/ui/types.js.map +1 -0
- package/lib/utils/hashing.d.ts +17 -0
- package/lib/utils/hashing.js +41 -0
- package/lib/utils/hashing.js.map +1 -0
- package/lib/utils/json.d.ts +24 -0
- package/lib/utils/json.js +76 -0
- package/lib/utils/json.js.map +1 -0
- package/lib/utils/logger.d.ts +39 -0
- package/lib/utils/logger.js +82 -0
- package/lib/utils/logger.js.map +1 -0
- package/lib/utils/paths.d.ts +31 -0
- package/lib/utils/paths.js +132 -0
- package/lib/utils/paths.js.map +1 -0
- package/lib/utils/zip.d.ts +54 -0
- package/lib/utils/zip.js +285 -0
- package/lib/utils/zip.js.map +1 -0
- package/package.json +109 -0
- package/src/adapters/agent-presets.ts +14 -0
- package/src/adapters/credentials.test.ts +90 -0
- package/src/adapters/credentials.ts +141 -0
- package/src/adapters/file-collection.ts +110 -0
- package/src/adapters/files.test.ts +113 -0
- package/src/adapters/index.ts +69 -0
- package/src/adapters/mcp.test.ts +96 -0
- package/src/adapters/mcp.ts +181 -0
- package/src/adapters/plugin-files.ts +96 -0
- package/src/adapters/plugins.test.ts +72 -0
- package/src/adapters/plugins.ts +176 -0
- package/src/adapters/prompts.test.ts +110 -0
- package/src/adapters/prompts.ts +226 -0
- package/src/adapters/providers.test.ts +125 -0
- package/src/adapters/providers.ts +161 -0
- package/src/adapters/roundtrip.test.ts +188 -0
- package/src/adapters/sessions.ts +15 -0
- package/src/adapters/settings.test.ts +93 -0
- package/src/adapters/settings.ts +165 -0
- package/src/adapters/skills.ts +14 -0
- package/src/adapters/test-helpers.ts +207 -0
- package/src/adapters/ui.test.ts +56 -0
- package/src/adapters/ui.ts +106 -0
- package/src/adapters/workspaces.test.ts +79 -0
- package/src/adapters/workspaces.ts +95 -0
- package/src/client/CLIENT_DEPENDENCIES.md +101 -0
- package/src/client/ConfigManagerSection.tsx +60 -0
- package/src/client/api.ts +270 -0
- package/src/client/client-types.ts +27 -0
- package/src/client/common/ErrorBanner.tsx +68 -0
- package/src/client/common/ProgressBar.tsx +49 -0
- package/src/client/common/ReportView.tsx +117 -0
- package/src/client/common/ui.tsx +165 -0
- package/src/client/config-manager.module.css +759 -0
- package/src/client/css-modules.d.ts +8 -0
- package/src/client/export/ExportView.tsx +222 -0
- package/src/client/import/ConflictList.tsx +72 -0
- package/src/client/import/ImportWizardView.tsx +406 -0
- package/src/client/import/PathMappingForm.tsx +77 -0
- package/src/client/index.ts +65 -0
- package/src/client/locales.ts +182 -0
- package/src/core/analyzer.ts +631 -0
- package/src/core/backup.ts +212 -0
- package/src/core/exporter.ts +246 -0
- package/src/core/importer.ts +66 -0
- package/src/core/index.ts +39 -0
- package/src/core/rollback.ts +140 -0
- package/src/core/smoke.test.ts +717 -0
- package/src/core/types.ts +343 -0
- package/src/core/validator.ts +84 -0
- package/src/index.ts +942 -0
- package/src/migrations/index.ts +71 -0
- package/src/migrations/v1-to-v2.ts +17 -0
- package/src/profiles/index.ts +8 -0
- package/src/profiles/profile-manager.test.ts +280 -0
- package/src/profiles/profile-manager.ts +606 -0
- package/src/schema/config.ts +137 -0
- package/src/schema/manifest.ts +124 -0
- package/src/schema/types.ts +179 -0
- package/src/schema/versions.ts +55 -0
- package/src/security/encryption.ts +180 -0
- package/src/security/index.ts +16 -0
- package/src/security/integrity.ts +102 -0
- package/src/security/redaction.ts +101 -0
- package/src/security/secret-scanner.ts +275 -0
- package/src/security/security.test.ts +796 -0
- package/src/security/zip-security.ts +180 -0
- package/src/ui/conflict-view.test.ts +62 -0
- package/src/ui/conflict-view.ts +65 -0
- package/src/ui/errors.test.ts +44 -0
- package/src/ui/errors.ts +95 -0
- package/src/ui/export-flow.test.ts +75 -0
- package/src/ui/export-flow.ts +128 -0
- package/src/ui/import-wizard.test.ts +128 -0
- package/src/ui/import-wizard.ts +208 -0
- package/src/ui/path-mapping.test.ts +67 -0
- package/src/ui/path-mapping.ts +103 -0
- package/src/ui/progress.test.ts +42 -0
- package/src/ui/progress.ts +90 -0
- package/src/ui/report.test.ts +93 -0
- package/src/ui/report.ts +140 -0
- package/src/ui/test-helpers.ts +177 -0
- package/src/ui/types.ts +193 -0
- package/src/utils/hashing.ts +53 -0
- package/src/utils/json.ts +77 -0
- package/src/utils/logger.ts +114 -0
- package/src/utils/paths.ts +122 -0
- package/src/utils/zip.ts +332 -0
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 核心引擎冒烟测试(m3 验收):真实运行 导出 → ZIP → 导入 往返。
|
|
3
|
+
* 使用内存 mock 的 HostContext 与 ConfigAdapter(引擎与 DSH 运行时解耦的可测性证明)。
|
|
4
|
+
*
|
|
5
|
+
* 覆盖:往返 / Secret 不泄值 / Dry Run 零写入 / 未确认拒绝 / 冲突不覆盖 /
|
|
6
|
+
* 幂等重复导入 / 整体回滚 / checksum 篡改拒绝 / schema 版本拒绝 / ZIP 安全。
|
|
7
|
+
*/
|
|
8
|
+
import test from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import fs from 'node:fs/promises';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
14
|
+
|
|
15
|
+
import { Exporter } from './exporter.ts';
|
|
16
|
+
import { Importer } from './importer.ts';
|
|
17
|
+
import { parseZip, zipToBuffer, crc32, ZipSafetyError, type ZipWriteEntry } from '../utils/zip.ts';
|
|
18
|
+
import { parseManifest } from '../schema/manifest.ts';
|
|
19
|
+
import { computeCompatibility } from './validator.ts';
|
|
20
|
+
import { createLogger, type Logger } from '../utils/logger.ts';
|
|
21
|
+
import { isPathSafe, normalizePath } from '../utils/paths.ts';
|
|
22
|
+
import { sha256Hex } from '../utils/hashing.ts';
|
|
23
|
+
import { UnsupportedSchemaError } from '../schema/versions.ts';
|
|
24
|
+
import { ImportNotConfirmedError } from './types.ts';
|
|
25
|
+
import type {
|
|
26
|
+
FilesSection, NamespaceRecord, SettingsSection, WorkspaceRecord, WorkspacesSection,
|
|
27
|
+
} from '../schema/types.ts';
|
|
28
|
+
import type {
|
|
29
|
+
ApplyResult, ConfigAdapter, CredentialsFacade, ExportSection, FileSystemFacade,
|
|
30
|
+
HostContext, ImportContext, NamespaceInfo, PatchFileFacade,
|
|
31
|
+
PlanItem, PluginsFacade, SettingsFacade, Snapshot,
|
|
32
|
+
SnapshotEntry, SnapshotStore, SnapshotTarget, ValidationResult,
|
|
33
|
+
WorkspaceFacade,
|
|
34
|
+
} from './types.ts';
|
|
35
|
+
|
|
36
|
+
/* ================= Mock 基础设施 ================= */
|
|
37
|
+
|
|
38
|
+
class MemFs implements FileSystemFacade {
|
|
39
|
+
files = new Map<string, Uint8Array>();
|
|
40
|
+
private readonly homeDir: string;
|
|
41
|
+
constructor(homeDir: string) {
|
|
42
|
+
this.homeDir = homeDir;
|
|
43
|
+
}
|
|
44
|
+
private key(p: string): string {
|
|
45
|
+
return normalizePath(path.resolve(this.homeDir, p));
|
|
46
|
+
}
|
|
47
|
+
async readFile(relPath: string): Promise<Uint8Array> {
|
|
48
|
+
const v = this.files.get(this.key(relPath));
|
|
49
|
+
if (v === undefined) throw new Error(`ENOENT: ${relPath}`);
|
|
50
|
+
return v;
|
|
51
|
+
}
|
|
52
|
+
async writeFile(relPath: string, data: Uint8Array): Promise<void> {
|
|
53
|
+
this.files.set(this.key(relPath), data);
|
|
54
|
+
}
|
|
55
|
+
async exists(relPath: string): Promise<boolean> {
|
|
56
|
+
return this.files.has(this.key(relPath));
|
|
57
|
+
}
|
|
58
|
+
async copy(from: string, to: string): Promise<void> {
|
|
59
|
+
const v = this.files.get(this.key(from));
|
|
60
|
+
if (v === undefined) throw new Error(`ENOENT: ${from}`);
|
|
61
|
+
this.files.set(this.key(to), v);
|
|
62
|
+
}
|
|
63
|
+
async remove(relPath: string): Promise<void> {
|
|
64
|
+
this.files.delete(this.key(relPath));
|
|
65
|
+
}
|
|
66
|
+
async listRecursive(dir: string): Promise<string[]> {
|
|
67
|
+
const prefix = normalizePath(path.resolve(this.homeDir, dir)) + '/';
|
|
68
|
+
const out: string[] = [];
|
|
69
|
+
for (const k of this.files.keys()) {
|
|
70
|
+
if (k.startsWith(prefix)) out.push(k.slice(this.homeDir.length).replace(/^[\\/]+/, ''));
|
|
71
|
+
}
|
|
72
|
+
return out.sort();
|
|
73
|
+
}
|
|
74
|
+
async mkdir(): Promise<void> { /* 内存实现无需建目录 */ }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
class MemSettings implements SettingsFacade {
|
|
78
|
+
ns = new Map<string, { value: unknown; revision: number; secrets: { path: string[]; set: boolean }[] }>();
|
|
79
|
+
async describe(namespace: string): Promise<NamespaceInfo> {
|
|
80
|
+
const rec = this.ns.get(namespace);
|
|
81
|
+
if (!rec) throw new Error(`namespace not found: ${namespace}`);
|
|
82
|
+
return { value: rec.value, revision: rec.revision, secrets: rec.secrets };
|
|
83
|
+
}
|
|
84
|
+
async replace(namespace: string, value: unknown, expectedRevision?: number): Promise<void> {
|
|
85
|
+
const rec = this.ns.get(namespace);
|
|
86
|
+
if (expectedRevision !== undefined && rec && rec.revision !== expectedRevision) {
|
|
87
|
+
throw new Error(`SETTINGS_CONFLICT: ${namespace} revision ${rec.revision} !== ${expectedRevision}`);
|
|
88
|
+
}
|
|
89
|
+
this.ns.set(namespace, { value, revision: (rec?.revision ?? 0) + 1, secrets: rec?.secrets ?? [] });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
class MemCredentials implements CredentialsFacade {
|
|
94
|
+
values = new Map<string, string>();
|
|
95
|
+
async describe(ref: string): Promise<{ configured: boolean; source?: string; writable?: boolean }> {
|
|
96
|
+
const v = this.values.get(ref);
|
|
97
|
+
return { configured: v !== undefined, source: v !== undefined ? 'file' : 'env', writable: true };
|
|
98
|
+
}
|
|
99
|
+
async set(ref: string, value: string): Promise<void> { this.values.set(ref, value); }
|
|
100
|
+
async unset(ref: string): Promise<void> { this.values.delete(ref); }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
class MemPlugins implements PluginsFacade {
|
|
104
|
+
installed = new Map<string, { name: string; version: string; enabled: boolean }>();
|
|
105
|
+
async listInstalled() { return [...this.installed.values()]; }
|
|
106
|
+
async install(pkg: string) { this.installed.set(pkg, { name: pkg, version: '1.0.0', enabled: true }); return { needsRestart: true }; }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
class MemWorkspace implements WorkspaceFacade {
|
|
110
|
+
records = new Map<string, WorkspaceRecord>();
|
|
111
|
+
async listRecords(): Promise<WorkspaceRecord[]> { return [...this.records.values()]; }
|
|
112
|
+
async writeRecord(r: WorkspaceRecord): Promise<void> { this.records.set(r.id, r); }
|
|
113
|
+
async removeRecord(id: string): Promise<void> { this.records.delete(id); }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
class MemPatch implements PatchFileFacade {
|
|
117
|
+
lines = new Map<string, { lineId: string; raw: unknown }>();
|
|
118
|
+
async readPatchLines(): Promise<{ lineId: string; raw: unknown }[]> { return [...this.lines.values()]; }
|
|
119
|
+
async applyPatchChanges(_file: string, changes: { lineId: string; raw: unknown; action: 'insert' | 'update' | 'remove' }[]): Promise<void> {
|
|
120
|
+
for (const c of changes) {
|
|
121
|
+
if (c.action === 'remove') this.lines.delete(c.lineId);
|
|
122
|
+
else this.lines.set(c.lineId, { lineId: c.lineId, raw: c.raw });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
class MemSnapshotStore implements SnapshotStore {
|
|
128
|
+
snapshots = new Map<string, Snapshot>();
|
|
129
|
+
blobs = new Map<string, Uint8Array>();
|
|
130
|
+
async save(snapshot: Snapshot, blobs: Map<string, Uint8Array> = new Map()): Promise<string> {
|
|
131
|
+
this.snapshots.set(snapshot.id, snapshot);
|
|
132
|
+
for (const [k, v] of blobs) this.blobs.set(`${snapshot.id}/${k}`, v);
|
|
133
|
+
return snapshot.id;
|
|
134
|
+
}
|
|
135
|
+
async load(id: string): Promise<Snapshot> {
|
|
136
|
+
const s = this.snapshots.get(id);
|
|
137
|
+
if (!s) throw new Error(`snapshot not found: ${id}`);
|
|
138
|
+
return s;
|
|
139
|
+
}
|
|
140
|
+
async readBlob(id: string, blobPath: string): Promise<Uint8Array> {
|
|
141
|
+
const v = this.blobs.get(`${id}/${blobPath}`);
|
|
142
|
+
if (!v) throw new Error(`blob not found: ${id}/${blobPath}`);
|
|
143
|
+
return v;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
class MockHostContext implements HostContext {
|
|
148
|
+
platform: string;
|
|
149
|
+
arch = 'x64';
|
|
150
|
+
homeDir: string;
|
|
151
|
+
dshVersion = '0.1.0-rc.6';
|
|
152
|
+
log: Logger;
|
|
153
|
+
settings = new MemSettings();
|
|
154
|
+
credentials = new MemCredentials();
|
|
155
|
+
plugins = new MemPlugins();
|
|
156
|
+
workspace = new MemWorkspace();
|
|
157
|
+
patchFile = new MemPatch();
|
|
158
|
+
fs: MemFs;
|
|
159
|
+
constructor(platform: string, homeDir: string) {
|
|
160
|
+
this.platform = platform;
|
|
161
|
+
this.homeDir = homeDir;
|
|
162
|
+
this.fs = new MemFs(homeDir);
|
|
163
|
+
this.log = createLogger({ level: 'error', sink: () => {} });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* ================= Mock Adapters(契约样例,m5 实现真实版) ================= */
|
|
168
|
+
|
|
169
|
+
const KNOWN_NS = ['general', 'llm-deepseek', 'theme'];
|
|
170
|
+
|
|
171
|
+
class MockSettingsAdapter implements ConfigAdapter<SettingsSection> {
|
|
172
|
+
readonly id = 'settings' as const;
|
|
173
|
+
readonly displayName = 'Settings';
|
|
174
|
+
readonly defaultIncluded = true;
|
|
175
|
+
readonly portability = 'portable' as const;
|
|
176
|
+
/** 测试钩子:下一次 applyItem 抛错(回滚测试用) */
|
|
177
|
+
failNext = false;
|
|
178
|
+
|
|
179
|
+
async export(ctx: HostContext): Promise<ExportSection<SettingsSection>> {
|
|
180
|
+
const namespaces: Record<string, NamespaceRecord> = {};
|
|
181
|
+
for (const name of KNOWN_NS) {
|
|
182
|
+
try {
|
|
183
|
+
const info = await ctx.settings.describe(name);
|
|
184
|
+
namespaces[name] = { value: info.value, base: info.base, revision: info.revision, applies: info.applies, secrets: info.secrets };
|
|
185
|
+
} catch { /* 不存在则跳过 */ }
|
|
186
|
+
}
|
|
187
|
+
return { sectionId: 'settings', data: { version: 1, namespaces }, counts: { namespaces: Object.keys(namespaces).length }, warnings: [] };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async analyzeImport(data: SettingsSection, ctx: ImportContext): Promise<PlanItem[]> {
|
|
191
|
+
const items: PlanItem[] = [];
|
|
192
|
+
for (const [name, rec] of Object.entries(data.namespaces)) {
|
|
193
|
+
let current: NamespaceInfo | null = null;
|
|
194
|
+
try { current = await ctx.target.settings.describe(name); } catch { current = null; }
|
|
195
|
+
const id = `settings:${name}`;
|
|
196
|
+
if (current === null) {
|
|
197
|
+
items.push({ id, kind: 'Create', adapter: 'settings', description: `创建设置 ${name}`, severity: 'info', target: { adapter: 'settings', ref: name } });
|
|
198
|
+
} else if (isDeepStrictEqual(current.value, rec.value)) {
|
|
199
|
+
items.push({ id, kind: 'Skip', adapter: 'settings', description: `设置 ${name} 已一致`, severity: 'info' });
|
|
200
|
+
} else {
|
|
201
|
+
items.push({
|
|
202
|
+
id, kind: 'Conflict', adapter: 'settings', description: `设置 ${name} 与目标不同`,
|
|
203
|
+
detail: `current=${JSON.stringify(current.value)} imported=${JSON.stringify(rec.value)}`.slice(0, 200),
|
|
204
|
+
severity: 'warning', target: { adapter: 'settings', ref: name },
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return items;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async applyItem(item: PlanItem, ctx: ImportContext): Promise<ApplyResult> {
|
|
212
|
+
if (this.failNext) { this.failNext = false; throw new Error('模拟写入失败(failNext)'); }
|
|
213
|
+
const ref = item.target?.ref;
|
|
214
|
+
if (!ref) return { ok: false, message: '缺少 target.ref' };
|
|
215
|
+
const data = ctx.sections.get('settings') as SettingsSection;
|
|
216
|
+
const rec = data.namespaces[ref];
|
|
217
|
+
if (!rec) return { ok: false, message: `导入数据缺少 namespace ${ref}` };
|
|
218
|
+
await ctx.target.settings.replace(ref, rec.value);
|
|
219
|
+
return { ok: true };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async validate(data: SettingsSection): Promise<ValidationResult> {
|
|
223
|
+
return { valid: data.version === 1 && data.namespaces !== null && typeof data.namespaces === 'object', issues: [] };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
class MockSkillsAdapter implements ConfigAdapter<FilesSection> {
|
|
228
|
+
readonly id = 'skills' as const;
|
|
229
|
+
readonly displayName = 'Skills';
|
|
230
|
+
readonly defaultIncluded = true;
|
|
231
|
+
readonly portability = 'portable' as const;
|
|
232
|
+
|
|
233
|
+
async export(ctx: HostContext): Promise<ExportSection<FilesSection>> {
|
|
234
|
+
const files: FilesSection['files'] = [];
|
|
235
|
+
const rels = await ctx.fs.listRecursive('skills');
|
|
236
|
+
for (const rel of rels) {
|
|
237
|
+
const data = await ctx.fs.readFile(rel);
|
|
238
|
+
files.push({ relativePath: rel.replace(/^skills[\\/]/, ''), data, contentHash: sha256Hex(data) });
|
|
239
|
+
}
|
|
240
|
+
return { sectionId: 'skills', data: { version: 1, files }, counts: { files: files.length }, warnings: [] };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async analyzeImport(data: FilesSection, ctx: ImportContext): Promise<PlanItem[]> {
|
|
244
|
+
const items: PlanItem[] = [];
|
|
245
|
+
for (const file of data.files) {
|
|
246
|
+
const id = `skill:${file.relativePath}`;
|
|
247
|
+
let current: Uint8Array | null = null;
|
|
248
|
+
try { current = await ctx.target.fs.readFile(path.join('skills', file.relativePath)); } catch { current = null; }
|
|
249
|
+
if (current === null) {
|
|
250
|
+
items.push({ id, kind: 'Create', adapter: 'skills', description: `创建技能 ${file.relativePath}`, severity: 'info', target: { adapter: 'skills', ref: file.relativePath } });
|
|
251
|
+
} else if (sha256Hex(current) === file.contentHash) {
|
|
252
|
+
items.push({ id, kind: 'Skip', adapter: 'skills', description: `技能 ${file.relativePath} 已一致`, severity: 'info' });
|
|
253
|
+
} else {
|
|
254
|
+
items.push({ id, kind: 'Conflict', adapter: 'skills', description: `技能 ${file.relativePath} 内容不同`, severity: 'warning', target: { adapter: 'skills', ref: file.relativePath } });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return items;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async applyItem(item: PlanItem, ctx: ImportContext): Promise<ApplyResult> {
|
|
261
|
+
const ref = item.target?.ref;
|
|
262
|
+
if (!ref) return { ok: false, message: '缺少 ref' };
|
|
263
|
+
const data = ctx.sections.get('skills') as FilesSection;
|
|
264
|
+
const file = data.files.find((f) => f.relativePath === ref);
|
|
265
|
+
if (!file) return { ok: false, message: `导入数据缺少技能 ${ref}` };
|
|
266
|
+
await ctx.target.fs.writeFile(path.join('skills', ref), file.data);
|
|
267
|
+
return { ok: true };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async validate(data: FilesSection): Promise<ValidationResult> {
|
|
271
|
+
return { valid: data.version === 1 && Array.isArray(data.files), issues: [] };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
class MockWorkspacesAdapter implements ConfigAdapter<WorkspacesSection> {
|
|
276
|
+
readonly id = 'workspaces' as const;
|
|
277
|
+
readonly displayName = 'Workspaces';
|
|
278
|
+
readonly defaultIncluded = true;
|
|
279
|
+
readonly portability = 'platformSpecific' as const;
|
|
280
|
+
|
|
281
|
+
async export(ctx: HostContext): Promise<ExportSection<WorkspacesSection>> {
|
|
282
|
+
const records = await ctx.workspace.listRecords();
|
|
283
|
+
return { sectionId: 'workspaces', data: { version: 1, workspaces: records }, counts: { workspaces: records.length }, warnings: [] };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async analyzeImport(data: WorkspacesSection, ctx: ImportContext): Promise<PlanItem[]> {
|
|
287
|
+
const items: PlanItem[] = [];
|
|
288
|
+
for (const rec of data.workspaces) {
|
|
289
|
+
const id = `workspace:${rec.id}`;
|
|
290
|
+
const existing = (await ctx.target.workspace.listRecords()).find((r) => r.id === rec.id);
|
|
291
|
+
if (!existing) {
|
|
292
|
+
items.push({ id, kind: 'Create', adapter: 'workspaces', description: `创建工作区 ${rec.title ?? rec.id}`, severity: 'info', target: { adapter: 'workspaces', ref: rec.id } });
|
|
293
|
+
} else if (existing.path === rec.path) {
|
|
294
|
+
items.push({ id, kind: 'Skip', adapter: 'workspaces', description: `工作区 ${rec.id} 已一致`, severity: 'info' });
|
|
295
|
+
} else {
|
|
296
|
+
items.push({ id, kind: 'Conflict', adapter: 'workspaces', description: `工作区 ${rec.id} 路径不同`, severity: 'warning', target: { adapter: 'workspaces', ref: rec.id } });
|
|
297
|
+
}
|
|
298
|
+
if (rec.path.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(rec.path)) {
|
|
299
|
+
items.push({
|
|
300
|
+
id: `${id}:path`, kind: 'PathMapping', adapter: 'workspaces',
|
|
301
|
+
description: `路径需映射: ${rec.path}`, severity: 'warning',
|
|
302
|
+
pathMapping: { oldPrefix: rec.path, newPrefix: '', appliesTo: ['workspaces'] },
|
|
303
|
+
target: { adapter: 'workspaces', ref: rec.id },
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return items;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async applyItem(item: PlanItem, ctx: ImportContext): Promise<ApplyResult> {
|
|
311
|
+
if (item.kind === 'PathMapping') return { ok: true }; // 数据已由 PathMapper 应用到 sections
|
|
312
|
+
const ref = item.target?.ref;
|
|
313
|
+
if (!ref) return { ok: false, message: '缺少 ref' };
|
|
314
|
+
const data = ctx.sections.get('workspaces') as WorkspacesSection;
|
|
315
|
+
const rec = data.workspaces.find((r) => r.id === ref);
|
|
316
|
+
if (!rec) return { ok: false, message: `导入数据缺少工作区 ${ref}` };
|
|
317
|
+
await ctx.target.workspace.writeRecord(rec);
|
|
318
|
+
return { ok: true };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async validate(data: WorkspacesSection): Promise<ValidationResult> {
|
|
322
|
+
return { valid: data.version === 1 && Array.isArray(data.workspaces), issues: [] };
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
class MockCredentialsAdapter implements ConfigAdapter<import('../schema/types.ts').CredentialsSection> {
|
|
327
|
+
readonly id = 'credentialsStatus' as const;
|
|
328
|
+
readonly displayName = 'Credentials';
|
|
329
|
+
readonly defaultIncluded = true;
|
|
330
|
+
readonly portability = 'deviceSpecific' as const;
|
|
331
|
+
|
|
332
|
+
/** 从 settings 分区推断凭据引用(apiKeyEnv / secrets 标记),再查状态 */
|
|
333
|
+
async export(ctx: HostContext): Promise<ExportSection<import('../schema/types.ts').CredentialsSection>> {
|
|
334
|
+
const refs = new Set<string>();
|
|
335
|
+
for (const name of KNOWN_NS) {
|
|
336
|
+
try {
|
|
337
|
+
const info = await ctx.settings.describe(name);
|
|
338
|
+
for (const s of info.secrets) {
|
|
339
|
+
if (s.set && s.path.length > 0 && typeof s.path[0] === 'string') refs.add(s.path[0]);
|
|
340
|
+
}
|
|
341
|
+
if (name === 'llm-deepseek') {
|
|
342
|
+
const v = info.value as { apiKeyEnv?: unknown };
|
|
343
|
+
if (typeof v.apiKeyEnv === 'string') refs.add(v.apiKeyEnv);
|
|
344
|
+
}
|
|
345
|
+
} catch { /* 跳过不存在的 namespace */ }
|
|
346
|
+
}
|
|
347
|
+
const credentials: import('../schema/types.ts').CredentialStatus[] = [];
|
|
348
|
+
for (const ref of refs) {
|
|
349
|
+
const status = await ctx.credentials.describe(ref);
|
|
350
|
+
credentials.push({ ref, required: true, configured: status.configured, source: (status.source ?? 'file') as 'file', hasValue: false });
|
|
351
|
+
}
|
|
352
|
+
return { sectionId: 'credentialsStatus', data: { version: 1, credentials }, counts: { credentials: credentials.length }, warnings: [] };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async analyzeImport(data: import('../schema/types.ts').CredentialsSection, _ctx: ImportContext): Promise<PlanItem[]> {
|
|
356
|
+
return data.credentials.map((c) => ({
|
|
357
|
+
id: `secret:${c.ref}`,
|
|
358
|
+
kind: 'MissingSecret' as const,
|
|
359
|
+
adapter: 'credentialsStatus' as const,
|
|
360
|
+
description: `凭据 ${c.ref} 需补录`,
|
|
361
|
+
severity: 'warning' as const,
|
|
362
|
+
target: { adapter: 'credentialsStatus' as const, ref: c.ref },
|
|
363
|
+
}));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async applyItem(item: PlanItem, ctx: ImportContext): Promise<ApplyResult> {
|
|
367
|
+
const ref = item.target?.ref;
|
|
368
|
+
if (!ref) return { ok: false, message: '缺少 ref' };
|
|
369
|
+
const value = ctx.secretInputs[ref] ?? ctx.decryptedCredentials?.get(ref);
|
|
370
|
+
if (value === undefined || value === '') return { ok: false, message: '凭据值未提供' };
|
|
371
|
+
await ctx.target.credentials.set(ref, value);
|
|
372
|
+
return { ok: true };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async validate(data: import('../schema/types.ts').CredentialsSection): Promise<ValidationResult> {
|
|
376
|
+
return { valid: data.version === 1 && Array.isArray(data.credentials), issues: [] };
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/* ================= 测试辅助 ================= */
|
|
381
|
+
|
|
382
|
+
function makeContext(platform: string, homeDir: string): MockHostContext {
|
|
383
|
+
return new MockHostContext(platform, homeDir);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function makeAdapters(): ConfigAdapter[] {
|
|
387
|
+
return [new MockSettingsAdapter(), new MockSkillsAdapter(), new MockWorkspacesAdapter(), new MockCredentialsAdapter()];
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** 手工构造原始 ZIP(含任意条目名,绕过写侧校验,用于读侧 Zip Slip 测试) */
|
|
391
|
+
function buildRawZip(entryName: string, data: Uint8Array): Uint8Array {
|
|
392
|
+
const nameBuf = Buffer.from(entryName, 'utf8');
|
|
393
|
+
const crc = crc32(data);
|
|
394
|
+
const local = Buffer.alloc(30);
|
|
395
|
+
local.writeUInt32LE(0x04034b50, 0);
|
|
396
|
+
local.writeUInt16LE(20, 4);
|
|
397
|
+
local.writeUInt16LE(0x0800, 6);
|
|
398
|
+
local.writeUInt16LE(0, 8); // store
|
|
399
|
+
local.writeUInt32LE(crc, 14);
|
|
400
|
+
local.writeUInt32LE(data.length, 18);
|
|
401
|
+
local.writeUInt32LE(data.length, 22);
|
|
402
|
+
local.writeUInt16LE(nameBuf.length, 26);
|
|
403
|
+
local.writeUInt16LE(0, 28);
|
|
404
|
+
const cen = Buffer.alloc(46);
|
|
405
|
+
cen.writeUInt32LE(0x02014b50, 0);
|
|
406
|
+
cen.writeUInt16LE(20, 4);
|
|
407
|
+
cen.writeUInt16LE(20, 6);
|
|
408
|
+
cen.writeUInt16LE(0x0800, 8);
|
|
409
|
+
cen.writeUInt16LE(0, 10);
|
|
410
|
+
cen.writeUInt32LE(crc, 16);
|
|
411
|
+
cen.writeUInt32LE(data.length, 20);
|
|
412
|
+
cen.writeUInt32LE(data.length, 24);
|
|
413
|
+
cen.writeUInt16LE(nameBuf.length, 28);
|
|
414
|
+
cen.writeUInt32LE(30 + nameBuf.length, 42);
|
|
415
|
+
const eocd = Buffer.alloc(22);
|
|
416
|
+
eocd.writeUInt32LE(0x06054b50, 0);
|
|
417
|
+
eocd.writeUInt16LE(1, 8);
|
|
418
|
+
eocd.writeUInt16LE(1, 10);
|
|
419
|
+
eocd.writeUInt32LE(46 + nameBuf.length, 12);
|
|
420
|
+
eocd.writeUInt32LE(30 + nameBuf.length + data.length, 16);
|
|
421
|
+
return Buffer.concat([local, nameBuf, data, cen, nameBuf, eocd]);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** 导出完整示例备份(win32 源,含 secret 字段、技能文件、workspace 绝对路径) */
|
|
425
|
+
async function exportFixture(src: MockHostContext, outPath: string, adapters: ConfigAdapter[]): Promise<void> {
|
|
426
|
+
src.settings.ns.set('general', { value: { theme: 'dark', language: 'zh-CN' }, revision: 3, secrets: [] });
|
|
427
|
+
src.settings.ns.set('llm-deepseek', {
|
|
428
|
+
value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://api.example.com', model: 'deepseek-chat', apiKey: 'sk-super-secret-value-123' },
|
|
429
|
+
revision: 5,
|
|
430
|
+
secrets: [{ path: ['apiKey'], set: true }],
|
|
431
|
+
});
|
|
432
|
+
await src.fs.writeFile('skills/coding.md', Buffer.from('# Coding skill\nUse deepseek.\n', 'utf8'));
|
|
433
|
+
await src.fs.writeFile('skills/git.md', Buffer.from('# Git skill\n', 'utf8'));
|
|
434
|
+
src.credentials.values.set('DEEPSEEK_API_KEY', 'sk-super-secret-value-123');
|
|
435
|
+
src.workspace.records.set('ws-ops', {
|
|
436
|
+
id: 'ws-ops', path: 'C:\\Users\\alice\\projects\\ops', title: 'OpsFlow', sessionIds: [],
|
|
437
|
+
});
|
|
438
|
+
const exporter = new Exporter({ ctx: src, adapters, exporterVersion: '0.1.0', now: () => new Date('2026-08-14T12:00:00.000Z') });
|
|
439
|
+
const result = await exporter.export({ includeSecrets: false, outPath });
|
|
440
|
+
assert.equal(result.manifest.schemaVersion, 1);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/* ================= 测试用例 ================= */
|
|
444
|
+
|
|
445
|
+
test('ZIP 基础:写读往返 + CRC32 + 目录穿越拒绝', () => {
|
|
446
|
+
const buf = zipToBuffer([
|
|
447
|
+
{ name: 'a/b.txt', data: Buffer.from('hello') },
|
|
448
|
+
{ name: 'c.json', data: Buffer.from('{"x":1}') },
|
|
449
|
+
]);
|
|
450
|
+
const archive = parseZip(buf);
|
|
451
|
+
assert.deepEqual(archive.names(), ['a/b.txt', 'c.json']);
|
|
452
|
+
assert.equal(Buffer.from(archive.readEntry('a/b.txt')).toString(), 'hello');
|
|
453
|
+
assert.deepEqual(archive.readEntryJson('c.json'), { x: 1 });
|
|
454
|
+
// 写侧拒绝不安全条目名
|
|
455
|
+
assert.throws(() => zipToBuffer([{ name: '../evil.txt', data: Buffer.from('x') }]), ZipSafetyError);
|
|
456
|
+
assert.equal(isPathSafe('../evil.txt'), false);
|
|
457
|
+
assert.equal(isPathSafe('C:\\evil.txt'), false);
|
|
458
|
+
assert.equal(isPathSafe('/abs/path'), false);
|
|
459
|
+
assert.equal(isPathSafe('a/b.txt'), true);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test('ZIP 读侧安全:../ 条目名 → ZipSafetyError(Zip Slip)', () => {
|
|
463
|
+
const malicious = buildRawZip('../evil.txt', Buffer.from('pwned'));
|
|
464
|
+
assert.throws(() => parseZip(malicious), ZipSafetyError);
|
|
465
|
+
const absolute = buildRawZip('C:/evil.txt', Buffer.from('pwned'));
|
|
466
|
+
assert.throws(() => parseZip(absolute), ZipSafetyError);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test('导出→导入完整往返(win32 → linux + 路径映射 + Secret 不泄值 + Dry Run 零写入)', async () => {
|
|
470
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-cm-smoke-'));
|
|
471
|
+
try {
|
|
472
|
+
const src = makeContext('win32', 'C:\\Users\\alice');
|
|
473
|
+
const adapters = makeAdapters();
|
|
474
|
+
const zipPath = path.join(tmp, 'dsh-config-test.zip');
|
|
475
|
+
await exportFixture(src, zipPath, adapters);
|
|
476
|
+
|
|
477
|
+
// —— 导出物检查 ——
|
|
478
|
+
const archive = parseZip(await fs.readFile(zipPath));
|
|
479
|
+
const manifest = parseManifest(archive.readEntryText('manifest.json'));
|
|
480
|
+
assert.equal(manifest.security.containsSecrets, false);
|
|
481
|
+
assert.equal(manifest.security.encrypted, false);
|
|
482
|
+
assert.equal(manifest.sections.settings, true);
|
|
483
|
+
assert.equal(manifest.sections.skills, true);
|
|
484
|
+
assert.equal(manifest.sections.workspaces, true);
|
|
485
|
+
// 完整性表存在且可校验
|
|
486
|
+
assert.ok(archive.has('integrity/checksums.json'));
|
|
487
|
+
// Secret 值绝不出现在导出内容(结构化数据被剥离)
|
|
488
|
+
const allText = archive.names()
|
|
489
|
+
.filter((n) => n !== 'integrity/checksums.json')
|
|
490
|
+
.map((n) => Buffer.from(archive.readEntry(n)).toString('utf8'))
|
|
491
|
+
.join('\n');
|
|
492
|
+
assert.ok(!allText.includes('sk-super-secret-value-123'), 'secret 值不得写入导出');
|
|
493
|
+
assert.ok(!allText.includes('super-secret'), 'secret 值不得写入导出');
|
|
494
|
+
|
|
495
|
+
// —— 目标机器(linux)导入 ——
|
|
496
|
+
const dst = makeContext('linux', '/home/bob');
|
|
497
|
+
const importer = new Importer({ ctx: dst, adapters, snapshotStore: new MemSnapshotStore() });
|
|
498
|
+
|
|
499
|
+
const analysis = await importer.analyzeImport(zipPath);
|
|
500
|
+
assert.equal(analysis.secretCount, 1, '应报告 1 个已配置凭据(DEEPSEEK_API_KEY)');
|
|
501
|
+
assert.equal(analysis.valid, true);
|
|
502
|
+
assert.equal(analysis.compatibility, 'partial'); // 跨平台
|
|
503
|
+
assert.ok(analysis.pathIssues.some((p) => p.kind === 'platformMismatch' && p.value.includes('C:\\Users\\alice')), '应检测到跨平台路径问题');
|
|
504
|
+
|
|
505
|
+
// Dry Run:analyze + plan 后目标零变化
|
|
506
|
+
const plan = await importer.createImportPlan(zipPath, {
|
|
507
|
+
strategy: 'merge',
|
|
508
|
+
resolutions: {},
|
|
509
|
+
pathMappings: [{ oldPrefix: 'C:\\Users\\alice', newPrefix: '/home/bob', appliesTo: ['workspaces', 'mcp', 'pluginConfig', 'skills'] }],
|
|
510
|
+
});
|
|
511
|
+
assert.equal(dst.settings.ns.size, 0);
|
|
512
|
+
assert.equal(dst.fs.files.size, 0);
|
|
513
|
+
assert.equal(dst.workspace.records.size, 0);
|
|
514
|
+
assert.ok(plan.pathMappings.some((m) => m.oldPrefix === 'C:\\Users\\alice'), '路径映射应进入计划');
|
|
515
|
+
assert.equal(plan.needsRestart, false);
|
|
516
|
+
|
|
517
|
+
// 未确认 → 拒绝执行且零写入
|
|
518
|
+
await assert.rejects(() => importer.executeImportPlan(zipPath, plan, { confirm: false }), ImportNotConfirmedError);
|
|
519
|
+
assert.equal(dst.settings.ns.size, 0);
|
|
520
|
+
|
|
521
|
+
// 确认执行
|
|
522
|
+
const result = await importer.executeImportPlan(zipPath, plan, { confirm: true });
|
|
523
|
+
assert.equal(result.ok, true);
|
|
524
|
+
assert.ok(result.snapshotId, '应有快照 id');
|
|
525
|
+
|
|
526
|
+
// —— 目标已恢复源配置 ——
|
|
527
|
+
assert.deepEqual(dst.settings.ns.get('general')?.value, { theme: 'dark', language: 'zh-CN' });
|
|
528
|
+
const llm = dst.settings.ns.get('llm-deepseek')?.value as Record<string, unknown>;
|
|
529
|
+
assert.equal(llm.apiKeyEnv, 'DEEPSEEK_API_KEY');
|
|
530
|
+
assert.equal(llm.apiKey, '', 'secret 字段被剥离为空串');
|
|
531
|
+
assert.ok(await dst.fs.exists(path.join('skills', 'coding.md')), '技能文件应已导入');
|
|
532
|
+
assert.equal(Buffer.from(await dst.fs.readFile(path.join('skills', 'coding.md'))).toString(), '# Coding skill\nUse deepseek.\n');
|
|
533
|
+
const ws = dst.workspace.records.get('ws-ops');
|
|
534
|
+
assert.ok(ws, 'workspace 应已导入');
|
|
535
|
+
assert.equal(normalizePath(ws!.path), '/home/bob/projects/ops', '绝对路径应被映射');
|
|
536
|
+
|
|
537
|
+
// 凭据值未随备份 → 结果如实报告需补录(值绝不落盘)
|
|
538
|
+
assert.ok(result.missingSecrets.includes('DEEPSEEK_API_KEY'), '应报告凭据需补录');
|
|
539
|
+
assert.ok(!dst.credentials.values.has('DEEPSEEK_API_KEY'), '未提供值时不得写入凭据');
|
|
540
|
+
|
|
541
|
+
// 幂等:再次导入 → 无 Create/Update/Install 新写入项
|
|
542
|
+
const plan2 = await importer.createImportPlan(zipPath, {
|
|
543
|
+
strategy: 'merge',
|
|
544
|
+
resolutions: {},
|
|
545
|
+
pathMappings: [{ oldPrefix: 'C:\\Users\\alice', newPrefix: '/home/bob', appliesTo: ['workspaces', 'mcp', 'pluginConfig', 'skills'] }],
|
|
546
|
+
});
|
|
547
|
+
assert.ok(
|
|
548
|
+
!plan2.items.some((i) => i.kind === 'Create' || i.kind === 'Update' || i.kind === 'Install'),
|
|
549
|
+
'重复导入不应产生新写入项(Create/Update/Install)',
|
|
550
|
+
);
|
|
551
|
+
const result2 = await importer.executeImportPlan(zipPath, plan2, { confirm: true });
|
|
552
|
+
assert.ok(result2.executed.every((e) => e.status !== 'failed'), '第二次导入不应有失败项');
|
|
553
|
+
assert.deepEqual(dst.settings.ns.get('general')?.value, { theme: 'dark', language: 'zh-CN' }, '第二次导入不应改写已有数据');
|
|
554
|
+
} finally {
|
|
555
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
test('冲突不默认覆盖:merge 无决策 → 跳过;keepCurrent/useImported 决策生效', async () => {
|
|
560
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-cm-conflict-'));
|
|
561
|
+
try {
|
|
562
|
+
const src = makeContext('win32', 'C:\\Users\\alice');
|
|
563
|
+
src.settings.ns.set('general', { value: { theme: 'dark' }, revision: 1, secrets: [] });
|
|
564
|
+
const adapters = [new MockSettingsAdapter()];
|
|
565
|
+
const zipPath = path.join(tmp, 'x.zip');
|
|
566
|
+
await new Exporter({ ctx: src, adapters, now: () => new Date() }).export({ includeSecrets: false, outPath: zipPath });
|
|
567
|
+
|
|
568
|
+
// 目标已有不同值
|
|
569
|
+
const dst = makeContext('linux', '/home/bob');
|
|
570
|
+
dst.settings.ns.set('general', { value: { theme: 'light' }, revision: 7, secrets: [] });
|
|
571
|
+
const importer = new Importer({ ctx: dst, adapters, snapshotStore: new MemSnapshotStore() });
|
|
572
|
+
|
|
573
|
+
// merge + 无决策 → Conflict 保留 → 执行跳过,不覆盖
|
|
574
|
+
const plan = await importer.createImportPlan(zipPath, { strategy: 'merge', resolutions: {}, pathMappings: [] });
|
|
575
|
+
const conflict = plan.items.find((i) => i.id === 'settings:general');
|
|
576
|
+
assert.equal(conflict?.kind, 'Conflict');
|
|
577
|
+
const r1 = await importer.executeImportPlan(zipPath, plan, { confirm: true });
|
|
578
|
+
assert.ok(r1.executed.find((e) => e.itemId === 'settings:general')?.status === 'skipped');
|
|
579
|
+
assert.equal((dst.settings.ns.get('general')?.value as { theme: string }).theme, 'light', '冲突未解决时不得覆盖');
|
|
580
|
+
|
|
581
|
+
// keepCurrent → Skip
|
|
582
|
+
const planKeep = await importer.createImportPlan(zipPath, { strategy: 'merge', resolutions: { 'settings:general': 'keepCurrent' }, pathMappings: [] });
|
|
583
|
+
assert.equal(planKeep.items.find((i) => i.id === 'settings:general')?.kind, 'Skip');
|
|
584
|
+
const r2 = await importer.executeImportPlan(zipPath, planKeep, { confirm: true });
|
|
585
|
+
assert.ok(r2.executed.every((e) => e.status === 'skipped'));
|
|
586
|
+
|
|
587
|
+
// useImported → Update 覆盖
|
|
588
|
+
const planUse = await importer.createImportPlan(zipPath, { strategy: 'merge', resolutions: { 'settings:general': 'useImported' }, pathMappings: [] });
|
|
589
|
+
assert.equal(planUse.items.find((i) => i.id === 'settings:general')?.kind, 'Update');
|
|
590
|
+
const r3 = await importer.executeImportPlan(zipPath, planUse, { confirm: true });
|
|
591
|
+
assert.ok(r3.executed.find((e) => e.itemId === 'settings:general')?.status === 'ok');
|
|
592
|
+
assert.equal((dst.settings.ns.get('general')?.value as { theme: string }).theme, 'dark', 'useImported 应覆盖');
|
|
593
|
+
|
|
594
|
+
// skipExisting 全局策略 → Conflict 自动 Skip
|
|
595
|
+
const planSkip = await importer.createImportPlan(zipPath, { strategy: 'skipExisting', resolutions: {}, pathMappings: [] });
|
|
596
|
+
assert.equal(planSkip.items.find((i) => i.id === 'settings:general')?.kind, 'Skip');
|
|
597
|
+
} finally {
|
|
598
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
test('整体回滚:applyItem 中途失败 + rollbackOnError → 目标恢复导入前状态', async () => {
|
|
603
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-cm-rollback-'));
|
|
604
|
+
try {
|
|
605
|
+
const src = makeContext('win32', 'C:\\Users\\alice');
|
|
606
|
+
src.settings.ns.set('general', { value: { theme: 'dark' }, revision: 1, secrets: [] });
|
|
607
|
+
src.settings.ns.set('llm-deepseek', { value: { model: 'deepseek-chat' }, revision: 2, secrets: [] });
|
|
608
|
+
const settingsAdapter = new MockSettingsAdapter();
|
|
609
|
+
const adapters: ConfigAdapter[] = [settingsAdapter, new MockSkillsAdapter()];
|
|
610
|
+
const zipPath = path.join(tmp, 'x.zip');
|
|
611
|
+
await new Exporter({ ctx: src, adapters, now: () => new Date() }).export({ includeSecrets: false, outPath: zipPath });
|
|
612
|
+
|
|
613
|
+
// 目标初始:general=light(将被覆盖),llm-deepseek 不存在(将被创建)
|
|
614
|
+
const dst = makeContext('linux', '/home/bob');
|
|
615
|
+
dst.settings.ns.set('general', { value: { theme: 'light' }, revision: 7, secrets: [] });
|
|
616
|
+
const importer = new Importer({ ctx: dst, adapters, snapshotStore: new MemSnapshotStore() });
|
|
617
|
+
|
|
618
|
+
const plan = await importer.createImportPlan(zipPath, {
|
|
619
|
+
strategy: 'replace', // 覆盖目标
|
|
620
|
+
resolutions: {},
|
|
621
|
+
pathMappings: [],
|
|
622
|
+
});
|
|
623
|
+
settingsAdapter.failNext = true; // 第一次 applyItem 抛错 → general 失败
|
|
624
|
+
const result = await importer.executeImportPlan(zipPath, plan, { confirm: true, rollbackOnError: true });
|
|
625
|
+
|
|
626
|
+
assert.equal(result.ok, false);
|
|
627
|
+
assert.ok(result.rollback, '失败应有回滚报告');
|
|
628
|
+
assert.equal(result.rollback.full, true, '快照覆盖目标应能完整回滚');
|
|
629
|
+
assert.equal((dst.settings.ns.get('general')?.value as { theme: string }).theme, 'light', '回滚后 general 应恢复 light');
|
|
630
|
+
assert.ok(!dst.settings.ns.has('llm-deepseek'), '回滚后 llm-deepseek 应不存在');
|
|
631
|
+
} finally {
|
|
632
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
test('完整性:篡改 ZIP 内容后 checksum 校验拒绝', async () => {
|
|
637
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-cm-integrity-'));
|
|
638
|
+
try {
|
|
639
|
+
const src = makeContext('win32', 'C:\\Users\\alice');
|
|
640
|
+
src.settings.ns.set('general', { value: { theme: 'dark' }, revision: 1, secrets: [] });
|
|
641
|
+
const adapters = [new MockSettingsAdapter()];
|
|
642
|
+
const zipPath = path.join(tmp, 'x.zip');
|
|
643
|
+
await new Exporter({ ctx: src, adapters, now: () => new Date() }).export({ includeSecrets: false, outPath: zipPath });
|
|
644
|
+
|
|
645
|
+
// 解包 → 篡改 config/settings.json → 原样重打包(checksums.json 不变)
|
|
646
|
+
const archive = parseZip(await fs.readFile(zipPath));
|
|
647
|
+
const entries: ZipWriteEntry[] = [];
|
|
648
|
+
for (const name of archive.names()) {
|
|
649
|
+
if (name === 'config/settings.json') {
|
|
650
|
+
const data = JSON.parse(archive.readEntryText(name));
|
|
651
|
+
data.namespaces.general.value.theme = 'HACKED';
|
|
652
|
+
entries.push({ name, data: Buffer.from(JSON.stringify(data, null, 2)) });
|
|
653
|
+
} else {
|
|
654
|
+
entries.push({ name, data: archive.readEntry(name) });
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
const tampered = path.join(tmp, 'tampered.zip');
|
|
658
|
+
await fs.writeFile(tampered, zipToBuffer(entries));
|
|
659
|
+
|
|
660
|
+
const importer = new Importer({ ctx: makeContext('linux', '/home/bob'), adapters, snapshotStore: new MemSnapshotStore() });
|
|
661
|
+
await assert.rejects(() => importer.analyzeImport(tampered), /完整性校验失败/);
|
|
662
|
+
} finally {
|
|
663
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
test('版本:过新 schema 拒绝导入', async () => {
|
|
668
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-cm-version-'));
|
|
669
|
+
try {
|
|
670
|
+
const src = makeContext('win32', 'C:\\Users\\alice');
|
|
671
|
+
src.settings.ns.set('general', { value: { theme: 'dark' }, revision: 1, secrets: [] });
|
|
672
|
+
const adapters = [new MockSettingsAdapter()];
|
|
673
|
+
const zipPath = path.join(tmp, 'x.zip');
|
|
674
|
+
await new Exporter({ ctx: src, adapters, now: () => new Date() }).export({ includeSecrets: false, outPath: zipPath });
|
|
675
|
+
|
|
676
|
+
// 篡改 manifest.schemaVersion → 999
|
|
677
|
+
const archive = parseZip(await fs.readFile(zipPath));
|
|
678
|
+
const entries: ZipWriteEntry[] = [];
|
|
679
|
+
for (const name of archive.names()) {
|
|
680
|
+
if (name === 'manifest.json') {
|
|
681
|
+
const m = JSON.parse(archive.readEntryText(name));
|
|
682
|
+
m.schemaVersion = 999;
|
|
683
|
+
entries.push({ name, data: Buffer.from(JSON.stringify(m, null, 2)) });
|
|
684
|
+
} else {
|
|
685
|
+
entries.push({ name, data: archive.readEntry(name) });
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
const v999 = path.join(tmp, 'v999.zip');
|
|
689
|
+
await fs.writeFile(v999, zipToBuffer(entries));
|
|
690
|
+
|
|
691
|
+
const importer = new Importer({ ctx: makeContext('linux', '/home/bob'), adapters, snapshotStore: new MemSnapshotStore() });
|
|
692
|
+
await assert.rejects(() => importer.analyzeImport(v999), /无法导入/);
|
|
693
|
+
} finally {
|
|
694
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
test('兼容性评分规则', () => {
|
|
699
|
+
assert.equal(computeCompatibility({ sourceDsh: '0.1.0-rc.6', targetDsh: '0.1.0-rc.6', sourcePlatform: 'win32', targetPlatform: 'win32', schemaVersion: 1, missingSections: [] }), 'excellent');
|
|
700
|
+
assert.equal(computeCompatibility({ sourceDsh: '0.1.0-rc.4', targetDsh: '0.1.0-rc.6', sourcePlatform: 'win32', targetPlatform: 'win32', schemaVersion: 1, missingSections: [] }), 'good');
|
|
701
|
+
assert.equal(computeCompatibility({ sourceDsh: '0.1.0-rc.6', targetDsh: '0.1.0-rc.6', sourcePlatform: 'win32', targetPlatform: 'darwin', schemaVersion: 1, missingSections: [] }), 'partial');
|
|
702
|
+
assert.equal(computeCompatibility({ sourceDsh: '0.1.0-rc.6', targetDsh: '0.1.0-rc.6', sourcePlatform: 'win32', targetPlatform: 'win32', schemaVersion: 999, missingSections: [] }), 'unsupported');
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
test('包含秘密导出:无加密提供者时拒绝(绝不明文泄密)', async () => {
|
|
706
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-cm-sec-'));
|
|
707
|
+
try {
|
|
708
|
+
const src = makeContext('win32', 'C:\\Users\\alice');
|
|
709
|
+
const exporter = new Exporter({ ctx: src, adapters: makeAdapters(), now: () => new Date() });
|
|
710
|
+
await assert.rejects(
|
|
711
|
+
() => exporter.export({ includeSecrets: true, outPath: path.join(tmp, 'x.zip') }),
|
|
712
|
+
/EncryptionProvider/,
|
|
713
|
+
);
|
|
714
|
+
} finally {
|
|
715
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
716
|
+
}
|
|
717
|
+
});
|