dsh-config-manager 0.1.78 → 0.1.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/adapters/credentials.d.ts +6 -2
- package/lib/adapters/credentials.js +19 -3
- package/lib/adapters/file-collection.js +2 -1
- package/lib/adapters/mcp.d.ts +48 -2
- package/lib/adapters/mcp.js +265 -3
- package/lib/adapters/messages.d.ts +3 -0
- package/lib/adapters/messages.js +7 -0
- package/lib/adapters/plugin-files.js +2 -1
- package/lib/adapters/plugins.js +105 -20
- package/lib/adapters/pnpm-workspace.d.ts +32 -0
- package/lib/adapters/pnpm-workspace.js +113 -2
- package/lib/adapters/prompts.d.ts +6 -2
- package/lib/adapters/prompts.js +17 -2
- package/lib/adapters/providers.d.ts +19 -2
- package/lib/adapters/providers.js +59 -3
- package/lib/adapters/settings.d.ts +6 -2
- package/lib/adapters/settings.js +13 -2
- package/lib/adapters/skills.d.ts +25 -1
- package/lib/adapters/skills.js +60 -0
- package/lib/adapters/ui.d.ts +6 -2
- package/lib/adapters/ui.js +13 -2
- package/lib/adapters/units.d.ts +18 -0
- package/lib/adapters/units.js +39 -0
- package/lib/adapters/workspaces.js +2 -1
- package/lib/client.d.ts +401 -112
- package/lib/client.js +2899 -1525
- package/lib/core/backup.js +20 -0
- package/lib/core/mcp-connector-store.d.ts +159 -0
- package/lib/core/mcp-connector-store.js +445 -0
- package/lib/core/messages-core.d.ts +2 -0
- package/lib/core/messages-core.js +4 -0
- package/lib/core/messages.d.ts +20 -0
- package/lib/core/messages.js +44 -0
- package/lib/core/phase3-host.js +18 -1
- package/lib/core/plugin-cli.d.ts +22 -2
- package/lib/core/plugin-cli.js +22 -3
- package/lib/core/recovery-orchestrator.d.ts +3 -1
- package/lib/core/recovery-orchestrator.js +75 -16
- package/lib/core/rollback-activity.d.ts +89 -0
- package/lib/core/rollback-activity.js +350 -0
- package/lib/core/rollback-ledger.d.ts +0 -9
- package/lib/core/rollback-ledger.js +37 -0
- package/lib/core/rollback-merge.d.ts +22 -2
- package/lib/core/rollback-merge.js +63 -13
- package/lib/core/rollback-point.d.ts +93 -0
- package/lib/core/rollback-point.js +420 -101
- package/lib/core/rollback-undo-journal.d.ts +123 -0
- package/lib/core/rollback-undo-journal.js +517 -0
- package/lib/core/self-update.js +2 -4
- package/lib/core/text-fidelity.d.ts +89 -0
- package/lib/core/text-fidelity.js +176 -0
- package/lib/core/types.d.ts +12 -3
- package/lib/core/update-check.js +2 -4
- package/lib/core/validator.d.ts +9 -0
- package/lib/core/validator.js +12 -0
- package/lib/foreign/bundle.js +42 -3
- package/lib/foreign/claude-code.js +358 -26
- package/lib/foreign/claude-sessions.d.ts +51 -8
- package/lib/foreign/claude-sessions.js +111 -13
- package/lib/foreign/read-claude-code.d.ts +33 -4
- package/lib/foreign/read-claude-code.js +446 -65
- package/lib/foreign/read-claude-plugins.d.ts +36 -0
- package/lib/foreign/read-claude-plugins.js +403 -0
- package/lib/foreign/registry.js +25 -2
- package/lib/foreign/truth-table.js +6 -4
- package/lib/foreign/types.d.ts +170 -2
- package/lib/index.d.ts +14 -5
- package/lib/index.js +60 -16
- package/lib/market/git-file-writer.js +7 -1
- package/lib/profiles/dsh-profile-launcher.d.ts +20 -0
- package/lib/profiles/dsh-profile-launcher.js +58 -1
- package/lib/profiles/dsh-profile-shared.d.ts +8 -2
- package/lib/profiles/dsh-profile-shared.js +8 -2
- package/lib/routes/foreign.js +8 -1
- package/lib/routes/index.d.ts +1 -1
- package/lib/routes/kit.d.ts +2 -2
- package/lib/routes/sync.js +173 -9
- package/lib/schema/manifest.d.ts +6 -1
- package/lib/schema/manifest.js +55 -0
- package/lib/schema/types.d.ts +47 -2
- package/lib/sync/ancestor.d.ts +22 -0
- package/lib/sync/ancestor.js +66 -2
- package/lib/sync/autosync-config.d.ts +73 -0
- package/lib/sync/autosync-config.js +89 -0
- package/lib/sync/autosync-scheduler.d.ts +80 -6
- package/lib/sync/autosync-scheduler.js +263 -40
- package/lib/sync/gist/gist-transport.d.ts +18 -1
- package/lib/sync/gist/gist-transport.js +42 -3
- package/lib/sync/gist/messages.d.ts +2 -0
- package/lib/sync/gist/messages.js +5 -0
- package/lib/sync/git/git-transport.d.ts +42 -3
- package/lib/sync/git/git-transport.js +146 -3
- package/lib/sync/merge.d.ts +47 -14
- package/lib/sync/merge.js +242 -222
- package/lib/sync/messages.d.ts +8 -0
- package/lib/sync/messages.js +19 -0
- package/lib/sync/risk.d.ts +19 -2
- package/lib/sync/risk.js +10 -3
- package/lib/sync/s3/messages.d.ts +6 -0
- package/lib/sync/s3/messages.js +13 -0
- package/lib/sync/s3/s3-transport.d.ts +9 -1
- package/lib/sync/s3/s3-transport.js +78 -6
- package/lib/sync/s3/sigv4.js +11 -2
- package/lib/sync/sync-engine.d.ts +212 -8
- package/lib/sync/sync-engine.js +514 -58
- package/lib/sync/sync-history.d.ts +11 -0
- package/lib/sync/sync-history.js +20 -0
- package/lib/sync/sync-state.d.ts +43 -0
- package/lib/sync/sync-state.js +44 -0
- package/lib/sync/sync-watcher.d.ts +76 -0
- package/lib/sync/sync-watcher.js +202 -0
- package/lib/sync/text-merge.d.ts +85 -0
- package/lib/sync/text-merge.js +513 -0
- package/lib/sync/transport.d.ts +48 -0
- package/lib/sync/transport.js +12 -0
- package/lib/sync/watch-scope.d.ts +38 -0
- package/lib/sync/watch-scope.js +95 -0
- package/lib/sync/webdav/webdav-transport.d.ts +16 -2
- package/lib/sync/webdav/webdav-transport.js +128 -11
- package/lib/ui/foreign-view.js +10 -0
- package/lib/ui/i18n.d.ts +9 -0
- package/lib/ui/i18n.js +22 -0
- package/lib/ui/rollback-view.d.ts +12 -0
- package/lib/ui/rollback-view.js +2 -0
- package/lib/ui/sync-settings-view.d.ts +47 -0
- package/lib/ui/sync-settings-view.js +24 -0
- package/lib/ui/types.d.ts +21 -0
- package/lib/utils/env-lock.d.ts +1 -0
- package/lib/utils/env-lock.js +7 -4
- package/package.json +6 -6
- package/src/adapters/credentials.ts +22 -4
- package/src/adapters/file-collection.ts +2 -1
- package/src/adapters/mcp.test.ts +235 -1
- package/src/adapters/mcp.ts +276 -5
- package/src/adapters/messages.ts +7 -0
- package/src/adapters/plugin-files.ts +2 -1
- package/src/adapters/plugins.test.ts +176 -2
- package/src/adapters/plugins.ts +107 -21
- package/src/adapters/pnpm-workspace.test.ts +89 -1
- package/src/adapters/pnpm-workspace.ts +127 -2
- package/src/adapters/prompts.ts +20 -3
- package/src/adapters/providers.ts +62 -4
- package/src/adapters/settings.ts +16 -3
- package/src/adapters/skills.test.ts +141 -0
- package/src/adapters/skills.ts +58 -2
- package/src/adapters/ui.ts +16 -3
- package/src/adapters/units-entries.test.ts +190 -0
- package/src/adapters/units.ts +50 -0
- package/src/adapters/workspaces.ts +2 -1
- package/src/client/about/PluginUpdateDialog.tsx +4 -2
- package/src/client/about/ReleaseNotesDialog.tsx +4 -2
- package/src/client/chat/RollbackReviewDialog.tsx +3 -2
- package/src/client/common/Skeleton.tsx +32 -0
- package/src/client/common/api-base.test.ts +36 -0
- package/src/client/common/http.test.ts +12 -1
- package/src/client/common/http.ts +8 -2
- package/src/client/common/routes.ts +46 -3
- package/src/client/config-manager.module.css +321 -11
- package/src/client/home/HomePanel.tsx +14 -6
- package/src/client/import/ForeignImportView.tsx +3 -1
- package/src/client/import/import-wizard-steps.tsx +2 -1
- package/src/client/library/ArtifactRow.tsx +12 -2
- package/src/client/library/LibraryActions.tsx +6 -3
- package/src/client/library/LibraryPanel.tsx +21 -3
- package/src/client/library/RestorePlanView.tsx +4 -2
- package/src/client/locales.ts +3 -0
- package/src/client/market/market-api.test.ts +3 -2
- package/src/client/recovery/RecoveryPanel.tsx +54 -7
- package/src/client/recovery/RollbackCard.tsx +7 -2
- package/src/client/recovery/RollbackDiffPane.tsx +3 -2
- package/src/client/recovery/recovery-locales.ts +20 -2
- package/src/client/recovery/recovery-view.test.ts +39 -1
- package/src/client/recovery/recovery-view.ts +23 -1
- package/src/client/run-store.test.ts +1 -1
- package/src/client/run-store.ts +4 -0
- package/src/client/sync/AutosyncCard.tsx +49 -6
- package/src/client/sync/MergeModal.tsx +220 -0
- package/src/client/sync/MergePreviewView.tsx +306 -0
- package/src/client/sync/SyncActivityBar.tsx +31 -0
- package/src/client/sync/SyncConfirmView.tsx +2 -1
- package/src/client/sync/SyncPanel.tsx +130 -87
- package/src/client/sync/SyncProbeRow.tsx +162 -0
- package/src/client/sync/SyncSettingsView.test.ts +27 -2
- package/src/client/sync/WorkDirCard.tsx +44 -6
- package/src/client/sync/history-model.ts +4 -1
- package/src/client/sync/merge-view.test.ts +248 -0
- package/src/client/sync/merge-view.ts +398 -0
- package/src/client/sync/probe-view.test.ts +90 -0
- package/src/client/sync/probe-view.ts +63 -0
- package/src/client/sync/remote-device-view.test.ts +35 -0
- package/src/client/sync/remote-device-view.ts +25 -0
- package/src/client/sync/sync-api.test.ts +5 -3
- package/src/client/sync/sync-api.ts +212 -2
- package/src/client/sync/sync-entry.test.ts +78 -0
- package/src/client/sync/sync-entry.ts +53 -0
- package/src/client/sync/sync-locales.ts +234 -2
- package/src/client/sync/sync-view.test.ts +18 -1
- package/src/client/sync/sync-view.ts +20 -0
- package/src/core/backup.test.ts +41 -0
- package/src/core/backup.ts +20 -0
- package/src/core/mcp-connector-store.test.ts +265 -0
- package/src/core/mcp-connector-store.ts +516 -0
- package/src/core/messages-core.ts +4 -0
- package/src/core/messages.ts +46 -0
- package/src/core/phase3-host.ts +18 -1
- package/src/core/phase3-production-integration.test.ts +4 -0
- package/src/core/plugin-cli.test.ts +26 -0
- package/src/core/plugin-cli.ts +30 -4
- package/src/core/recovery-orchestrator.test.ts +104 -5
- package/src/core/recovery-orchestrator.ts +74 -17
- package/src/core/rollback-activity.test.ts +311 -0
- package/src/core/rollback-activity.ts +367 -0
- package/src/core/rollback-cross-session.test.ts +694 -0
- package/src/core/rollback-interrupted.test.ts +137 -0
- package/src/core/rollback-ledger.ts +32 -0
- package/src/core/rollback-merge.test.ts +130 -0
- package/src/core/rollback-merge.ts +78 -14
- package/src/core/rollback-point.ts +468 -91
- package/src/core/rollback-undo-journal.test.ts +508 -0
- package/src/core/rollback-undo-journal.ts +571 -0
- package/src/core/self-update.ts +2 -5
- package/src/core/text-fidelity.test.ts +189 -0
- package/src/core/text-fidelity.ts +197 -0
- package/src/core/types.ts +11 -3
- package/src/core/update-check.ts +2 -5
- package/src/core/validator.ts +14 -0
- package/src/foreign/bundle-origin.test.ts +265 -0
- package/src/foreign/bundle.ts +45 -5
- package/src/foreign/claude-code.test.ts +268 -0
- package/src/foreign/claude-code.ts +406 -25
- package/src/foreign/claude-plugins.test.ts +234 -0
- package/src/foreign/claude-sessions.test.ts +141 -0
- package/src/foreign/claude-sessions.ts +179 -23
- package/src/foreign/file-budget.test.ts +4 -1
- package/src/foreign/read-claude-code.test.ts +320 -0
- package/src/foreign/read-claude-code.ts +490 -71
- package/src/foreign/read-claude-plugins.ts +465 -0
- package/src/foreign/registry.ts +25 -2
- package/src/foreign/session-ir.test.ts +65 -0
- package/src/foreign/truth-table.test.ts +81 -0
- package/src/foreign/truth-table.ts +6 -3
- package/src/foreign/types.ts +162 -2
- package/src/index.facade.test.ts +26 -4
- package/src/index.ts +76 -19
- package/src/market/git-file-writer.test.ts +4 -0
- package/src/market/git-file-writer.ts +7 -1
- package/src/profiles/dsh-profile-launcher.test.ts +68 -3
- package/src/profiles/dsh-profile-launcher.ts +56 -1
- package/src/profiles/dsh-profile-shared.ts +8 -2
- package/src/routes/foreign.ts +8 -1
- package/src/routes/index.ts +1 -1
- package/src/routes/kit.ts +2 -2
- package/src/routes/sync.ts +176 -9
- package/src/schema/manifest-schema.test.ts +87 -2
- package/src/schema/manifest.ts +57 -1
- package/src/schema/types.ts +45 -2
- package/src/sync/ancestor.ts +76 -2
- package/src/sync/autosync-config.test.ts +73 -1
- package/src/sync/autosync-config.ts +124 -0
- package/src/sync/autosync-risk-tier.test.ts +38 -2
- package/src/sync/autosync-scheduler.test.ts +264 -12
- package/src/sync/autosync-scheduler.ts +296 -44
- package/src/sync/gist/gist-conditional-write.test.ts +203 -0
- package/src/sync/gist/gist-transport.ts +53 -5
- package/src/sync/gist/messages.ts +5 -0
- package/src/sync/git/git-probe.test.ts +80 -0
- package/src/sync/git/git-transport.test.ts +185 -0
- package/src/sync/git/git-transport.ts +178 -5
- package/src/sync/merge.test.ts +188 -55
- package/src/sync/merge.ts +282 -224
- package/src/sync/messages.ts +19 -0
- package/src/sync/risk.ts +29 -5
- package/src/sync/s3/messages.ts +13 -0
- package/src/sync/s3/s3-conditional-write.test.ts +330 -0
- package/src/sync/s3/s3-transport.test.ts +2 -1
- package/src/sync/s3/s3-transport.ts +87 -9
- package/src/sync/s3/sigv4.test.ts +4 -1
- package/src/sync/s3/sigv4.ts +11 -2
- package/src/sync/sync-device-identity.test.ts +267 -0
- package/src/sync/sync-engine.test.ts +410 -19
- package/src/sync/sync-engine.ts +602 -57
- package/src/sync/sync-history.test.ts +16 -1
- package/src/sync/sync-history.ts +26 -0
- package/src/sync/sync-state.test.ts +64 -0
- package/src/sync/sync-state.ts +79 -0
- package/src/sync/sync-watcher.test.ts +197 -0
- package/src/sync/sync-watcher.ts +246 -0
- package/src/sync/text-merge.test.ts +358 -0
- package/src/sync/text-merge.ts +554 -0
- package/src/sync/transport.ts +57 -0
- package/src/sync/watch-scope.test.ts +122 -0
- package/src/sync/watch-scope.ts +120 -0
- package/src/sync/webdav/webdav-conditional-write.test.ts +360 -0
- package/src/sync/webdav/webdav-transport.ts +137 -14
- package/src/ui/foreign-view.test.ts +34 -0
- package/src/ui/foreign-view.ts +10 -0
- package/src/ui/i18n.ts +22 -0
- package/src/ui/rollback-view.ts +14 -0
- package/src/ui/sync-settings-view.test.ts +28 -1
- package/src/ui/sync-settings-view.ts +43 -0
- package/src/ui/types.ts +22 -0
- package/src/utils/env-lock.ts +8 -5
package/src/adapters/settings.ts
CHANGED
|
@@ -11,12 +11,13 @@ import { msgOf, zhMsg } from '../core/messages.ts';
|
|
|
11
11
|
import type { MsgFunc } from '../core/messages.ts';
|
|
12
12
|
import type { NamespaceRecord, SettingsSection } from '../schema/types.ts';
|
|
13
13
|
import type {
|
|
14
|
-
ApplyResult, ConfigAdapter, ExportOptions, ExportSection, HostContext,
|
|
14
|
+
ApplyResult, ConfigAdapter, ExportOptions, ExportSection, ExportUnit, HostContext,
|
|
15
15
|
ImportContext, NamespaceInfo, PlanItem, ValidationResult,
|
|
16
16
|
} from '../core/types.ts';
|
|
17
17
|
import { isUiNamespace } from './ui.ts';
|
|
18
18
|
import { sectionMeta } from '../schema/section-registry.ts';
|
|
19
19
|
import { validateJsonSection } from './json-section.ts';
|
|
20
|
+
import { pickRecord, recordUnits } from './units.ts';
|
|
20
21
|
|
|
21
22
|
/** namespace 清单提供者:宿主从 settings.yaml 顶层 key / 插件注册表获得 */
|
|
22
23
|
export type NamespaceProvider = (ctx: HostContext) => Promise<string[]> | string[];
|
|
@@ -175,12 +176,14 @@ export class SettingsAdapter implements ConfigAdapter<SettingsSection> {
|
|
|
175
176
|
this.namespaces = namespaces;
|
|
176
177
|
}
|
|
177
178
|
|
|
178
|
-
async export(ctx: HostContext,
|
|
179
|
+
async export(ctx: HostContext, options: ExportOptions): Promise<ExportSection<SettingsSection>> {
|
|
179
180
|
const warnings: string[] = [];
|
|
180
181
|
const all = await resolveNamespaces(this.namespaces, ctx);
|
|
181
182
|
const nonUi = all.filter((n) => !isUiNamespace(n));
|
|
182
183
|
if (nonUi.length === 0) warnings.push(msgOf(ctx)('adapter.noSettingsNamespaces'));
|
|
183
|
-
|
|
184
|
+
// 条目级白名单:未勾选的 namespace 整条不进包(勾了却全量搬 = 选择器在撒谎)
|
|
185
|
+
const collected = await collectNamespaceRecords(ctx, nonUi, warnings);
|
|
186
|
+
const namespaces = pickRecord(collected, options.includeItems?.[this.id], (ns) => this.unitId(ns));
|
|
184
187
|
return {
|
|
185
188
|
sectionId: 'settings',
|
|
186
189
|
data: { version: 1, namespaces },
|
|
@@ -189,6 +192,16 @@ export class SettingsAdapter implements ConfigAdapter<SettingsSection> {
|
|
|
189
192
|
};
|
|
190
193
|
}
|
|
191
194
|
|
|
195
|
+
/** 单元 id:`settings:<namespace>`(同一命名空间下的唯一键) */
|
|
196
|
+
private unitId(namespace: string): string {
|
|
197
|
+
return this.id + ':' + namespace;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** 条目级单元清单(零 I/O:输入即 export 产物);一个 namespace = 一个单元 */
|
|
201
|
+
listUnits(section: ExportSection<SettingsSection>): ExportUnit[] {
|
|
202
|
+
return recordUnits(this.id, section.data.namespaces ?? {});
|
|
203
|
+
}
|
|
204
|
+
|
|
192
205
|
async analyzeImport(data: SettingsSection, ctx: ImportContext): Promise<PlanItem[]> {
|
|
193
206
|
return planNamespaceItems(data.namespaces, 'settings', ctx);
|
|
194
207
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* skills 分区:bundled 技能不参与备份(issue #102 回归测试)。
|
|
3
|
+
*
|
|
4
|
+
* 真机现象:导出把插件 registerProvider 注册、`source === 'bundled'` 的技能也写成
|
|
5
|
+
* `<name>/SKILL.md`,但技能服务只暴露 SKILL.md 文本,脚本/资源拿不到 ⇒ 备份是**残缺影子**;
|
|
6
|
+
* 导入后影子落在 `$DSH_HOME/skills` 与插件自带的同名技能争生效顺序,且不随插件升级更新。
|
|
7
|
+
*
|
|
8
|
+
* 三条硬约束(本文件钉住):
|
|
9
|
+
* ① 导出:service 侧 `source === 'bundled'` 不进行,其它来源照旧;
|
|
10
|
+
* ② 导入:旧备份里的 bundled 技能在**计划阶段**降级为 Skip(不写盘、无可执行 target);
|
|
11
|
+
* ③ 判不出(旧宿主没有 source / 服务抛错)时按旧行为放行 —— 绝不少导入用户真正的技能。
|
|
12
|
+
*/
|
|
13
|
+
import test from 'node:test';
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
|
|
16
|
+
import { SkillsAdapter, isMigratableSkillSource } from './skills.ts';
|
|
17
|
+
import { makeContext, makeImportContext, sha256Hex } from './test-helpers.ts';
|
|
18
|
+
import type { MockHostContext } from './test-helpers.ts';
|
|
19
|
+
import type { FileEntry, FilesSection } from '../schema/types.ts';
|
|
20
|
+
import type { SkillDefinitionView, SkillSummaryView } from '../core/types.ts';
|
|
21
|
+
|
|
22
|
+
const HOME = '/home/me';
|
|
23
|
+
|
|
24
|
+
function bytes(text: string): Uint8Array {
|
|
25
|
+
return new TextEncoder().encode(text);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 注入技能服务端口:summary 列表 + 按名取定义(get 缺省返回正文可用的定义)。 */
|
|
29
|
+
function withSkills(
|
|
30
|
+
ctx: MockHostContext,
|
|
31
|
+
summaries: SkillSummaryView[],
|
|
32
|
+
get?: (name: string) => Promise<SkillDefinitionView | undefined>,
|
|
33
|
+
): MockHostContext {
|
|
34
|
+
ctx.skills = {
|
|
35
|
+
list: async () => summaries,
|
|
36
|
+
get: get ?? (async (name: string) => {
|
|
37
|
+
const summary = summaries.find((s) => s.name === name);
|
|
38
|
+
return summary === undefined ? undefined : { ...summary, content: `body of ${name}` };
|
|
39
|
+
}),
|
|
40
|
+
};
|
|
41
|
+
return ctx;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function fileEntry(relativePath: string, text: string): FileEntry {
|
|
45
|
+
const data = bytes(text);
|
|
46
|
+
return { relativePath, data, contentHash: sha256Hex(data) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function skillsSection(files: FileEntry[]): FilesSection {
|
|
50
|
+
return { version: 1, files };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* ------------------------------------------------------------ 判据 */
|
|
54
|
+
|
|
55
|
+
test('isMigratableSkillSource:只有 bundled 不可迁移;undefined(旧宿主)必须放行', () => {
|
|
56
|
+
assert.equal(isMigratableSkillSource('bundled'), false);
|
|
57
|
+
assert.equal(isMigratableSkillSource(undefined), true, '旧宿主不报来源 —— 判成不可迁移会让技能分区恒空');
|
|
58
|
+
assert.equal(isMigratableSkillSource('user-dsh'), true);
|
|
59
|
+
assert.equal(isMigratableSkillSource('custom'), true);
|
|
60
|
+
assert.equal(isMigratableSkillSource('project-dsh'), true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/* ------------------------------------------------------ 导出侧(①) */
|
|
64
|
+
|
|
65
|
+
test('导出:service 侧 bundled 技能不进备份,其它来源与磁盘技能照旧(issue #102)', async () => {
|
|
66
|
+
const ctx = makeContext('win32', HOME);
|
|
67
|
+
await ctx.fs.writeFile('skills/disk-a/SKILL.md', bytes('---\nname: disk-a\ndescription: d\n---\n\nbody'));
|
|
68
|
+
|
|
69
|
+
withSkills(ctx, [
|
|
70
|
+
{ name: 'disk-a', description: 'on disk source wins', source: 'user-dsh' },
|
|
71
|
+
{ name: 'legit-custom', description: 'custom dir skill', source: 'custom' },
|
|
72
|
+
{ name: 'diagnose-windows-sandbox-acl', description: 'bundled by a plugin', source: 'bundled' },
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
const exported = await new SkillsAdapter().export(ctx, { includeSecrets: false });
|
|
76
|
+
const rels = exported.data.files.map((f) => f.relativePath).sort();
|
|
77
|
+
|
|
78
|
+
assert.deepEqual(rels, ['disk-a/SKILL.md', 'legit-custom/SKILL.md'], 'bundled 技能必须被排除,其余两路照旧');
|
|
79
|
+
assert.ok(rels.every((r) => !r.startsWith('diagnose-windows-sandbox-acl')), 'bundled 技能不得写出任何影子文件');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('导出:service 不报 source(旧宿主形态)时照旧收录,不因判据丢技能', async () => {
|
|
83
|
+
const ctx = makeContext('win32', HOME);
|
|
84
|
+
withSkills(ctx, [{ name: 'legacy-skill', description: 'no source field' }]);
|
|
85
|
+
|
|
86
|
+
const exported = await new SkillsAdapter().export(ctx, { includeSecrets: false });
|
|
87
|
+
assert.deepEqual(exported.data.files.map((f) => f.relativePath), ['legacy-skill/SKILL.md']);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/* ------------------------------------------------------ 导入侧(②③) */
|
|
91
|
+
|
|
92
|
+
test('导入:旧备份里的 bundled 技能降级为 Skip(无可执行 target),用户技能照常 Create', async () => {
|
|
93
|
+
const dst = makeContext('win32', HOME);
|
|
94
|
+
withSkills(dst, [
|
|
95
|
+
{ name: 'diagnose-windows-sandbox-acl', description: 'bundled by a plugin', source: 'bundled' },
|
|
96
|
+
]);
|
|
97
|
+
const section = skillsSection([
|
|
98
|
+
fileEntry('diagnose-windows-sandbox-acl/SKILL.md', '---\nname: diagnose-windows-sandbox-acl\n---\n\nbody'),
|
|
99
|
+
fileEntry('my-own-skill/SKILL.md', '---\nname: my-own-skill\n---\n\nbody'),
|
|
100
|
+
]);
|
|
101
|
+
const sections = new Map<string, unknown>([['skills', section]]);
|
|
102
|
+
const adapter = new SkillsAdapter();
|
|
103
|
+
|
|
104
|
+
const items = await adapter.analyzeImport(section, makeImportContext(dst, sections));
|
|
105
|
+
const bundled = items.find((i) => i.target?.ref === 'diagnose-windows-sandbox-acl/SKILL.md' || i.id.includes('diagnose'));
|
|
106
|
+
assert.ok(bundled !== undefined);
|
|
107
|
+
assert.equal(bundled.kind, 'Skip', 'bundled 技能在计划阶段就跳过(不写盘)');
|
|
108
|
+
assert.equal(bundled.target, undefined, '不得留下可执行的写入 target');
|
|
109
|
+
assert.equal(bundled.severity, 'info');
|
|
110
|
+
assert.match(bundled.description, /bundled/, '说明必须点名原因(插件内置提供、副本不完整)');
|
|
111
|
+
|
|
112
|
+
const own = items.find((i) => i.id.includes('my-own-skill'));
|
|
113
|
+
assert.ok(own !== undefined);
|
|
114
|
+
assert.equal(own.kind, 'Create', '用户自己的技能仍需正常导入');
|
|
115
|
+
assert.equal(own.target?.ref, 'my-own-skill/SKILL.md');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('导入:技能服务列举失败时原样放行(不因判不出而少导技能)', async () => {
|
|
119
|
+
const dst = makeContext('win32', HOME);
|
|
120
|
+
dst.skills = {
|
|
121
|
+
list: async () => { throw new Error('skill service down'); },
|
|
122
|
+
get: async () => undefined,
|
|
123
|
+
};
|
|
124
|
+
const section = skillsSection([fileEntry('diagnose-windows-sandbox-acl/SKILL.md', 'x')]);
|
|
125
|
+
const sections = new Map<string, unknown>([['skills', section]]);
|
|
126
|
+
|
|
127
|
+
const items = await new SkillsAdapter().analyzeImport(section, makeImportContext(dst, sections));
|
|
128
|
+
assert.equal(items.length, 1);
|
|
129
|
+
assert.equal(items[0]?.kind, 'Create', '服务不可用 ⇒ 退回旧行为(该写就写),绝不静默丢弃');
|
|
130
|
+
assert.equal(items[0]?.target?.ref, 'diagnose-windows-sandbox-acl/SKILL.md');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('导入:目标机无技能服务(旧宿主)时行为与改造前一致', async () => {
|
|
134
|
+
const dst = makeContext('win32', HOME);
|
|
135
|
+
const section = skillsSection([fileEntry('some-skill/SKILL.md', 'x')]);
|
|
136
|
+
const sections = new Map<string, unknown>([['skills', section]]);
|
|
137
|
+
|
|
138
|
+
const items = await new SkillsAdapter().analyzeImport(section, makeImportContext(dst, sections));
|
|
139
|
+
assert.equal(items.length, 1);
|
|
140
|
+
assert.equal(items[0]?.kind, 'Create');
|
|
141
|
+
});
|
package/src/adapters/skills.ts
CHANGED
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import path from 'node:path';
|
|
26
26
|
import { FileCollectionAdapter } from './file-collection.ts';
|
|
27
|
-
import type { HostContext, SkillDefinitionView } from '../core/types.ts';
|
|
27
|
+
import type { HostContext, ImportContext, PlanItem, SkillDefinitionView } from '../core/types.ts';
|
|
28
|
+
import type { FilesSection } from '../schema/types.ts';
|
|
28
29
|
import type { RecursiveListing } from '../utils/recursive-walk.ts';
|
|
29
30
|
import { sha256Hex } from '../utils/hashing.ts';
|
|
30
31
|
import { isPathSafe, normalizePath } from '../utils/paths.ts';
|
|
@@ -35,6 +36,21 @@ export function skillServiceRel(name: string): string {
|
|
|
35
36
|
return normalizePath(path.join(name, 'SKILL.md'));
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
/**
|
|
40
|
+
* 该来源的技能能否进本分区备份(issue #102)。
|
|
41
|
+
*
|
|
42
|
+
* `bundled` = 插件包 / DSH 内置目录里随插件发行的技能(宿主的 `SkillSummaryView.source`)。
|
|
43
|
+
* 技能服务端口只暴露 **SKILL.md 文本**(`ctx.skills.get()` 返回 name/description/content),
|
|
44
|
+
* 技能的 `scripts/`、模板、资源文件一律拿不到 —— 备份出来必然是**残缺影子**;导入后它还会以
|
|
45
|
+
* 同名同 rank 落在 `$DSH_HOME/skills`,跟插件自己的版本争谁生效,且不随插件升级更新。
|
|
46
|
+
* 正确姿势是「装/升级对应的插件」,不是搬 SKILL.md。
|
|
47
|
+
*
|
|
48
|
+
* `source === undefined`(旧宿主不报来源)必须当**可迁移**:否则旧宿主上一个技能都备份不到。
|
|
49
|
+
*/
|
|
50
|
+
export function isMigratableSkillSource(source?: string): boolean {
|
|
51
|
+
return source !== 'bundled';
|
|
52
|
+
}
|
|
53
|
+
|
|
38
54
|
/**
|
|
39
55
|
* YAML 标量编码(issue #71 真机验收发现的缺陷修复)。
|
|
40
56
|
*
|
|
@@ -168,7 +184,7 @@ export class SkillsAdapter extends FileCollectionAdapter {
|
|
|
168
184
|
const listing = await super.listRelPaths(ctx);
|
|
169
185
|
this.serviceSkills = new Map<string, SkillDefinitionView>();
|
|
170
186
|
if (ctx.skills === undefined) return listing;
|
|
171
|
-
let summaries: { name: string }[];
|
|
187
|
+
let summaries: { name: string; source?: string }[];
|
|
172
188
|
try {
|
|
173
189
|
summaries = await ctx.skills.list();
|
|
174
190
|
} catch (err) {
|
|
@@ -182,6 +198,8 @@ export class SkillsAdapter extends FileCollectionAdapter {
|
|
|
182
198
|
for (const summary of summaries) {
|
|
183
199
|
// 名字要能当路径用:DSH 的 SKILL_NAME 是 [a-z0-9-],这里再做一道通用安全校验(拒 `..`/绝对路径)
|
|
184
200
|
if (typeof summary.name !== 'string' || !isPathSafe(summary.name) || summary.name.includes('/')) continue;
|
|
201
|
+
// issue #102:bundled 技能不进备份(残缺影子,见 isMigratableSkillSource)
|
|
202
|
+
if (!isMigratableSkillSource(summary.source)) continue;
|
|
185
203
|
const rel = skillServiceRel(summary.name);
|
|
186
204
|
if (onDisk.has(rel)) continue; // 文件系统优先
|
|
187
205
|
let definition: SkillDefinitionView | undefined;
|
|
@@ -231,4 +249,42 @@ export class SkillsAdapter extends FileCollectionAdapter {
|
|
|
231
249
|
}
|
|
232
250
|
return { data, sizeBytes: data.byteLength, contentHash: sha256Hex(data) };
|
|
233
251
|
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* 导入侧:备份里的 bundled 技能一律跳过(issue #102)。
|
|
255
|
+
*
|
|
256
|
+
* 旧备份(本修复之前导出的)会把插件内置技能写成残缺影子,导入后落在 `$DSH_HOME/skills`,
|
|
257
|
+
* 与插件自己的同名技能争生效顺序。这里在**计划阶段**就把它们降级成 Skip(不写盘、不进
|
|
258
|
+
* 冲突收集器),并给出可理解的说明;不是静默丢弃。
|
|
259
|
+
*
|
|
260
|
+
* 目标机技能清单只查一次(`ctx.target.skills.list()`)—— 逐文件调 `get()` 会是 O(n²);
|
|
261
|
+
* 查不到(旧宿主 / 服务抛错)就**原样放行**,绝不因为判不出而少导入用户真正的技能。
|
|
262
|
+
*/
|
|
263
|
+
override async analyzeImport(data: FilesSection, ctx: ImportContext): Promise<PlanItem[]> {
|
|
264
|
+
const items = await super.analyzeImport(data, ctx);
|
|
265
|
+
if (ctx.target.skills === undefined) return items;
|
|
266
|
+
let bundled: Set<string>;
|
|
267
|
+
try {
|
|
268
|
+
const summaries = await ctx.target.skills.list();
|
|
269
|
+
bundled = new Set(summaries.filter((s) => !isMigratableSkillSource(s.source)).map((s) => s.name));
|
|
270
|
+
} catch (err) {
|
|
271
|
+
ctx.log.warn(`技能服务列举失败(无法识别 bundled 技能,按旧行为导入): ${err instanceof Error ? err.message : String(err)}`);
|
|
272
|
+
return items;
|
|
273
|
+
}
|
|
274
|
+
if (bundled.size === 0) return items;
|
|
275
|
+
return items.map((item) => {
|
|
276
|
+
if (item.kind === 'Error') return item;
|
|
277
|
+
const ref = item.target?.ref;
|
|
278
|
+
if (ref === undefined) return item;
|
|
279
|
+
const name = this.unitIdOf(ref);
|
|
280
|
+
if (!bundled.has(name)) return item;
|
|
281
|
+
return {
|
|
282
|
+
...item,
|
|
283
|
+
kind: 'Skip' as const,
|
|
284
|
+
severity: 'info' as const,
|
|
285
|
+
target: undefined,
|
|
286
|
+
description: ctx.msg('adapter.skillBundledSkipped', { name }),
|
|
287
|
+
};
|
|
288
|
+
});
|
|
289
|
+
}
|
|
234
290
|
}
|
package/src/adapters/ui.ts
CHANGED
|
@@ -8,12 +8,13 @@ import { zhMsg } from '../core/messages.ts';
|
|
|
8
8
|
import type { MsgFunc } from '../core/messages.ts';
|
|
9
9
|
import { engineMsg } from './messages.ts';
|
|
10
10
|
import type {
|
|
11
|
-
ApplyResult, ConfigAdapter, ExportOptions, ExportSection, HostContext,
|
|
11
|
+
ApplyResult, ConfigAdapter, ExportOptions, ExportSection, ExportUnit, HostContext,
|
|
12
12
|
ImportContext, PlanItem, ValidationResult,
|
|
13
13
|
} from '../core/types.ts';
|
|
14
14
|
import { collectNamespaceRecords, planNamespaceItems, applyNamespaceItem, resolveNamespaces, type NamespaceProvider } from './settings.ts';
|
|
15
15
|
import { sectionMeta } from '../schema/section-registry.ts';
|
|
16
16
|
import { validateJsonSection } from './json-section.ts';
|
|
17
|
+
import { pickRecord, recordUnits } from './units.ts';
|
|
17
18
|
|
|
18
19
|
/** UI 类 namespace 前缀名单(设计 §4.1;随新插件扩展,只影响「某一 namespace 归哪一边」,不影响数据完整性) */
|
|
19
20
|
export const KNOWN_UI_NAMESPACE_PREFIXES: readonly string[] = [
|
|
@@ -71,11 +72,13 @@ export class UiAdapter implements ConfigAdapter<UiSection> {
|
|
|
71
72
|
this.namespaces = namespaces;
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
async export(ctx: HostContext,
|
|
75
|
+
async export(ctx: HostContext, options: ExportOptions): Promise<ExportSection<UiSection>> {
|
|
75
76
|
const warnings: string[] = [];
|
|
76
77
|
const all = await resolveNamespaces(this.namespaces, ctx);
|
|
77
78
|
const uiNames = all.filter((n) => isUiNamespace(n));
|
|
78
|
-
const
|
|
79
|
+
const collected: Record<string, NamespaceRecord> = await collectNamespaceRecords(ctx, uiNames, warnings);
|
|
80
|
+
// 条目级白名单:未勾选的 namespace 整条不带走;迁移说明(纯说明、不含值)与选择无关,始终保留
|
|
81
|
+
const namespaces = pickRecord(collected, options.includeItems?.[this.id], (ns) => this.unitId(ns));
|
|
79
82
|
const notes = uiMigrationNotes(ctx.msg);
|
|
80
83
|
return {
|
|
81
84
|
sectionId: 'ui',
|
|
@@ -85,6 +88,16 @@ export class UiAdapter implements ConfigAdapter<UiSection> {
|
|
|
85
88
|
};
|
|
86
89
|
}
|
|
87
90
|
|
|
91
|
+
/** 单元 id:`ui:<namespace>` */
|
|
92
|
+
private unitId(namespace: string): string {
|
|
93
|
+
return this.id + ':' + namespace;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 条目级单元清单(零 I/O):一个 UI namespace = 一个单元 */
|
|
97
|
+
listUnits(section: ExportSection<UiSection>): ExportUnit[] {
|
|
98
|
+
return recordUnits(this.id, section.data.namespaces ?? {});
|
|
99
|
+
}
|
|
100
|
+
|
|
88
101
|
async analyzeImport(data: UiSection, ctx: ImportContext): Promise<PlanItem[]> {
|
|
89
102
|
return planNamespaceItems(data.namespaces, 'ui', ctx);
|
|
90
103
|
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 条目级导出选择(Phase 2)—— JSON / 记录型分区的「一个条目 = 一个单元」契约与回归。
|
|
3
|
+
*
|
|
4
|
+
* 背景:导出选择器能否展开某一行,取决于 /export-preview 拿到的 units 是否为空
|
|
5
|
+
* (src/ui/selection-model.ts 的 sectionExpandable = units.length > 0 || unitsUnread)。
|
|
6
|
+
* mcp / skills / plugins 等文件类分区早已实现 listUnits,而 settings / ui / providers /
|
|
7
|
+
* prompts / credentialsStatus 这五个「数据型」分区此前没有 —— 用户只能整体勾选、无法逐条取舍。
|
|
8
|
+
*
|
|
9
|
+
* 这里钉住四件事(每个分区都要成立):
|
|
10
|
+
* 1. listUnits 与 export 的键/条目**同源**(unit id 直接能被 includeItems 接受);
|
|
11
|
+
* 2. 部分勾选时只带走被勾的那几条;
|
|
12
|
+
* 3. 空数组 = 一条都不带(不静默产出「勾了 0 条却整分区照搬」);
|
|
13
|
+
* 4. providers 的 raw 必须随勾选收窄 —— raw 是 namespace 级快照,导入按 namespace 整体替换,
|
|
14
|
+
* 不收窄就会把「没勾的 provider」一并写回目标机(选择器撒谎)。
|
|
15
|
+
*/
|
|
16
|
+
import test from 'node:test';
|
|
17
|
+
import assert from 'node:assert/strict';
|
|
18
|
+
|
|
19
|
+
import { SettingsAdapter } from './settings.ts';
|
|
20
|
+
import { UiAdapter } from './ui.ts';
|
|
21
|
+
import { ProvidersAdapter } from './providers.ts';
|
|
22
|
+
import { PromptsAdapter } from './prompts.ts';
|
|
23
|
+
import { CredentialsAdapter } from './credentials.ts';
|
|
24
|
+
import { createAdapters } from './index.ts';
|
|
25
|
+
import { makeContext } from './test-helpers.ts';
|
|
26
|
+
import type { ExportOptions, HostContext } from '../core/types.ts';
|
|
27
|
+
|
|
28
|
+
const HOME = 'C:\\Users\\alice';
|
|
29
|
+
|
|
30
|
+
/** 单分区导出:includeItems 显式给定(undefined = 全量) */
|
|
31
|
+
function opts(section: string, allow: string[] | undefined): ExportOptions {
|
|
32
|
+
return allow === undefined ? { includeSecrets: false } : { includeSecrets: false, includeItems: { [section]: allow } };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test('settings 条目级:一个 namespace = 一个单元,勾哪条带哪条', async () => {
|
|
36
|
+
const ctx = makeContext('win32', HOME);
|
|
37
|
+
ctx.settings.ns.set('llm-pi-ai', { value: { a: 1 }, revision: 1, secrets: [] });
|
|
38
|
+
ctx.settings.ns.set('dsh-ops', { value: { b: 2 }, revision: 1, secrets: [] });
|
|
39
|
+
const adapter = new SettingsAdapter(['llm-pi-ai', 'dsh-ops']);
|
|
40
|
+
|
|
41
|
+
const full = await adapter.export(ctx, opts('settings', undefined));
|
|
42
|
+
const units = adapter.listUnits(full);
|
|
43
|
+
assert.deepEqual(units.map((u) => u.id).sort(), ['settings:dsh-ops', 'settings:llm-pi-ai']);
|
|
44
|
+
assert.deepEqual(units.map((u) => u.label).sort(), ['dsh-ops', 'llm-pi-ai']);
|
|
45
|
+
assert.ok(units.every((u) => u.sizeBytes > 0), '单元体积必须非零(选择器要显示真实体积)');
|
|
46
|
+
|
|
47
|
+
const part = await adapter.export(ctx, opts('settings', ['settings:llm-pi-ai']));
|
|
48
|
+
assert.deepEqual(Object.keys(part.data.namespaces), ['llm-pi-ai']);
|
|
49
|
+
assert.equal(part.counts?.namespaces, 1);
|
|
50
|
+
|
|
51
|
+
const none = await adapter.export(ctx, opts('settings', []));
|
|
52
|
+
assert.deepEqual(Object.keys(none.data.namespaces), [], '空数组 = 一条都不带');
|
|
53
|
+
assert.equal(none.counts?.namespaces, 0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('ui 条目级:勾选只作用于 namespace,迁移说明(纯说明)恒在', async () => {
|
|
57
|
+
const ctx = makeContext('win32', HOME);
|
|
58
|
+
ctx.settings.ns.set('pet', { value: { on: true }, revision: 1, secrets: [] });
|
|
59
|
+
ctx.settings.ns.set('theme', { value: { dark: true }, revision: 1, secrets: [] });
|
|
60
|
+
const adapter = new UiAdapter(['pet', 'theme']);
|
|
61
|
+
|
|
62
|
+
const full = await adapter.export(ctx, opts('ui', undefined));
|
|
63
|
+
assert.deepEqual(adapter.listUnits(full).map((u) => u.id).sort(), ['ui:pet', 'ui:theme']);
|
|
64
|
+
|
|
65
|
+
const none = await adapter.export(ctx, opts('ui', []));
|
|
66
|
+
assert.deepEqual(Object.keys(none.data.namespaces), []);
|
|
67
|
+
assert.ok((none.data.uiMigrationNotes?.length ?? 0) > 0, '迁移说明是提示信息,不随勾选消失');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('providers 条目级:一台 provider = 一个单元,且 raw.providers 按勾选收窄', async () => {
|
|
71
|
+
const ctx = makeContext('win32', HOME);
|
|
72
|
+
ctx.settings.ns.set('llm-pi-ai', {
|
|
73
|
+
value: { providers: { p1: { baseUrl: 'u1' }, p2: { baseUrl: 'u2' } } },
|
|
74
|
+
revision: 1,
|
|
75
|
+
secrets: [],
|
|
76
|
+
});
|
|
77
|
+
const adapter = new ProvidersAdapter(['llm-pi-ai']);
|
|
78
|
+
|
|
79
|
+
const full = await adapter.export(ctx, opts('providers', undefined));
|
|
80
|
+
const units = adapter.listUnits(full);
|
|
81
|
+
assert.deepEqual(units.map((u) => u.id).sort(), ['providers:p1', 'providers:p2']);
|
|
82
|
+
assert.equal(units.find((u) => u.id === 'providers:p1')?.detail, 'llm-pi-ai', 'detail = 来源 namespace');
|
|
83
|
+
// 全量导出不得改动 raw(旧备份逐字节兼容)
|
|
84
|
+
assert.deepEqual(Object.keys(full.data.providers['p1']!.raw as Record<string, unknown>), ['providers']);
|
|
85
|
+
assert.deepEqual(Object.keys((full.data.providers['p1']!.raw as { providers: Record<string, unknown> }).providers), ['p1', 'p2']);
|
|
86
|
+
|
|
87
|
+
const part = await adapter.export(ctx, opts('providers', ['providers:p1']));
|
|
88
|
+
assert.deepEqual(Object.keys(part.data.providers), ['p1']);
|
|
89
|
+
assert.equal(part.counts?.providers, 1);
|
|
90
|
+
// 关键:未勾的 p2 不能借 raw 混进包(导入按 namespace 整体替换)
|
|
91
|
+
assert.deepEqual(
|
|
92
|
+
Object.keys((part.data.providers['p1']!.raw as { providers: Record<string, unknown> }).providers),
|
|
93
|
+
['p1'],
|
|
94
|
+
'raw.providers 必须收窄到被勾选的 route,否则未勾的 provider 会被写回目标机',
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const none = await adapter.export(ctx, opts('providers', []));
|
|
98
|
+
assert.deepEqual(part.data.providers, part.data.providers, 'sanity');
|
|
99
|
+
assert.deepEqual(Object.keys(none.data.providers), []);
|
|
100
|
+
assert.equal(none.counts?.providers, 0);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('prompts 条目级:一条 persona / plan-mode 段 = 一个单元', async () => {
|
|
104
|
+
const ctx = makeContext('win32', HOME, 'desktop');
|
|
105
|
+
ctx.patchFile.lines.set('p1', {
|
|
106
|
+
lineId: 'p1',
|
|
107
|
+
raw: { id: 'p1', name: 'agent-x', config: { systemPrompt: { persona: 'be brief' } } },
|
|
108
|
+
});
|
|
109
|
+
ctx.patchFile.lines.set('p2', {
|
|
110
|
+
lineId: 'p2',
|
|
111
|
+
raw: { id: 'p2', name: 'agent-y', config: { systemPrompt: 'be verbose' } },
|
|
112
|
+
});
|
|
113
|
+
const adapter = new PromptsAdapter();
|
|
114
|
+
|
|
115
|
+
const full = await adapter.export(ctx, opts('prompts', undefined));
|
|
116
|
+
const units = adapter.listUnits(full);
|
|
117
|
+
assert.deepEqual(units.map((u) => u.id).sort(), ['prompts:prompt:p1:persona', 'prompts:prompt:p2:persona']);
|
|
118
|
+
assert.deepEqual(units.map((u) => u.detail).sort(), ['systemPrompt', 'systemPrompt']);
|
|
119
|
+
|
|
120
|
+
const part = await adapter.export(ctx, opts('prompts', ['prompts:prompt:p2:persona']));
|
|
121
|
+
assert.deepEqual(part.data.prompts.map((p) => p.id), ['prompt:p2:persona']);
|
|
122
|
+
|
|
123
|
+
const none = await adapter.export(ctx, opts('prompts', []));
|
|
124
|
+
assert.deepEqual(none.data.prompts, [], '空数组 = 一条都不带');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('credentialsStatus 条目级:勾掉一条 = 少一个「需补录」项', async () => {
|
|
128
|
+
const ctx = makeContext('win32', HOME);
|
|
129
|
+
ctx.credentials.values.set('TOKEN_A', 'secret-a');
|
|
130
|
+
const adapter = new CredentialsAdapter({ refs: async () => ['TOKEN_A', 'TOKEN_B'] });
|
|
131
|
+
|
|
132
|
+
const full = await adapter.export(ctx, opts('credentialsStatus', undefined));
|
|
133
|
+
const units = adapter.listUnits(full);
|
|
134
|
+
assert.deepEqual(units.map((u) => u.id).sort(), ['credentialsStatus:TOKEN_A', 'credentialsStatus:TOKEN_B']);
|
|
135
|
+
assert.equal(units.find((u) => u.id === 'credentialsStatus:TOKEN_A')?.detail, 'configured');
|
|
136
|
+
assert.equal(units.find((u) => u.id === 'credentialsStatus:TOKEN_B')?.detail, 'missing');
|
|
137
|
+
assert.ok(units.every((u) => !JSON.stringify(u).includes('secret-a')), '单元里绝不能出现凭据值');
|
|
138
|
+
|
|
139
|
+
const part = await adapter.export(ctx, opts('credentialsStatus', ['credentialsStatus:TOKEN_B']));
|
|
140
|
+
assert.deepEqual(part.data.credentials.map((c) => c.ref), ['TOKEN_B']);
|
|
141
|
+
assert.equal(part.counts?.credentials, 1);
|
|
142
|
+
|
|
143
|
+
const none = await adapter.export(ctx, opts('credentialsStatus', []));
|
|
144
|
+
assert.deepEqual(none.data.credentials, []);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('自洽性守卫:每个分区的 listUnits 所报 id 都能被自己的 export 接受', async () => {
|
|
148
|
+
const ctx = makeContext('win32', HOME);
|
|
149
|
+
ctx.settings.ns.set('llm-pi-ai', { value: { providers: { p1: {}, p2: {} } }, revision: 1, secrets: [] });
|
|
150
|
+
ctx.settings.ns.set('pet', { value: { on: true }, revision: 1, secrets: [] });
|
|
151
|
+
ctx.credentials.values.set('TOKEN_A', 'x');
|
|
152
|
+
|
|
153
|
+
// 四种 adapter 的 data 形态互不相同,统一收窄成本地结构类型(只用到 export / listUnits 两件事)
|
|
154
|
+
type UnitCase = {
|
|
155
|
+
section: string;
|
|
156
|
+
picked: (data: unknown) => number;
|
|
157
|
+
adapter: {
|
|
158
|
+
export: (c: HostContext, o: ExportOptions) => Promise<{ data: unknown }>;
|
|
159
|
+
listUnits: (s: never) => { id: string }[];
|
|
160
|
+
};
|
|
161
|
+
};
|
|
162
|
+
const cases: UnitCase[] = [
|
|
163
|
+
{ adapter: new SettingsAdapter(['llm-pi-ai']), section: 'settings', picked: (d) => Object.keys((d as { namespaces: object }).namespaces).length },
|
|
164
|
+
{ adapter: new UiAdapter(['pet']), section: 'ui', picked: (d) => Object.keys((d as { namespaces: object }).namespaces).length },
|
|
165
|
+
{ adapter: new ProvidersAdapter(['llm-pi-ai']), section: 'providers', picked: (d) => Object.keys((d as { providers: object }).providers).length },
|
|
166
|
+
{ adapter: new CredentialsAdapter({ refs: async () => ['TOKEN_A'] }), section: 'credentialsStatus', picked: (d) => (d as { credentials: unknown[] }).credentials.length },
|
|
167
|
+
];
|
|
168
|
+
for (const c of cases) {
|
|
169
|
+
const full = await c.adapter.export(ctx, { includeSecrets: false });
|
|
170
|
+
const units = c.adapter.listUnits(full as never);
|
|
171
|
+
assert.ok(units.length > 0, c.section + ' 必须有可展开的单元');
|
|
172
|
+
for (const unit of units) {
|
|
173
|
+
const only = await c.adapter.export(ctx, { includeSecrets: false, includeItems: { [c.section]: [unit.id] } });
|
|
174
|
+
assert.equal(c.picked(only.data), 1, '勾选单元 ' + unit.id + ' 后必须恰好带出它自己');
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
test('全集守卫:createAdapters() 里每个分区都实现了 listUnits,且空分区返回空数组而非抛错', () => {
|
|
179
|
+
// 为什么要有这条:选择器是否可展开只看 units 是否为空。任何 adapter 漏实现 listUnits,
|
|
180
|
+
// 那个分区就退化成「只能整体勾选」—— 用户报的现象(MCP 服务器不能展开)就是这么来的。
|
|
181
|
+
const adapters = createAdapters({ includeSessions: true });
|
|
182
|
+
assert.ok(adapters.length >= 14, 'registry 至少 14 个分区,实际 ' + adapters.length);
|
|
183
|
+
for (const adapter of adapters) {
|
|
184
|
+
assert.equal(typeof adapter.listUnits, 'function', adapter.id + ' 未实现 listUnits,该分区将无法展开');
|
|
185
|
+
const empty = { sectionId: adapter.id, data: {}, counts: {}, warnings: [] } as never;
|
|
186
|
+
// 必须 call(adapter):listUnits 内部要用 this.id,裸取方法会丢接收者(真机曾因此抛错)
|
|
187
|
+
const listUnits = adapter.listUnits as (s: never) => { id: string }[];
|
|
188
|
+
assert.deepEqual(listUnits.call(adapter, empty), [], adapter.id + ' 的空分区必须返回空数组');
|
|
189
|
+
}
|
|
190
|
+
});
|
package/src/adapters/units.ts
CHANGED
|
@@ -76,3 +76,53 @@ export function unitsFromEntries(
|
|
|
76
76
|
sizeBytes: agg.size,
|
|
77
77
|
}));
|
|
78
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* JSON / 记录型分区的单元体积 = 该条目在包里的 UTF-8 字节数。
|
|
81
|
+
*
|
|
82
|
+
* 与文件类不同,这里不需要读盘:条目本身就是 export() 的产物,直接量它的序列化长度,
|
|
83
|
+
* 预览与导出因此天然同口径(同一个函数量同一个对象)。
|
|
84
|
+
*/
|
|
85
|
+
export function entryByteSize(value: unknown): number {
|
|
86
|
+
return new TextEncoder().encode(JSON.stringify(value) ?? '').byteLength;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 按单元白名单过滤条目(JSON / 记录型分区共用)。
|
|
91
|
+
*
|
|
92
|
+
* allow === undefined = 未指定 → 全量(旧调用方 / 旧备份零改动);空数组 = 一条都不留。
|
|
93
|
+
* 与 unitAllowed 同一判据,只是作用在整批条目上。
|
|
94
|
+
*/
|
|
95
|
+
export function filterByUnits<T>(
|
|
96
|
+
items: readonly T[],
|
|
97
|
+
allow: readonly string[] | undefined,
|
|
98
|
+
idOf: (item: T) => string,
|
|
99
|
+
): T[] {
|
|
100
|
+
if (allow === undefined) return [...items];
|
|
101
|
+
return items.filter((item) => allow.includes(idOf(item)));
|
|
102
|
+
}
|
|
103
|
+
/** 记录型分区的单元清单:一个顶层键 = 一个单元(零 I/O;体积 = 条目序列化长度)。 */
|
|
104
|
+
export function recordUnits<V>(
|
|
105
|
+
sectionId: string,
|
|
106
|
+
record: Record<string, V>,
|
|
107
|
+
detailOf?: (value: V, key: string) => string | undefined,
|
|
108
|
+
): ExportUnit[] {
|
|
109
|
+
return Object.entries(record).map(([key, value]) => {
|
|
110
|
+
const unit: ExportUnit = { id: sectionId + ':' + key, label: key, sizeBytes: entryByteSize(value) };
|
|
111
|
+
const detail = detailOf?.(value, key);
|
|
112
|
+
if (detail !== undefined && detail !== '') unit.detail = detail;
|
|
113
|
+
return unit;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 记录型分区的白名单过滤(键 → 单元 id 由 idOf 决定,与 recordUnits 同一规则)。 */
|
|
118
|
+
export function pickRecord<V>(
|
|
119
|
+
record: Record<string, V>,
|
|
120
|
+
allow: readonly string[] | undefined,
|
|
121
|
+
idOf: (key: string) => string,
|
|
122
|
+
): Record<string, V> {
|
|
123
|
+
const out: Record<string, V> = {};
|
|
124
|
+
for (const [key, value] of Object.entries(record)) {
|
|
125
|
+
if (unitAllowed(allow, idOf(key))) out[key] = value;
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
@@ -104,7 +104,8 @@ export class WorkspacesAdapter implements ConfigAdapter<WorkspacesSection> {
|
|
|
104
104
|
|
|
105
105
|
/** 单元清单(零 I/O):一条工作区记录 = 一个单元;标题缺失时退回 id。 */
|
|
106
106
|
listUnits(section: ExportSection<WorkspacesSection>): ExportUnit[] {
|
|
107
|
-
|
|
107
|
+
// workspaces 可能缺省(空分区 / 手写 section):按空表处理,不抛错 —— 选择器对任何分区都会调 listUnits
|
|
108
|
+
return (section.data.workspaces ?? []).map((rec) => ({
|
|
108
109
|
id: `workspace:${rec.id}`,
|
|
109
110
|
label: rec.title !== undefined && rec.title !== '' ? rec.title : rec.id,
|
|
110
111
|
detail: rec.path,
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
import { useCallback, useEffect, useState } from 'react'
|
|
16
16
|
import type { TranslateNS } from '../client-types.ts'
|
|
17
17
|
import type { ConfigManagerApi } from '../api.ts'
|
|
18
|
-
import { Banner, Button
|
|
18
|
+
import { Banner, Button } from '../common/ui.tsx'
|
|
19
|
+
import { Skeleton } from '../common/Skeleton.tsx'
|
|
19
20
|
import { Modal } from '../common/Modal.tsx'
|
|
20
21
|
import { redact } from '../../security/redaction.ts'
|
|
21
22
|
import { ABOUT_META } from './about-view.ts'
|
|
@@ -130,7 +131,8 @@ export function PluginUpdateDialog({
|
|
|
130
131
|
|
|
131
132
|
{notes.kind === 'loading' && (
|
|
132
133
|
<div className={css.updateNotesLoading}>
|
|
133
|
-
|
|
134
|
+
{/* 更新说明是**正文块**(多段文字):按行铺骨架,读完再决定要不要装 */}
|
|
135
|
+
<Skeleton shape="line" count={6} label={t('about.update.dialog.loadingNotes')} />
|
|
134
136
|
</div>
|
|
135
137
|
)}
|
|
136
138
|
|
|
@@ -12,6 +12,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|
|
12
12
|
import type { TranslateNS } from '../client-types.ts'
|
|
13
13
|
import { redact } from '../../security/redaction.ts'
|
|
14
14
|
import { Badge, Banner, Button, Spinner } from '../common/ui.tsx'
|
|
15
|
+
import { SkeletonList } from '../common/Skeleton.tsx'
|
|
15
16
|
import { Modal } from '../common/Modal.tsx'
|
|
16
17
|
import { ABOUT_META } from './about-view.ts'
|
|
17
18
|
import {
|
|
@@ -238,8 +239,9 @@ export function ReleaseNotesDialog({
|
|
|
238
239
|
style={{ maxHeight: '65vh', gap: '12px' }}
|
|
239
240
|
>
|
|
240
241
|
{loading && (
|
|
241
|
-
<div
|
|
242
|
-
|
|
242
|
+
<div>
|
|
243
|
+
{/* 发布说明是卡片流:按卡片铺骨架(下一屏是否还有内容,看形状就知道) */}
|
|
244
|
+
<SkeletonList count={2} label={t('about.releaseNotes.loading')} />
|
|
243
245
|
</div>
|
|
244
246
|
)}
|
|
245
247
|
|
|
@@ -40,7 +40,8 @@ import type {
|
|
|
40
40
|
RollbackPreviewResult,
|
|
41
41
|
RollbackRewindResult,
|
|
42
42
|
} from '../../ui/rollback-view.ts'
|
|
43
|
-
import { Badge, Banner, Button, Checkbox, Empty
|
|
43
|
+
import { Badge, Banner, Button, Checkbox, Empty } from '../common/ui.tsx'
|
|
44
|
+
import { SkeletonList } from '../common/Skeleton.tsx'
|
|
44
45
|
import type { RollbackFileIconRenderer } from './RollbackFileIcon.tsx'
|
|
45
46
|
import { RollbackDiffPane, type RollbackDiffState } from '../recovery/RollbackDiffPane.tsx'
|
|
46
47
|
import { RollbackFileChanges } from '../recovery/RollbackFileChanges.tsx'
|
|
@@ -379,7 +380,7 @@ export function RollbackReviewDialog({ sessionId, turn, port, rt, t, openChildSe
|
|
|
379
380
|
</div>
|
|
380
381
|
|
|
381
382
|
<div className={`${css.dialogBody} ${css.dialogBodyScroll}`}>
|
|
382
|
-
{state === 'loading' && <
|
|
383
|
+
{state === 'loading' && <SkeletonList count={3} label={t('chat.entry.loading')} />}
|
|
383
384
|
|
|
384
385
|
{state === 'nopoint' && <Empty>{t('chat.entry.noPoint')}</Empty>}
|
|
385
386
|
|