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,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 迁移链(设计 §10):schemaVersion 独立演进,迁移逻辑集中于此。
|
|
3
|
+
* 当前 v1 为起点(CURRENT_SCHEMA_VERSION=1),未来 schema v2 发布时:
|
|
4
|
+
* 1) 新增 src/migrations/v1-to-v2.ts 的迁移函数;
|
|
5
|
+
* 2) 在下方 MIGRATIONS 注册;业务代码零版本判断(统一走 migrateToCurrent)。
|
|
6
|
+
*/
|
|
7
|
+
import { CURRENT_SCHEMA_VERSION, MIN_SUPPORTED_SCHEMA_VERSION, UnsupportedSchemaError } from '../schema/versions.ts';
|
|
8
|
+
import { V1_TO_V2 } from './v1-to-v2.ts';
|
|
9
|
+
|
|
10
|
+
export interface MigrationStep {
|
|
11
|
+
from: number;
|
|
12
|
+
to: number;
|
|
13
|
+
/** 纯函数式转换:输入旧结构 → 输出新结构(不触碰目标 DSH) */
|
|
14
|
+
migrate(doc: unknown): unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface MigrationResult {
|
|
18
|
+
doc: unknown;
|
|
19
|
+
migratedFrom: number;
|
|
20
|
+
applied: { from: number; to: number }[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 迁移注册表:按 from 升序的链式步骤。
|
|
25
|
+
* 当前只有 v1→v2 占位(CURRENT=1 时不会触发,schema v2 发布后生效)。
|
|
26
|
+
*/
|
|
27
|
+
export const MIGRATIONS: MigrationStep[] = [V1_TO_V2];
|
|
28
|
+
|
|
29
|
+
/** 注册新迁移步骤(供未来版本追加;保持链式无环:from 严格递增) */
|
|
30
|
+
export function registerMigration(step: MigrationStep): void {
|
|
31
|
+
const dup = MIGRATIONS.find((s) => s.from === step.from || s.to === step.to);
|
|
32
|
+
if (dup) throw new Error(`迁移步骤冲突: ${step.from}→${step.to} 与 ${dup.from}→${dup.to} 重叠`);
|
|
33
|
+
MIGRATIONS.push(step);
|
|
34
|
+
MIGRATIONS.sort((a, b) => a.from - b.from);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 沿迁移链把文档升级到目标版本(缺省当前版本)。
|
|
39
|
+
* - 已是目标版本 → 原样返回;
|
|
40
|
+
* - 高于目标 → UnsupportedSchemaError(需升级插件);
|
|
41
|
+
* - 低于最低支持 → UnsupportedSchemaError;
|
|
42
|
+
* - 无可用迁移路径 → 抛错。
|
|
43
|
+
*/
|
|
44
|
+
export function migrateToCurrent(doc: unknown, fromVersion: number, targetVersion: number = CURRENT_SCHEMA_VERSION): MigrationResult {
|
|
45
|
+
if (fromVersion === targetVersion) {
|
|
46
|
+
return { doc, migratedFrom: fromVersion, applied: [] };
|
|
47
|
+
}
|
|
48
|
+
if (fromVersion > targetVersion) {
|
|
49
|
+
throw new UnsupportedSchemaError(fromVersion, `备份 schema v${fromVersion} 高于当前 v${targetVersion},需升级本插件`);
|
|
50
|
+
}
|
|
51
|
+
if (fromVersion < MIN_SUPPORTED_SCHEMA_VERSION) {
|
|
52
|
+
throw new UnsupportedSchemaError(fromVersion, `备份 schema v${fromVersion} 低于最低支持 v${MIN_SUPPORTED_SCHEMA_VERSION}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let current = doc;
|
|
56
|
+
let version = fromVersion;
|
|
57
|
+
const applied: { from: number; to: number }[] = [];
|
|
58
|
+
while (version < targetVersion) {
|
|
59
|
+
const step = MIGRATIONS.find((s) => s.from === version && s.to <= targetVersion);
|
|
60
|
+
if (!step) {
|
|
61
|
+
throw new UnsupportedSchemaError(version, `没有从 schema v${version} 到 v${targetVersion} 的迁移路径`);
|
|
62
|
+
}
|
|
63
|
+
if (step.to <= step.from) {
|
|
64
|
+
throw new Error(`迁移步骤 ${step.from}→${step.to} 未前进(注册表损坏)`);
|
|
65
|
+
}
|
|
66
|
+
current = step.migrate(current);
|
|
67
|
+
applied.push({ from: step.from, to: step.to });
|
|
68
|
+
version = step.to;
|
|
69
|
+
}
|
|
70
|
+
return { doc: current, migratedFrom: fromVersion, applied };
|
|
71
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v1 → v2 迁移(占位):Export Schema 发布 v2 时在此实现真实转换并注册。
|
|
3
|
+
* 当前 CURRENT_SCHEMA_VERSION=1,本步骤不会触发(migrateToCurrent 目标=1)。
|
|
4
|
+
*
|
|
5
|
+
* 迁移必须是纯函数式:输入 v1 结构 → 输出 v2 结构,绝不触碰目标 DSH。
|
|
6
|
+
*/
|
|
7
|
+
import type { MigrationStep } from './index.ts';
|
|
8
|
+
|
|
9
|
+
/** v1→v2 转换(占位实现:v2 尚不存在,仅结构骨架) */
|
|
10
|
+
export const V1_TO_V2: MigrationStep = {
|
|
11
|
+
from: 1,
|
|
12
|
+
to: 2,
|
|
13
|
+
migrate(doc: unknown): unknown {
|
|
14
|
+
// TODO(schema-v2): 例如 settings 分区结构变更时在此改写;当前原样返回
|
|
15
|
+
return doc;
|
|
16
|
+
},
|
|
17
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profiles 模块公共出口(m8,Phase 6)。
|
|
3
|
+
*/
|
|
4
|
+
export {
|
|
5
|
+
ProfileManager, APPLY_ORDER, isValidProfileName, encodeSections, decodeSections,
|
|
6
|
+
type ProfileManagerOptions, type ProfileMeta, type StoredProfile,
|
|
7
|
+
type SwitchPreview, type ProfileSwitchResult,
|
|
8
|
+
} from './profile-manager.ts';
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profiles 模块测试(m8,规范 §20):
|
|
3
|
+
* 保存 / 列表 / 复制 / 重命名 / 删除 / Preview / 切换(confirm 安全阀 + 快照 + 回滚)/
|
|
4
|
+
* 冲突决策 / 文件类 base64 往返 / 导出导入往返 / 名称校验。
|
|
5
|
+
*/
|
|
6
|
+
import test from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import fs from 'node:fs/promises';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { ProfileManager, isValidProfileName } from './profile-manager.ts';
|
|
13
|
+
import { createAdapters } from '../adapters/index.ts';
|
|
14
|
+
import { makeContext, MemSnapshotStore } from '../adapters/test-helpers.ts';
|
|
15
|
+
import { ImportNotConfirmedError } from '../core/types.ts';
|
|
16
|
+
import type { ApplyResult, ConfigAdapter, ExportOptions, ExportSection, HostContext, ImportContext, PlanItem, Portability, ValidationResult } from '../core/types.ts';
|
|
17
|
+
import type { SectionId } from '../schema/types.ts';
|
|
18
|
+
|
|
19
|
+
const NS = ['general', 'theme', 'llm-deepseek'];
|
|
20
|
+
|
|
21
|
+
/** 装饰器:对指定 item id 的 applyItem 抛错(回滚测试用),其余委托内部 adapter */
|
|
22
|
+
class FailOnItemAdapter implements ConfigAdapter {
|
|
23
|
+
readonly id: SectionId;
|
|
24
|
+
readonly displayName: string;
|
|
25
|
+
readonly defaultIncluded: boolean;
|
|
26
|
+
readonly portability: Portability;
|
|
27
|
+
private readonly inner: ConfigAdapter;
|
|
28
|
+
private readonly failItemId: string;
|
|
29
|
+
constructor(inner: ConfigAdapter, failItemId: string) {
|
|
30
|
+
this.inner = inner;
|
|
31
|
+
this.failItemId = failItemId;
|
|
32
|
+
this.id = inner.id;
|
|
33
|
+
this.displayName = inner.displayName;
|
|
34
|
+
this.defaultIncluded = inner.defaultIncluded;
|
|
35
|
+
this.portability = inner.portability;
|
|
36
|
+
}
|
|
37
|
+
export(ctx: HostContext, opts: ExportOptions): Promise<ExportSection<unknown>> { return this.inner.export(ctx, opts); }
|
|
38
|
+
validate(data: unknown): Promise<ValidationResult> { return this.inner.validate(data); }
|
|
39
|
+
analyzeImport(data: unknown, ctx: ImportContext): Promise<PlanItem[]> { return this.inner.analyzeImport(data, ctx); }
|
|
40
|
+
async applyItem(item: PlanItem, ctx: ImportContext): Promise<ApplyResult> {
|
|
41
|
+
if (item.id === this.failItemId) throw new Error(`模拟失败: ${item.id}`);
|
|
42
|
+
return this.inner.applyItem(item, ctx);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function seedSource(ctx: ReturnType<typeof makeContext>): Promise<void> {
|
|
47
|
+
ctx.settings.ns.set('general', { value: { theme: 'dark', language: 'zh-CN' }, revision: 3, secrets: [] });
|
|
48
|
+
ctx.settings.ns.set('theme', { value: { mode: 'dark' }, revision: 1, secrets: [] });
|
|
49
|
+
ctx.settings.ns.set('llm-deepseek', {
|
|
50
|
+
value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://api.deepseek.com', model: 'deepseek-chat' },
|
|
51
|
+
revision: 5,
|
|
52
|
+
secrets: [{ path: ['apiKey'], set: true }],
|
|
53
|
+
});
|
|
54
|
+
await ctx.fs.writeFile('skills/coding.md', Buffer.from('# Coding skill\n', 'utf8'));
|
|
55
|
+
ctx.credentials.values.set('DEEPSEEK_API_KEY', 'sk-secret');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
test('isValidProfileName: 拒绝路径穿越与非法字符', () => {
|
|
59
|
+
assert.equal(isValidProfileName('work'), true);
|
|
60
|
+
assert.equal(isValidProfileName('Work Personal-2'), true);
|
|
61
|
+
assert.equal(isValidProfileName('../evil'), false);
|
|
62
|
+
assert.equal(isValidProfileName('a/b'), false);
|
|
63
|
+
assert.equal(isValidProfileName('a\\b'), false);
|
|
64
|
+
assert.equal(isValidProfileName(''), false);
|
|
65
|
+
assert.equal(isValidProfileName('..'), false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('saveCurrent + list: 保存 profile.json(文件类 base64 编码,settings 不含秘密值)', async () => {
|
|
69
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-profiles-save-'));
|
|
70
|
+
try {
|
|
71
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
72
|
+
await seedSource(ctx);
|
|
73
|
+
const adapters = createAdapters({ namespaces: NS });
|
|
74
|
+
const mgr = new ProfileManager({ dataDir: path.join(tmp, 'data'), ctx, adapters, snapshotStore: new MemSnapshotStore() });
|
|
75
|
+
|
|
76
|
+
const meta = await mgr.saveCurrent('work');
|
|
77
|
+
assert.equal(meta.name, 'work');
|
|
78
|
+
assert.ok(meta.sections.includes('settings'));
|
|
79
|
+
assert.ok(meta.sections.includes('skills'));
|
|
80
|
+
|
|
81
|
+
const raw = JSON.parse(await fs.readFile(path.join(tmp, 'data', 'profiles', 'work', 'profile.json'), 'utf8'));
|
|
82
|
+
assert.equal(raw.version, 1);
|
|
83
|
+
const skillFile = raw.sections.skills.files.find((f: { relativePath: string }) => f.relativePath === 'coding.md');
|
|
84
|
+
assert.ok(skillFile, 'skills 文件进入 profile');
|
|
85
|
+
assert.equal(typeof skillFile.data, 'string', '文件类 data 以 base64 内嵌');
|
|
86
|
+
// 秘密值绝不进入 profile
|
|
87
|
+
const serialized = JSON.stringify(raw);
|
|
88
|
+
assert.ok(!serialized.includes('sk-secret'), 'Profile 不得包含凭据值');
|
|
89
|
+
|
|
90
|
+
const list = await mgr.list();
|
|
91
|
+
assert.equal(list.length, 1);
|
|
92
|
+
assert.equal(list[0]?.name, 'work');
|
|
93
|
+
assert.ok(list[0]?.sections.includes('skills'));
|
|
94
|
+
} finally {
|
|
95
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('duplicate / rename / delete: 基础管理操作', async () => {
|
|
100
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-profiles-mgmt-'));
|
|
101
|
+
try {
|
|
102
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
103
|
+
await seedSource(ctx);
|
|
104
|
+
const adapters = createAdapters({ namespaces: NS });
|
|
105
|
+
const mgr = new ProfileManager({ dataDir: path.join(tmp, 'data'), ctx, adapters, snapshotStore: new MemSnapshotStore() });
|
|
106
|
+
|
|
107
|
+
await mgr.saveCurrent('work');
|
|
108
|
+
const dup = await mgr.duplicate('work', 'personal');
|
|
109
|
+
assert.equal(dup.name, 'personal');
|
|
110
|
+
assert.equal((await mgr.list()).length, 2);
|
|
111
|
+
|
|
112
|
+
const renamed = await mgr.rename('personal', 'personal-2');
|
|
113
|
+
assert.equal(renamed.name, 'personal-2');
|
|
114
|
+
assert.equal((await mgr.list()).map((m) => m.name).sort().join(','), 'personal-2,work');
|
|
115
|
+
|
|
116
|
+
await mgr.delete('personal-2');
|
|
117
|
+
assert.equal((await mgr.list()).length, 1);
|
|
118
|
+
|
|
119
|
+
await assert.rejects(() => mgr.delete('not-exists'), /不存在/);
|
|
120
|
+
await assert.rejects(() => mgr.duplicate('work', '../evil'), /非法/);
|
|
121
|
+
} finally {
|
|
122
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('analyzeSwitch Preview + executeSwitch(confirm 安全阀 + Create 应用 + 幂等)', async () => {
|
|
127
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-profiles-switch-'));
|
|
128
|
+
try {
|
|
129
|
+
const src = makeContext('win32', 'C:\\Users\\alice');
|
|
130
|
+
await seedSource(src);
|
|
131
|
+
const adapters = createAdapters({ namespaces: NS });
|
|
132
|
+
const dataDir = path.join(tmp, 'data');
|
|
133
|
+
const srcMgr = new ProfileManager({ dataDir, ctx: src, adapters, snapshotStore: new MemSnapshotStore() });
|
|
134
|
+
await srcMgr.saveCurrent('work');
|
|
135
|
+
|
|
136
|
+
const dst = makeContext('linux', '/home/bob');
|
|
137
|
+
const dstMgr = new ProfileManager({ dataDir, ctx: dst, adapters, snapshotStore: new MemSnapshotStore() });
|
|
138
|
+
|
|
139
|
+
// 未确认 → 拒绝且零写入
|
|
140
|
+
await assert.rejects(() => dstMgr.executeSwitch('work', {}), ImportNotConfirmedError);
|
|
141
|
+
assert.equal(dst.settings.ns.size, 0);
|
|
142
|
+
|
|
143
|
+
// Preview(纯读)
|
|
144
|
+
const preview = await dstMgr.analyzeSwitch('work');
|
|
145
|
+
assert.ok(preview.items.some((i) => i.id === 'settings:general' && i.kind === 'Create'), 'Preview 含 Create 项');
|
|
146
|
+
assert.ok(preview.items.some((i) => i.id === 'skills:coding.md' && i.kind === 'Create'));
|
|
147
|
+
assert.ok(preview.sectionsInProfile.includes('settings'));
|
|
148
|
+
|
|
149
|
+
// 确认执行
|
|
150
|
+
const r = await dstMgr.executeSwitch('work', { confirm: true });
|
|
151
|
+
assert.equal(r.ok, true);
|
|
152
|
+
assert.ok(r.snapshotId, '切换必须产生快照');
|
|
153
|
+
assert.deepEqual(dst.settings.ns.get('general')?.value, { theme: 'dark', language: 'zh-CN' });
|
|
154
|
+
assert.deepEqual(dst.settings.ns.get('theme')?.value, { mode: 'dark' });
|
|
155
|
+
assert.equal((dst.settings.ns.get('llm-deepseek')?.value as Record<string, unknown>).apiKeyEnv, 'DEEPSEEK_API_KEY');
|
|
156
|
+
assert.equal(Buffer.from(await dst.fs.readFile('skills/coding.md')).toString(), '# Coding skill\n', '文件类分区恢复');
|
|
157
|
+
|
|
158
|
+
// 幂等:再切 → Skip
|
|
159
|
+
const preview2 = await dstMgr.analyzeSwitch('work');
|
|
160
|
+
assert.equal(preview2.items.find((i) => i.id === 'settings:general')?.kind, 'Skip');
|
|
161
|
+
assert.equal(preview2.items.find((i) => i.id === 'skills:coding.md')?.kind, 'Skip');
|
|
162
|
+
} finally {
|
|
163
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('executeSwitch: 冲突 merge 不覆盖 / useImported 覆盖', async () => {
|
|
168
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-profiles-conflict-'));
|
|
169
|
+
try {
|
|
170
|
+
const src = makeContext('win32', 'C:\\Users\\alice');
|
|
171
|
+
src.settings.ns.set('general', { value: { theme: 'dark' }, revision: 3, secrets: [] });
|
|
172
|
+
const adapters = createAdapters({ namespaces: NS });
|
|
173
|
+
const dataDir = path.join(tmp, 'data');
|
|
174
|
+
const srcMgr = new ProfileManager({ dataDir, ctx: src, adapters, snapshotStore: new MemSnapshotStore() });
|
|
175
|
+
await srcMgr.saveCurrent('work');
|
|
176
|
+
|
|
177
|
+
const dst = makeContext('linux', '/home/bob');
|
|
178
|
+
dst.settings.ns.set('general', { value: { theme: 'light' }, revision: 9, secrets: [] });
|
|
179
|
+
const dstMgr = new ProfileManager({ dataDir, ctx: dst, adapters, snapshotStore: new MemSnapshotStore() });
|
|
180
|
+
|
|
181
|
+
// merge + 无决策 → Conflict 保留,不覆盖
|
|
182
|
+
const r1 = await dstMgr.executeSwitch('work', { confirm: true, strategy: 'merge' });
|
|
183
|
+
assert.equal(r1.ok, true);
|
|
184
|
+
assert.equal(r1.executed.find((e) => e.itemId === 'settings:general')?.status, 'skipped', '冲突未解决时跳过');
|
|
185
|
+
assert.equal((dst.settings.ns.get('general')?.value as { theme: string }).theme, 'light', '不覆盖目标');
|
|
186
|
+
|
|
187
|
+
// useImported → Update 覆盖
|
|
188
|
+
const r2 = await dstMgr.executeSwitch('work', { confirm: true, resolutions: { 'settings:general': 'useImported' } });
|
|
189
|
+
assert.equal(r2.executed.find((e) => e.itemId === 'settings:general')?.status, 'ok');
|
|
190
|
+
assert.equal((dst.settings.ns.get('general')?.value as { theme: string }).theme, 'dark', 'useImported 覆盖');
|
|
191
|
+
|
|
192
|
+
// skipExisting 全局策略 → Conflict 自动 Skip
|
|
193
|
+
dst.settings.ns.set('general', { value: { theme: 'purple' }, revision: 10, secrets: [] });
|
|
194
|
+
const r3 = await dstMgr.executeSwitch('work', { confirm: true, strategy: 'skipExisting' });
|
|
195
|
+
assert.equal(r3.executed.find((e) => e.itemId === 'settings:general')?.status, 'skipped');
|
|
196
|
+
assert.equal((dst.settings.ns.get('general')?.value as { theme: string }).theme, 'purple');
|
|
197
|
+
} finally {
|
|
198
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('executeSwitch: 中途失败 + rollbackOnError → 恢复切换前状态', async () => {
|
|
203
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-profiles-rollback-'));
|
|
204
|
+
try {
|
|
205
|
+
const src = makeContext('win32', 'C:\\Users\\alice');
|
|
206
|
+
src.settings.ns.set('general', { value: { theme: 'dark' }, revision: 3, secrets: [] });
|
|
207
|
+
await src.fs.writeFile('skills/coding.md', Buffer.from('# Coding skill\n', 'utf8'));
|
|
208
|
+
const adapters = createAdapters({ namespaces: NS });
|
|
209
|
+
const dataDir = path.join(tmp, 'data');
|
|
210
|
+
const srcMgr = new ProfileManager({ dataDir, ctx: src, adapters, snapshotStore: new MemSnapshotStore() });
|
|
211
|
+
await srcMgr.saveCurrent('work');
|
|
212
|
+
|
|
213
|
+
const dst = makeContext('linux', '/home/bob');
|
|
214
|
+
dst.settings.ns.set('general', { value: { theme: 'light' }, revision: 7, secrets: [] });
|
|
215
|
+
const failingAdapters = adapters.map((a) => new FailOnItemAdapter(a, 'skills:coding.md'));
|
|
216
|
+
const dstMgr = new ProfileManager({ dataDir, ctx: dst, adapters: failingAdapters, snapshotStore: new MemSnapshotStore() });
|
|
217
|
+
|
|
218
|
+
const r = await dstMgr.executeSwitch('work', { confirm: true, strategy: 'replace', rollbackOnError: true });
|
|
219
|
+
assert.equal(r.ok, false, '失败项 → ok=false');
|
|
220
|
+
assert.ok(r.rollback, '失败应有回滚报告');
|
|
221
|
+
assert.equal(r.rollback.full, true, '快照覆盖目标应能完整回滚');
|
|
222
|
+
assert.equal((dst.settings.ns.get('general')?.value as { theme: string }).theme, 'light', '回滚后 general 恢复');
|
|
223
|
+
// 原目标无 skills/coding.md → 回滚应删除导入写入的文件
|
|
224
|
+
await assert.rejects(() => dst.fs.readFile('skills/coding.md'), /ENOENT/, '回滚删除导入创建的文件');
|
|
225
|
+
} finally {
|
|
226
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('exportProfile / importProfile 往返(完整验证)', async () => {
|
|
231
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-profiles-xport2-'));
|
|
232
|
+
try {
|
|
233
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
234
|
+
await seedSource(ctx);
|
|
235
|
+
const adapters = createAdapters({ namespaces: NS });
|
|
236
|
+
const dataDir = path.join(tmp, 'data');
|
|
237
|
+
const mgr = new ProfileManager({ dataDir, ctx, adapters, snapshotStore: new MemSnapshotStore() });
|
|
238
|
+
await mgr.saveCurrent('work');
|
|
239
|
+
|
|
240
|
+
const outPath = path.join(tmp, 'dsh-profile-work.json');
|
|
241
|
+
await mgr.exportProfile('work', outPath);
|
|
242
|
+
|
|
243
|
+
// 同名导入冲突
|
|
244
|
+
await assert.rejects(() => mgr.importProfile(outPath), /已存在/);
|
|
245
|
+
|
|
246
|
+
// asName 导入
|
|
247
|
+
const imported = await mgr.importProfile(outPath, { asName: 'work-copy' });
|
|
248
|
+
assert.equal(imported.name, 'work-copy');
|
|
249
|
+
assert.ok(imported.sections.includes('skills'), '文件类分区随导入保留');
|
|
250
|
+
|
|
251
|
+
// 导入后的 profile 可切换到空目标
|
|
252
|
+
const dst = makeContext('linux', '/home/bob');
|
|
253
|
+
const dstMgr = new ProfileManager({ dataDir, ctx: dst, adapters, snapshotStore: new MemSnapshotStore() });
|
|
254
|
+
const r = await dstMgr.executeSwitch('work-copy', { confirm: true });
|
|
255
|
+
assert.equal(r.ok, true);
|
|
256
|
+
assert.deepEqual(dst.settings.ns.get('general')?.value, { theme: 'dark', language: 'zh-CN' });
|
|
257
|
+
assert.equal(Buffer.from(await dst.fs.readFile('skills/coding.md')).toString(), '# Coding skill\n');
|
|
258
|
+
} finally {
|
|
259
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('importProfile: 拒绝非法文件与非法名', async () => {
|
|
264
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-profiles-bad-'));
|
|
265
|
+
try {
|
|
266
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
267
|
+
const adapters = createAdapters({ namespaces: NS });
|
|
268
|
+
const mgr = new ProfileManager({ dataDir: path.join(tmp, 'data'), ctx, adapters, snapshotStore: new MemSnapshotStore() });
|
|
269
|
+
|
|
270
|
+
const badPath = path.join(tmp, 'bad.json');
|
|
271
|
+
await fs.writeFile(badPath, '{"version":999,"sections":{}}', 'utf8');
|
|
272
|
+
await assert.rejects(() => mgr.importProfile(badPath), /无效/);
|
|
273
|
+
|
|
274
|
+
const evilPath = path.join(tmp, 'evil.json');
|
|
275
|
+
await fs.writeFile(evilPath, '{"name":"../evil","version":1,"sections":{}}', 'utf8');
|
|
276
|
+
await assert.rejects(() => mgr.importProfile(evilPath), /非法/);
|
|
277
|
+
} finally {
|
|
278
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
279
|
+
}
|
|
280
|
+
});
|