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,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* settings 分区 adapter(设计 §3.3/§4.1/§4.2):
|
|
3
|
+
* 数据源 = ~/.dsh/settings.yaml 中「非 UI 类」namespace(经 ctx.settings.describe({redactSecrets:true}))。
|
|
4
|
+
*
|
|
5
|
+
* 说明:DSH settings 服务没有「枚举全部 namespace」的 API(研究报告 §3.2),
|
|
6
|
+
* 因此 namespace 清单由宿主注入(真实实现可从 settings.yaml 顶层 key 解析)。
|
|
7
|
+
* 默认只导出名单内且不属于 UI 类(isUiNamespace)的 namespace,与 ui adapter 互斥并集 = 全部设置。
|
|
8
|
+
*/
|
|
9
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
10
|
+
import type { NamespaceRecord, SettingsSection } from '../schema/types.ts';
|
|
11
|
+
import type {
|
|
12
|
+
ApplyResult, ConfigAdapter, ExportOptions, ExportSection, HostContext,
|
|
13
|
+
ImportContext, NamespaceInfo, PlanItem, ValidationResult,
|
|
14
|
+
} from '../core/types.ts';
|
|
15
|
+
import { isUiNamespace } from './ui.ts';
|
|
16
|
+
|
|
17
|
+
/** namespace 清单提供者:宿主从 settings.yaml 顶层 key / 插件注册表获得 */
|
|
18
|
+
export type NamespaceProvider = (ctx: HostContext) => Promise<string[]> | string[];
|
|
19
|
+
|
|
20
|
+
export async function resolveNamespaces(
|
|
21
|
+
namespaces: string[] | NamespaceProvider,
|
|
22
|
+
ctx: HostContext,
|
|
23
|
+
): Promise<string[]> {
|
|
24
|
+
return typeof namespaces === 'function' ? namespaces(ctx) : namespaces;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 批量读取 namespace 的 redacted 记录(settings/ui 共用;单项失败跳过并告警) */
|
|
28
|
+
export async function collectNamespaceRecords(
|
|
29
|
+
ctx: HostContext,
|
|
30
|
+
names: string[],
|
|
31
|
+
warnings: string[],
|
|
32
|
+
): Promise<Record<string, NamespaceRecord>> {
|
|
33
|
+
const namespaces: Record<string, NamespaceRecord> = {};
|
|
34
|
+
for (const name of names) {
|
|
35
|
+
try {
|
|
36
|
+
const info = await ctx.settings.describe(name, { redactSecrets: true });
|
|
37
|
+
namespaces[name] = {
|
|
38
|
+
value: info.value,
|
|
39
|
+
base: info.base,
|
|
40
|
+
revision: info.revision,
|
|
41
|
+
applies: info.applies,
|
|
42
|
+
secrets: info.secrets,
|
|
43
|
+
};
|
|
44
|
+
} catch (err) {
|
|
45
|
+
warnings.push(`设置 namespace ${name} 读取失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return namespaces;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 对比导入记录与目标当前值:不存在 → Create;一致 → Skip;不同 → Conflict(纯计算,零写入) */
|
|
52
|
+
export async function planNamespaceItems(
|
|
53
|
+
data: Record<string, NamespaceRecord>,
|
|
54
|
+
adapter: 'settings' | 'ui',
|
|
55
|
+
ctx: ImportContext,
|
|
56
|
+
): Promise<PlanItem[]> {
|
|
57
|
+
const items: PlanItem[] = [];
|
|
58
|
+
for (const [name, rec] of Object.entries(data)) {
|
|
59
|
+
let current: NamespaceInfo | null = null;
|
|
60
|
+
try {
|
|
61
|
+
current = await ctx.target.settings.describe(name, { redactSecrets: true });
|
|
62
|
+
} catch {
|
|
63
|
+
current = null;
|
|
64
|
+
}
|
|
65
|
+
const id = `${adapter}:${name}`;
|
|
66
|
+
if (current === null) {
|
|
67
|
+
items.push({
|
|
68
|
+
id, kind: 'Create', adapter, description: `创建设置 ${name}`, severity: 'info',
|
|
69
|
+
target: { adapter, ref: name },
|
|
70
|
+
});
|
|
71
|
+
} else if (isDeepStrictEqual(current.value, rec.value)) {
|
|
72
|
+
items.push({ id, kind: 'Skip', adapter, description: `设置 ${name} 已一致`, severity: 'info' });
|
|
73
|
+
} else {
|
|
74
|
+
items.push({
|
|
75
|
+
id, kind: 'Conflict', adapter, description: `设置 ${name} 与目标不同`,
|
|
76
|
+
detail: `current=${JSON.stringify(current.value)} imported=${JSON.stringify(rec.value)}`.slice(0, 200),
|
|
77
|
+
severity: 'warning', target: { adapter, ref: name },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return items;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 写入单个 namespace(settings/ui 共用):读时锁 + expectedRevision 乐观锁,防覆盖并发修改 */
|
|
85
|
+
export async function applyNamespaceItem(
|
|
86
|
+
item: PlanItem,
|
|
87
|
+
sectionId: 'settings' | 'ui',
|
|
88
|
+
ctx: ImportContext,
|
|
89
|
+
): Promise<ApplyResult> {
|
|
90
|
+
const ref = item.target?.ref;
|
|
91
|
+
if (!ref) return { ok: false, message: '缺少 target.ref' };
|
|
92
|
+
const data = ctx.sections.get(sectionId) as { namespaces?: Record<string, NamespaceRecord> } | undefined;
|
|
93
|
+
const rec = data?.namespaces?.[ref];
|
|
94
|
+
if (!rec) return { ok: false, message: `导入数据缺少 namespace ${ref}` };
|
|
95
|
+
// 写入前读当前 revision 作为 expectedRevision(读时锁):目标不存在(Create)则不带锁提交
|
|
96
|
+
let expected: number | undefined;
|
|
97
|
+
try {
|
|
98
|
+
expected = (await ctx.target.settings.describe(ref, { redactSecrets: true })).revision;
|
|
99
|
+
} catch {
|
|
100
|
+
expected = undefined;
|
|
101
|
+
}
|
|
102
|
+
await ctx.target.settings.replace(ref, rec.value, expected);
|
|
103
|
+
return { ok: true };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export class SettingsAdapter implements ConfigAdapter<SettingsSection> {
|
|
107
|
+
readonly id = 'settings' as const;
|
|
108
|
+
readonly displayName = 'Settings';
|
|
109
|
+
readonly defaultIncluded = true;
|
|
110
|
+
readonly portability = 'portable' as const;
|
|
111
|
+
private readonly namespaces: string[] | NamespaceProvider;
|
|
112
|
+
|
|
113
|
+
constructor(namespaces: string[] | NamespaceProvider = []) {
|
|
114
|
+
this.namespaces = namespaces;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async export(ctx: HostContext, _options: ExportOptions): Promise<ExportSection<SettingsSection>> {
|
|
118
|
+
const warnings: string[] = [];
|
|
119
|
+
const all = await resolveNamespaces(this.namespaces, ctx);
|
|
120
|
+
const nonUi = all.filter((n) => !isUiNamespace(n));
|
|
121
|
+
if (nonUi.length === 0) warnings.push('未配置 settings namespace 清单,settings 分区为空');
|
|
122
|
+
const namespaces = await collectNamespaceRecords(ctx, nonUi, warnings);
|
|
123
|
+
return {
|
|
124
|
+
sectionId: 'settings',
|
|
125
|
+
data: { version: 1, namespaces },
|
|
126
|
+
counts: { namespaces: Object.keys(namespaces).length },
|
|
127
|
+
warnings,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async analyzeImport(data: SettingsSection, ctx: ImportContext): Promise<PlanItem[]> {
|
|
132
|
+
return planNamespaceItems(data.namespaces, 'settings', ctx);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async applyItem(item: PlanItem, ctx: ImportContext): Promise<ApplyResult> {
|
|
136
|
+
return applyNamespaceItem(item, 'settings', ctx);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async validate(data: SettingsSection): Promise<ValidationResult> {
|
|
140
|
+
const issues: ValidationResult['issues'] = [];
|
|
141
|
+
if (data === null || typeof data !== 'object') {
|
|
142
|
+
return { valid: false, issues: [{ path: '$', message: 'settings 数据必须是对象', severity: 'error' }] };
|
|
143
|
+
}
|
|
144
|
+
if (data.version !== 1) {
|
|
145
|
+
issues.push({ path: 'version', message: `version 必须为 1(收到 ${String(data.version)})`, severity: 'error' });
|
|
146
|
+
}
|
|
147
|
+
if (data.namespaces === null || typeof data.namespaces !== 'object') {
|
|
148
|
+
issues.push({ path: 'namespaces', message: '缺少 namespaces 对象', severity: 'error' });
|
|
149
|
+
} else {
|
|
150
|
+
for (const [name, rec] of Object.entries(data.namespaces)) {
|
|
151
|
+
if (rec === null || typeof rec !== 'object') {
|
|
152
|
+
issues.push({ path: `namespaces.${name}`, message: 'namespace 记录必须是对象', severity: 'error' });
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (typeof rec.revision !== 'number') {
|
|
156
|
+
issues.push({ path: `namespaces.${name}.revision`, message: 'revision 必须是数字', severity: 'error' });
|
|
157
|
+
}
|
|
158
|
+
if (!('value' in rec)) {
|
|
159
|
+
issues.push({ path: `namespaces.${name}.value`, message: '缺少 value', severity: 'error' });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return { valid: issues.filter((i) => i.severity === 'error').length === 0, issues };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* skills 分区 adapter(设计 §3.3):
|
|
3
|
+
* 数据源 = ~/.dsh/skills/(flat .md + 目录 bundle;dsh-skill-filesystem 发现)。
|
|
4
|
+
* 仅迁移用户主目录技能;~/.agents/skills、项目级技能默认不迁(研究报告 §2.2)。
|
|
5
|
+
*/
|
|
6
|
+
import { FileCollectionAdapter } from './file-collection.ts';
|
|
7
|
+
|
|
8
|
+
export class SkillsAdapter extends FileCollectionAdapter {
|
|
9
|
+
readonly id = 'skills' as const;
|
|
10
|
+
readonly displayName = 'Skills';
|
|
11
|
+
readonly defaultIncluded = true;
|
|
12
|
+
readonly portability = 'portable' as const;
|
|
13
|
+
readonly baseDir = 'skills';
|
|
14
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 适配器测试基础设施(仿 src/core/smoke.test.ts 的内存 mock 门面)。
|
|
3
|
+
* 仅供 src/adapters/*.test.ts 使用,不参与生产构建语义。
|
|
4
|
+
*/
|
|
5
|
+
import { normalizePath } from '../utils/paths.ts';
|
|
6
|
+
import { createLogger, type Logger } from '../utils/logger.ts';
|
|
7
|
+
import { sha256Hex } from '../utils/hashing.ts';
|
|
8
|
+
import type {
|
|
9
|
+
CredentialsFacade, FileSystemFacade, HostContext, ImportContext, NamespaceInfo,
|
|
10
|
+
PatchFileFacade, PluginInfo, PluginsFacade, SettingsFacade, Snapshot,
|
|
11
|
+
SnapshotStore, WorkspaceFacade,
|
|
12
|
+
} from '../core/types.ts';
|
|
13
|
+
import type { Manifest, SectionId, WorkspaceRecord } from '../schema/types.ts';
|
|
14
|
+
|
|
15
|
+
export class MemFs implements FileSystemFacade {
|
|
16
|
+
files = new Map<string, Uint8Array>();
|
|
17
|
+
private readonly homeDir: string;
|
|
18
|
+
constructor(homeDir: string) {
|
|
19
|
+
this.homeDir = homeDir;
|
|
20
|
+
}
|
|
21
|
+
private key(p: string): string {
|
|
22
|
+
return normalizePath(p.startsWith(this.homeDir) ? p : `${this.homeDir}/${p}`);
|
|
23
|
+
}
|
|
24
|
+
async readFile(relPath: string): Promise<Uint8Array> {
|
|
25
|
+
const v = this.files.get(this.key(relPath));
|
|
26
|
+
if (v === undefined) throw new Error(`ENOENT: ${relPath}`);
|
|
27
|
+
return v;
|
|
28
|
+
}
|
|
29
|
+
async writeFile(relPath: string, data: Uint8Array): Promise<void> {
|
|
30
|
+
this.files.set(this.key(relPath), data);
|
|
31
|
+
}
|
|
32
|
+
async exists(relPath: string): Promise<boolean> {
|
|
33
|
+
return this.files.has(this.key(relPath));
|
|
34
|
+
}
|
|
35
|
+
async copy(from: string, to: string): Promise<void> {
|
|
36
|
+
const v = this.files.get(this.key(from));
|
|
37
|
+
if (v === undefined) throw new Error(`ENOENT: ${from}`);
|
|
38
|
+
this.files.set(this.key(to), v);
|
|
39
|
+
}
|
|
40
|
+
async remove(relPath: string): Promise<void> {
|
|
41
|
+
this.files.delete(this.key(relPath));
|
|
42
|
+
}
|
|
43
|
+
async listRecursive(dir: string): Promise<string[]> {
|
|
44
|
+
const base = normalizePath(this.homeDir);
|
|
45
|
+
const prefix = normalizePath(dir) === '' ? base : `${base}/${normalizePath(dir)}`;
|
|
46
|
+
const out: string[] = [];
|
|
47
|
+
for (const k of this.files.keys()) {
|
|
48
|
+
if (k === prefix || k.startsWith(prefix + '/')) {
|
|
49
|
+
out.push(k.slice(base.length).replace(/^[\\/]+/, ''));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return out.sort();
|
|
53
|
+
}
|
|
54
|
+
async mkdir(): Promise<void> { /* 内存实现无需建目录 */ }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class MemSettings implements SettingsFacade {
|
|
58
|
+
ns = new Map<string, { value: unknown; base?: unknown; revision: number; applies?: string[]; secrets: { path: string[]; set: boolean }[] }>();
|
|
59
|
+
async describe(namespace: string, _opts?: { redactSecrets?: boolean }): Promise<NamespaceInfo> {
|
|
60
|
+
const rec = this.ns.get(namespace);
|
|
61
|
+
if (!rec) throw new Error(`namespace not found: ${namespace}`);
|
|
62
|
+
return { value: rec.value, base: rec.base, revision: rec.revision, applies: rec.applies, secrets: rec.secrets };
|
|
63
|
+
}
|
|
64
|
+
async replace(namespace: string, value: unknown, expectedRevision?: number): Promise<void> {
|
|
65
|
+
const rec = this.ns.get(namespace);
|
|
66
|
+
if (expectedRevision !== undefined && rec && rec.revision !== expectedRevision) {
|
|
67
|
+
throw new Error(`SETTINGS_CONFLICT: ${namespace} revision ${rec.revision} !== ${expectedRevision}`);
|
|
68
|
+
}
|
|
69
|
+
this.ns.set(namespace, {
|
|
70
|
+
value, revision: (rec?.revision ?? 0) + 1, secrets: rec?.secrets ?? [], base: rec?.base, applies: rec?.applies,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
async update?(namespace: string, patch: unknown, expectedRevision?: number): Promise<void> {
|
|
74
|
+
const rec = this.ns.get(namespace);
|
|
75
|
+
if (expectedRevision !== undefined && rec && rec.revision !== expectedRevision) {
|
|
76
|
+
throw new Error(`SETTINGS_CONFLICT: ${namespace} revision ${rec.revision} !== ${expectedRevision}`);
|
|
77
|
+
}
|
|
78
|
+
const merged = { ...(rec?.value as Record<string, unknown> | undefined), ...(patch as Record<string, unknown>) };
|
|
79
|
+
this.ns.set(namespace, { value: merged, revision: (rec?.revision ?? 0) + 1, secrets: rec?.secrets ?? [] });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export class MemCredentials implements CredentialsFacade {
|
|
84
|
+
values = new Map<string, string>();
|
|
85
|
+
async describe(ref: string): Promise<{ configured: boolean; source?: string; writable?: boolean }> {
|
|
86
|
+
const v = this.values.get(ref);
|
|
87
|
+
return { configured: v !== undefined, source: v !== undefined ? 'file' : 'env', writable: true };
|
|
88
|
+
}
|
|
89
|
+
async set(ref: string, value: string): Promise<void> { this.values.set(ref, value); }
|
|
90
|
+
async unset(ref: string): Promise<void> { this.values.delete(ref); }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class MemPlugins implements PluginsFacade {
|
|
94
|
+
installed = new Map<string, PluginInfo>();
|
|
95
|
+
async listInstalled(): Promise<PluginInfo[]> {
|
|
96
|
+
return [...this.installed.values()];
|
|
97
|
+
}
|
|
98
|
+
async install(pkg: string): Promise<{ needsRestart: boolean }> {
|
|
99
|
+
if (this.installed.has(pkg)) return { needsRestart: false };
|
|
100
|
+
this.installed.set(pkg, { name: pkg, version: '1.0.0', enabled: true });
|
|
101
|
+
return { needsRestart: true };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export class MemWorkspace implements WorkspaceFacade {
|
|
106
|
+
records = new Map<string, WorkspaceRecord>();
|
|
107
|
+
async listRecords(): Promise<WorkspaceRecord[]> { return [...this.records.values()]; }
|
|
108
|
+
async writeRecord(r: WorkspaceRecord): Promise<void> { this.records.set(r.id, r); }
|
|
109
|
+
async removeRecord(id: string): Promise<void> { this.records.delete(id); }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export class MemPatch implements PatchFileFacade {
|
|
113
|
+
lines = new Map<string, { lineId: string; raw: unknown }>();
|
|
114
|
+
async readPatchLines(_file: string): Promise<{ lineId: string; raw: unknown }[]> {
|
|
115
|
+
return [...this.lines.values()];
|
|
116
|
+
}
|
|
117
|
+
async applyPatchChanges(
|
|
118
|
+
_file: string,
|
|
119
|
+
changes: { lineId: string; raw: unknown; action: 'insert' | 'update' | 'remove' }[],
|
|
120
|
+
): Promise<void> {
|
|
121
|
+
for (const c of changes) {
|
|
122
|
+
if (c.action === 'remove') this.lines.delete(c.lineId);
|
|
123
|
+
else this.lines.set(c.lineId, { lineId: c.lineId, raw: c.raw });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export class MemSnapshotStore implements SnapshotStore {
|
|
129
|
+
snapshots = new Map<string, Snapshot>();
|
|
130
|
+
blobs = new Map<string, Uint8Array>();
|
|
131
|
+
async save(snapshot: Snapshot, blobs: Map<string, Uint8Array> = new Map()): Promise<string> {
|
|
132
|
+
this.snapshots.set(snapshot.id, snapshot);
|
|
133
|
+
for (const [k, v] of blobs) this.blobs.set(`${snapshot.id}/${k}`, v);
|
|
134
|
+
return snapshot.id;
|
|
135
|
+
}
|
|
136
|
+
async load(id: string): Promise<Snapshot> {
|
|
137
|
+
const s = this.snapshots.get(id);
|
|
138
|
+
if (!s) throw new Error(`snapshot not found: ${id}`);
|
|
139
|
+
return s;
|
|
140
|
+
}
|
|
141
|
+
async readBlob(id: string, blobPath: string): Promise<Uint8Array> {
|
|
142
|
+
const v = this.blobs.get(`${id}/${blobPath}`);
|
|
143
|
+
if (!v) throw new Error(`blob not found: ${id}/${blobPath}`);
|
|
144
|
+
return v;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export class MockHostContext implements HostContext {
|
|
149
|
+
platform: string;
|
|
150
|
+
arch = 'x64';
|
|
151
|
+
homeDir: string;
|
|
152
|
+
dshVersion = '0.1.0-rc.6';
|
|
153
|
+
log: Logger;
|
|
154
|
+
settings = new MemSettings();
|
|
155
|
+
credentials = new MemCredentials();
|
|
156
|
+
plugins = new MemPlugins();
|
|
157
|
+
workspace = new MemWorkspace();
|
|
158
|
+
patchFile = new MemPatch();
|
|
159
|
+
fs: MemFs;
|
|
160
|
+
constructor(platform: string, homeDir: string) {
|
|
161
|
+
this.platform = platform;
|
|
162
|
+
this.homeDir = homeDir;
|
|
163
|
+
this.fs = new MemFs(homeDir);
|
|
164
|
+
this.log = createLogger({ level: 'error', sink: () => {} });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function makeContext(platform: string, homeDir: string): MockHostContext {
|
|
169
|
+
return new MockHostContext(platform, homeDir);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** 构造最小合法 manifest(adapter 单测用;真实 manifest 由 exporter 生成) */
|
|
173
|
+
export function makeManifest(platform = 'win32'): Manifest {
|
|
174
|
+
return {
|
|
175
|
+
schemaVersion: 1,
|
|
176
|
+
exporter: { name: 'dsh-config-manager-test', version: '0.0.0' },
|
|
177
|
+
source: { dshVersion: '0.1.0-rc.6', platform: platform as Manifest['source']['platform'], arch: 'x64' },
|
|
178
|
+
exportedAt: new Date('2026-08-14T12:00:00.000Z').toISOString(),
|
|
179
|
+
sections: {
|
|
180
|
+
settings: false, ui: false, providers: false, plugins: false, mcp: false, prompts: false,
|
|
181
|
+
skills: false, agentPresets: false, workspaces: false, pluginFiles: false,
|
|
182
|
+
credentialsStatus: false, secrets: false, sessions: false,
|
|
183
|
+
},
|
|
184
|
+
security: { containsSecrets: false, encrypted: false, encryption: null },
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** 构造 ImportContext(adapter 单测用;sections 键运行时即 SectionId 字符串) */
|
|
189
|
+
export function makeImportContext(
|
|
190
|
+
target: HostContext,
|
|
191
|
+
sections: ReadonlyMap<string, unknown>,
|
|
192
|
+
overrides: Partial<ImportContext> = {},
|
|
193
|
+
): ImportContext {
|
|
194
|
+
return {
|
|
195
|
+
manifest: makeManifest(target.platform),
|
|
196
|
+
targetPlatform: target.platform,
|
|
197
|
+
target,
|
|
198
|
+
sections: sections as Map<SectionId, unknown>,
|
|
199
|
+
pathMappings: [],
|
|
200
|
+
resolutions: {},
|
|
201
|
+
secretInputs: {},
|
|
202
|
+
log: target.log,
|
|
203
|
+
...overrides,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export { sha256Hex };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ui adapter 测试:UI namespace 名单切分(与 settings 互斥)、uiMigrationNotes 说明导出、写回。
|
|
3
|
+
*/
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { UiAdapter, isUiNamespace, UI_MIGRATION_NOTES } from './ui.ts';
|
|
7
|
+
import { makeContext, makeImportContext } from './test-helpers.ts';
|
|
8
|
+
|
|
9
|
+
const NS = ['general', 'theme', 'pet', 'dsh-better-sidebar', 'llm-deepseek'];
|
|
10
|
+
|
|
11
|
+
test('ui: 名单识别与 settings 互斥切分', () => {
|
|
12
|
+
assert.equal(isUiNamespace('theme'), true);
|
|
13
|
+
assert.equal(isUiNamespace('pet'), true);
|
|
14
|
+
assert.equal(isUiNamespace('dsh-better-sidebar'), true);
|
|
15
|
+
assert.equal(isUiNamespace('ui-onboarding'), true);
|
|
16
|
+
assert.equal(isUiNamespace('general'), false);
|
|
17
|
+
assert.equal(isUiNamespace('llm-deepseek'), false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('ui: 导出只含 UI namespace + uiMigrationNotes(纯说明)', async () => {
|
|
21
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
22
|
+
ctx.settings.ns.set('general', { value: { theme: 'dark' }, revision: 3, secrets: [] });
|
|
23
|
+
ctx.settings.ns.set('theme', { value: { mode: 'dark' }, revision: 1, secrets: [] });
|
|
24
|
+
ctx.settings.ns.set('pet', { value: { position: 'bottom-right' }, revision: 2, secrets: [] });
|
|
25
|
+
|
|
26
|
+
const adapter = new UiAdapter(NS);
|
|
27
|
+
const out = await adapter.export(ctx, { includeSecrets: false });
|
|
28
|
+
assert.equal(out.data.version, 1);
|
|
29
|
+
assert.ok(out.data.namespaces['theme']);
|
|
30
|
+
assert.ok(out.data.namespaces['pet']);
|
|
31
|
+
assert.ok(!out.data.namespaces['general'], 'general 非 UI 类,应被 ui 排除');
|
|
32
|
+
assert.equal(out.counts.namespaces, 2);
|
|
33
|
+
assert.ok(out.data.uiMigrationNotes.length >= 1);
|
|
34
|
+
assert.equal(out.data.uiMigrationNotes[0]?.migratable, false);
|
|
35
|
+
assert.equal(UI_MIGRATION_NOTES.length, out.data.uiMigrationNotes.length);
|
|
36
|
+
|
|
37
|
+
const v = await adapter.validate(out.data);
|
|
38
|
+
assert.equal(v.valid, true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('ui: analyzeImport 与 applyItem 写回', async () => {
|
|
42
|
+
const src = makeContext('win32', 'C:\\Users\\alice');
|
|
43
|
+
src.settings.ns.set('theme', { value: { mode: 'dark' }, revision: 1, secrets: [] });
|
|
44
|
+
const adapter = new UiAdapter(NS);
|
|
45
|
+
const exported = await adapter.export(src, { includeSecrets: false });
|
|
46
|
+
const sections = new Map([['ui', exported.data]]);
|
|
47
|
+
|
|
48
|
+
const dst = makeContext('linux', '/home/bob');
|
|
49
|
+
const items = await adapter.analyzeImport(exported.data, makeImportContext(dst, sections));
|
|
50
|
+
assert.equal(items.length, 1);
|
|
51
|
+
assert.equal(items[0]?.kind, 'Create');
|
|
52
|
+
assert.equal(items[0]?.adapter, 'ui');
|
|
53
|
+
const r = await adapter.applyItem(items[0]!, makeImportContext(dst, sections));
|
|
54
|
+
assert.equal(r.ok, true);
|
|
55
|
+
assert.deepEqual(dst.settings.ns.get('theme')?.value, { mode: 'dark' });
|
|
56
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ui 分区 adapter(设计 §3.3/§4.1/§4.3):
|
|
3
|
+
* 数据源 = settings.yaml 中「UI 类」namespace(pet / dsh-better-sidebar / remote-web-ui / ui-onboarding / skin 等)。
|
|
4
|
+
* localStorage 等 Host 不可迁移的 UI 状态以 uiMigrationNotes 纯说明形式导出(不含任何值)。
|
|
5
|
+
*/
|
|
6
|
+
import type { NamespaceRecord, UiSection } from '../schema/types.ts';
|
|
7
|
+
import type {
|
|
8
|
+
ApplyResult, ConfigAdapter, ExportOptions, ExportSection, HostContext,
|
|
9
|
+
ImportContext, PlanItem, ValidationResult,
|
|
10
|
+
} from '../core/types.ts';
|
|
11
|
+
import { collectNamespaceRecords, planNamespaceItems, applyNamespaceItem, resolveNamespaces, type NamespaceProvider } from './settings.ts';
|
|
12
|
+
|
|
13
|
+
/** UI 类 namespace 前缀名单(设计 §4.1;随新插件扩展,只影响「某一 namespace 归哪一边」,不影响数据完整性) */
|
|
14
|
+
export const KNOWN_UI_NAMESPACE_PREFIXES: readonly string[] = [
|
|
15
|
+
'pet',
|
|
16
|
+
'dsh-better-sidebar',
|
|
17
|
+
'remote-web-ui',
|
|
18
|
+
'ui-onboarding',
|
|
19
|
+
'dsh-client-ui',
|
|
20
|
+
'skin',
|
|
21
|
+
'theme',
|
|
22
|
+
'layout',
|
|
23
|
+
'sidebar',
|
|
24
|
+
'appearance',
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/** namespace 名是否属于 UI 类(大小写不敏感前缀匹配) */
|
|
28
|
+
export function isUiNamespace(name: string): boolean {
|
|
29
|
+
const lower = name.toLowerCase();
|
|
30
|
+
return KNOWN_UI_NAMESPACE_PREFIXES.some((p) => lower.startsWith(p.toLowerCase()));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** localStorage 等 Host 无通道的 UI 状态说明(纯说明,不含值;研究报告 §2.2/§4.4) */
|
|
34
|
+
export const UI_MIGRATION_NOTES: UiSection['uiMigrationNotes'] = [
|
|
35
|
+
{
|
|
36
|
+
plugin: 'dsh-task-board',
|
|
37
|
+
storage: 'localStorage',
|
|
38
|
+
key: 'dsh.taskBoard.v1',
|
|
39
|
+
migratable: false,
|
|
40
|
+
reason: '任务看板数据存于浏览器 localStorage,Host 侧无访问通道,需在目标机器重新配置',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
plugin: 'dsh-aionui-panel',
|
|
44
|
+
storage: 'localStorage',
|
|
45
|
+
key: 'aionui-panel.*',
|
|
46
|
+
migratable: false,
|
|
47
|
+
reason: '面板宽度/折叠状态存于浏览器 localStorage,Host 侧无访问通道,需在目标机器重新配置',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
plugin: 'dsh-client-ui-*',
|
|
51
|
+
storage: 'localStorage',
|
|
52
|
+
key: '*',
|
|
53
|
+
migratable: false,
|
|
54
|
+
reason: '浏览器端 UI 状态无法经 Host API 迁移',
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
export class UiAdapter implements ConfigAdapter<UiSection> {
|
|
59
|
+
readonly id = 'ui' as const;
|
|
60
|
+
readonly displayName = 'UI Preferences';
|
|
61
|
+
readonly defaultIncluded = true;
|
|
62
|
+
readonly portability = 'portable' as const;
|
|
63
|
+
private readonly namespaces: string[] | NamespaceProvider;
|
|
64
|
+
|
|
65
|
+
constructor(namespaces: string[] | NamespaceProvider = []) {
|
|
66
|
+
this.namespaces = namespaces;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async export(ctx: HostContext, _options: ExportOptions): Promise<ExportSection<UiSection>> {
|
|
70
|
+
const warnings: string[] = [];
|
|
71
|
+
const all = await resolveNamespaces(this.namespaces, ctx);
|
|
72
|
+
const uiNames = all.filter((n) => isUiNamespace(n));
|
|
73
|
+
const namespaces: Record<string, NamespaceRecord> = await collectNamespaceRecords(ctx, uiNames, warnings);
|
|
74
|
+
return {
|
|
75
|
+
sectionId: 'ui',
|
|
76
|
+
data: { version: 1, namespaces, uiMigrationNotes: UI_MIGRATION_NOTES },
|
|
77
|
+
counts: { namespaces: Object.keys(namespaces).length, notes: UI_MIGRATION_NOTES.length },
|
|
78
|
+
warnings,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async analyzeImport(data: UiSection, ctx: ImportContext): Promise<PlanItem[]> {
|
|
83
|
+
return planNamespaceItems(data.namespaces, 'ui', ctx);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async applyItem(item: PlanItem, ctx: ImportContext): Promise<ApplyResult> {
|
|
87
|
+
return applyNamespaceItem(item, 'ui', ctx);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async validate(data: UiSection): Promise<ValidationResult> {
|
|
91
|
+
const issues: ValidationResult['issues'] = [];
|
|
92
|
+
if (data === null || typeof data !== 'object') {
|
|
93
|
+
return { valid: false, issues: [{ path: '$', message: 'ui 数据必须是对象', severity: 'error' }] };
|
|
94
|
+
}
|
|
95
|
+
if (data.version !== 1) {
|
|
96
|
+
issues.push({ path: 'version', message: `version 必须为 1(收到 ${String(data.version)})`, severity: 'error' });
|
|
97
|
+
}
|
|
98
|
+
if (data.namespaces === null || typeof data.namespaces !== 'object') {
|
|
99
|
+
issues.push({ path: 'namespaces', message: '缺少 namespaces 对象', severity: 'error' });
|
|
100
|
+
}
|
|
101
|
+
if (data.uiMigrationNotes !== undefined && !Array.isArray(data.uiMigrationNotes)) {
|
|
102
|
+
issues.push({ path: 'uiMigrationNotes', message: 'uiMigrationNotes 必须是数组', severity: 'error' });
|
|
103
|
+
}
|
|
104
|
+
return { valid: issues.filter((i) => i.severity === 'error').length === 0, issues };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workspaces adapter 测试:绝对路径导出、Create/Skip/Conflict + PathMapping 项、
|
|
3
|
+
* applyItem 写 record(PathMapper 先行后的映射数据)。
|
|
4
|
+
*/
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { WorkspacesAdapter } from './workspaces.ts';
|
|
8
|
+
import { makeContext, makeImportContext } from './test-helpers.ts';
|
|
9
|
+
import { applyPrefixMappings } from '../utils/paths.ts';
|
|
10
|
+
import type { WorkspacesSection } from '../schema/types.ts';
|
|
11
|
+
import type { PlanItem } from '../core/types.ts';
|
|
12
|
+
|
|
13
|
+
test('workspaces: 导出 records(含绝对路径)', async () => {
|
|
14
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
15
|
+
ctx.workspace.records.set('ws-ops', {
|
|
16
|
+
id: 'ws-ops', path: 'C:\\Users\\alice\\projects\\ops', title: 'OpsFlow', sessionIds: [],
|
|
17
|
+
});
|
|
18
|
+
const adapter = new WorkspacesAdapter();
|
|
19
|
+
const out = await adapter.export(ctx, { includeSecrets: false });
|
|
20
|
+
assert.equal(out.data.workspaces.length, 1);
|
|
21
|
+
assert.equal(out.data.workspaces[0]?.path, 'C:\\Users\\alice\\projects\\ops');
|
|
22
|
+
assert.equal(out.counts.workspaces, 1);
|
|
23
|
+
const v = await adapter.validate(out.data);
|
|
24
|
+
assert.equal(v.valid, true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('workspaces: analyzeImport 生成 Create + PathMapping(绝对路径)', async () => {
|
|
28
|
+
const src = makeContext('win32', 'C:\\Users\\alice');
|
|
29
|
+
src.workspace.records.set('ws-ops', {
|
|
30
|
+
id: 'ws-ops', path: 'C:\\Users\\alice\\projects\\ops', title: 'OpsFlow', sessionIds: [],
|
|
31
|
+
});
|
|
32
|
+
const adapter = new WorkspacesAdapter();
|
|
33
|
+
const exported = await adapter.export(src, { includeSecrets: false });
|
|
34
|
+
const sections = new Map([['workspaces', exported.data]]);
|
|
35
|
+
|
|
36
|
+
const dst = makeContext('linux', '/home/bob');
|
|
37
|
+
const items = await adapter.analyzeImport(exported.data, makeImportContext(dst, sections));
|
|
38
|
+
assert.equal(items.length, 2);
|
|
39
|
+
const create = items.find((i) => i.id === 'workspace:ws-ops');
|
|
40
|
+
const pathItem = items.find((i) => i.id === 'workspace:ws-ops:path');
|
|
41
|
+
assert.equal(create?.kind, 'Create');
|
|
42
|
+
assert.equal(pathItem?.kind, 'PathMapping');
|
|
43
|
+
assert.deepEqual(pathItem?.pathMapping?.appliesTo, ['workspaces']);
|
|
44
|
+
assert.equal(pathItem?.pathMapping?.oldPrefix, 'C:\\Users\\alice\\projects\\ops');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('workspaces: applyItem 写入映射后数据(PathMapper 先行)', async () => {
|
|
48
|
+
const src = makeContext('win32', 'C:\\Users\\alice');
|
|
49
|
+
src.workspace.records.set('ws-ops', {
|
|
50
|
+
id: 'ws-ops', path: 'C:\\Users\\alice\\projects\\ops', title: 'OpsFlow', sessionIds: [],
|
|
51
|
+
});
|
|
52
|
+
const adapter = new WorkspacesAdapter();
|
|
53
|
+
const exported = await adapter.export(src, { includeSecrets: false });
|
|
54
|
+
|
|
55
|
+
// 模拟 PathMapper:把映射应用到 sections
|
|
56
|
+
const mappedData = applyPrefixMappings(exported.data, [
|
|
57
|
+
{ oldPrefix: 'C:\\Users\\alice', newPrefix: '/home/bob', appliesTo: ['workspaces'] },
|
|
58
|
+
]) as WorkspacesSection;
|
|
59
|
+
assert.equal(mappedData.workspaces[0]?.path, '/home/bob/projects/ops');
|
|
60
|
+
|
|
61
|
+
const sections = new Map([['workspaces', mappedData]]);
|
|
62
|
+
const dst = makeContext('linux', '/home/bob');
|
|
63
|
+
const items = await adapter.analyzeImport(mappedData, makeImportContext(dst, sections));
|
|
64
|
+
const create = items.find((i) => i.id === 'workspace:ws-ops') as PlanItem;
|
|
65
|
+
const r = await adapter.applyItem(create, makeImportContext(dst, sections));
|
|
66
|
+
assert.equal(r.ok, true);
|
|
67
|
+
const rec = dst.workspace.records.get('ws-ops');
|
|
68
|
+
assert.ok(rec);
|
|
69
|
+
assert.equal(rec.path, '/home/bob/projects/ops', '写入的是映射后路径');
|
|
70
|
+
|
|
71
|
+
// PathMapping 项 applyItem 无副作用
|
|
72
|
+
const pathItem = items.find((i) => i.id === 'workspace:ws-ops:path') as PlanItem;
|
|
73
|
+
const r2 = await adapter.applyItem(pathItem, makeImportContext(dst, sections));
|
|
74
|
+
assert.equal(r2.ok, true);
|
|
75
|
+
|
|
76
|
+
// 幂等
|
|
77
|
+
const items2 = await adapter.analyzeImport(mappedData, makeImportContext(dst, sections));
|
|
78
|
+
assert.equal(items2.find((i) => i.id === 'workspace:ws-ops')?.kind, 'Skip');
|
|
79
|
+
});
|