@logictan/dsh-config-manager 0.1.60
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/LICENSE +21 -0
- package/README.md +105 -0
- package/cordis.patch.yml +21 -0
- package/lib/adapters/agent-instructions.d.ts +13 -0
- package/lib/adapters/agent-instructions.js +46 -0
- package/lib/adapters/agent-presets.d.ts +13 -0
- package/lib/adapters/agent-presets.js +14 -0
- package/lib/adapters/credentials.d.ts +37 -0
- package/lib/adapters/credentials.js +122 -0
- package/lib/adapters/file-collection.d.ts +15 -0
- package/lib/adapters/file-collection.js +127 -0
- package/lib/adapters/index.d.ts +57 -0
- package/lib/adapters/index.js +55 -0
- package/lib/adapters/link-report.d.ts +16 -0
- package/lib/adapters/link-report.js +47 -0
- package/lib/adapters/mcp.d.ts +28 -0
- package/lib/adapters/mcp.js +178 -0
- package/lib/adapters/plugin-files.d.ts +18 -0
- package/lib/adapters/plugin-files.js +142 -0
- package/lib/adapters/plugins.d.ts +41 -0
- package/lib/adapters/plugins.js +603 -0
- package/lib/adapters/pnpm-workspace.d.ts +26 -0
- package/lib/adapters/pnpm-workspace.js +147 -0
- package/lib/adapters/prompts.d.ts +30 -0
- package/lib/adapters/prompts.js +231 -0
- package/lib/adapters/providers.d.ts +34 -0
- package/lib/adapters/providers.js +155 -0
- package/lib/adapters/self.d.ts +17 -0
- package/lib/adapters/self.js +71 -0
- package/lib/adapters/sessions.d.ts +14 -0
- package/lib/adapters/sessions.js +15 -0
- package/lib/adapters/settings.d.ts +28 -0
- package/lib/adapters/settings.js +165 -0
- package/lib/adapters/skills.d.ts +13 -0
- package/lib/adapters/skills.js +14 -0
- package/lib/adapters/test-helpers.d.ts +125 -0
- package/lib/adapters/test-helpers.js +236 -0
- package/lib/adapters/ui.d.ts +27 -0
- package/lib/adapters/ui.js +89 -0
- package/lib/adapters/workspaces.d.ts +13 -0
- package/lib/adapters/workspaces.js +106 -0
- package/lib/client.d.ts +1026 -0
- package/lib/client.js +10047 -0
- package/lib/core/analyzer.d.ts +99 -0
- package/lib/core/analyzer.js +934 -0
- package/lib/core/backup.d.ts +138 -0
- package/lib/core/backup.js +513 -0
- package/lib/core/boot-rescue.d.ts +146 -0
- package/lib/core/boot-rescue.js +618 -0
- package/lib/core/cache-cleaner.d.ts +31 -0
- package/lib/core/cache-cleaner.js +95 -0
- package/lib/core/config-lifecycle.d.ts +180 -0
- package/lib/core/config-lifecycle.js +481 -0
- package/lib/core/config-snapshot.d.ts +135 -0
- package/lib/core/config-snapshot.js +424 -0
- package/lib/core/config-state.d.ts +70 -0
- package/lib/core/config-state.js +177 -0
- package/lib/core/consult-source.d.ts +58 -0
- package/lib/core/consult-source.js +260 -0
- package/lib/core/crash-report.d.ts +84 -0
- package/lib/core/crash-report.js +317 -0
- package/lib/core/exporter.d.ts +68 -0
- package/lib/core/exporter.js +375 -0
- package/lib/core/ghost-sweep.d.ts +19 -0
- package/lib/core/ghost-sweep.js +52 -0
- package/lib/core/importer.d.ts +60 -0
- package/lib/core/importer.js +29 -0
- package/lib/core/index.d.ts +14 -0
- package/lib/core/index.js +14 -0
- package/lib/core/journal.d.ts +197 -0
- package/lib/core/journal.js +418 -0
- package/lib/core/local-plugin-host.d.ts +35 -0
- package/lib/core/local-plugin-host.js +120 -0
- package/lib/core/local-plugin-pack.d.ts +135 -0
- package/lib/core/local-plugin-pack.js +278 -0
- package/lib/core/messages.d.ts +310 -0
- package/lib/core/messages.js +621 -0
- package/lib/core/migration-consult.d.ts +117 -0
- package/lib/core/migration-consult.js +363 -0
- package/lib/core/migration-history.d.ts +153 -0
- package/lib/core/migration-history.js +411 -0
- package/lib/core/model-tools.d.ts +50 -0
- package/lib/core/model-tools.js +148 -0
- package/lib/core/msg-types.d.ts +8 -0
- package/lib/core/msg-types.js +6 -0
- package/lib/core/phase3-child-crash.d.ts +1 -0
- package/lib/core/phase3-child-crash.js +87 -0
- package/lib/core/phase3-host.d.ts +138 -0
- package/lib/core/phase3-host.js +359 -0
- package/lib/core/phase3-prod-child.d.ts +1 -0
- package/lib/core/phase3-prod-child.js +35 -0
- package/lib/core/phase4-crash-child.d.ts +1 -0
- package/lib/core/phase4-crash-child.js +103 -0
- package/lib/core/plugin-cli.d.ts +150 -0
- package/lib/core/plugin-cli.js +497 -0
- package/lib/core/reconcile.d.ts +120 -0
- package/lib/core/reconcile.js +389 -0
- package/lib/core/recovery-orchestrator.d.ts +94 -0
- package/lib/core/recovery-orchestrator.js +327 -0
- package/lib/core/restore.d.ts +162 -0
- package/lib/core/restore.js +703 -0
- package/lib/core/rollback.d.ts +12 -0
- package/lib/core/rollback.js +145 -0
- package/lib/core/run-registry.d.ts +85 -0
- package/lib/core/run-registry.js +157 -0
- package/lib/core/startup-barrier.d.ts +53 -0
- package/lib/core/startup-barrier.js +77 -0
- package/lib/core/types.d.ts +515 -0
- package/lib/core/types.js +27 -0
- package/lib/core/undo.d.ts +73 -0
- package/lib/core/undo.js +87 -0
- package/lib/core/validator.d.ts +18 -0
- package/lib/core/validator.js +77 -0
- package/lib/core/verify-recovery.d.ts +21 -0
- package/lib/core/verify-recovery.js +256 -0
- package/lib/core/watcher.d.ts +101 -0
- package/lib/core/watcher.js +219 -0
- package/lib/index.d.ts +238 -0
- package/lib/index.js +2764 -0
- package/lib/migrations/index.d.ts +29 -0
- package/lib/migrations/index.js +56 -0
- package/lib/migrations/v1-to-v2.d.ts +9 -0
- package/lib/migrations/v1-to-v2.js +10 -0
- package/lib/profiles/index.d.ts +4 -0
- package/lib/profiles/index.js +5 -0
- package/lib/profiles/profile-manager.d.ts +106 -0
- package/lib/profiles/profile-manager.js +543 -0
- package/lib/schema/config.d.ts +28 -0
- package/lib/schema/config.js +165 -0
- package/lib/schema/index.d.ts +24 -0
- package/lib/schema/index.js +26 -0
- package/lib/schema/manifest.d.ts +28 -0
- package/lib/schema/manifest.js +109 -0
- package/lib/schema/tombstones.d.ts +46 -0
- package/lib/schema/tombstones.js +64 -0
- package/lib/schema/types.d.ts +232 -0
- package/lib/schema/types.js +8 -0
- package/lib/schema/versions.d.ts +27 -0
- package/lib/schema/versions.js +50 -0
- package/lib/security/encryption.d.ts +85 -0
- package/lib/security/encryption.js +279 -0
- package/lib/security/index.d.ts +16 -0
- package/lib/security/index.js +17 -0
- package/lib/security/integrity.d.ts +30 -0
- package/lib/security/integrity.js +78 -0
- package/lib/security/redaction.d.ts +26 -0
- package/lib/security/redaction.js +95 -0
- package/lib/security/secret-scanner.d.ts +86 -0
- package/lib/security/secret-scanner.js +404 -0
- package/lib/security/vault.d.ts +59 -0
- package/lib/security/vault.js +151 -0
- package/lib/security/zip-security.d.ts +27 -0
- package/lib/security/zip-security.js +154 -0
- package/lib/sync/autosync-config.d.ts +39 -0
- package/lib/sync/autosync-config.js +151 -0
- package/lib/sync/autosync-scheduler.d.ts +139 -0
- package/lib/sync/autosync-scheduler.js +564 -0
- package/lib/sync/fs.d.ts +18 -0
- package/lib/sync/fs.js +49 -0
- package/lib/sync/git/git-transport.d.ts +100 -0
- package/lib/sync/git/git-transport.js +437 -0
- package/lib/sync/github-auth.d.ts +131 -0
- package/lib/sync/github-auth.js +264 -0
- package/lib/sync/layout.d.ts +43 -0
- package/lib/sync/layout.js +203 -0
- package/lib/sync/snapshot-json.d.ts +9 -0
- package/lib/sync/snapshot-json.js +107 -0
- package/lib/sync/sync-config.d.ts +81 -0
- package/lib/sync/sync-config.js +281 -0
- package/lib/sync/sync-engine.d.ts +269 -0
- package/lib/sync/sync-engine.js +591 -0
- package/lib/sync/sync-history.d.ts +42 -0
- package/lib/sync/sync-history.js +78 -0
- package/lib/sync/sync-selection.d.ts +29 -0
- package/lib/sync/sync-selection.js +127 -0
- package/lib/sync/sync-session.d.ts +40 -0
- package/lib/sync/sync-session.js +54 -0
- package/lib/sync/sync-state.d.ts +37 -0
- package/lib/sync/sync-state.js +108 -0
- package/lib/sync/transport.d.ts +75 -0
- package/lib/sync/transport.js +55 -0
- package/lib/sync/ui-prefs.d.ts +34 -0
- package/lib/sync/ui-prefs.js +109 -0
- package/lib/sync/webdav/webdav-transport.d.ts +77 -0
- package/lib/sync/webdav/webdav-transport.js +451 -0
- package/lib/ui/errors.d.ts +19 -0
- package/lib/ui/errors.js +81 -0
- package/lib/ui/export-flow.d.ts +82 -0
- package/lib/ui/export-flow.js +98 -0
- package/lib/ui/i18n.d.ts +289 -0
- package/lib/ui/i18n.js +581 -0
- package/lib/ui/migration-consult-view.d.ts +91 -0
- package/lib/ui/migration-consult-view.js +115 -0
- package/lib/ui/progress.d.ts +38 -0
- package/lib/ui/progress.js +87 -0
- package/lib/ui/report.d.ts +23 -0
- package/lib/ui/report.js +153 -0
- package/lib/ui/test-helpers.d.ts +69 -0
- package/lib/ui/test-helpers.js +154 -0
- package/lib/ui/types.d.ts +257 -0
- package/lib/ui/types.js +21 -0
- package/lib/utils/atomic-write.d.ts +88 -0
- package/lib/utils/atomic-write.js +399 -0
- package/lib/utils/env-lock.d.ts +393 -0
- package/lib/utils/env-lock.js +930 -0
- package/lib/utils/hashing.d.ts +17 -0
- package/lib/utils/hashing.js +41 -0
- package/lib/utils/json.d.ts +24 -0
- package/lib/utils/json.js +76 -0
- package/lib/utils/logger.d.ts +39 -0
- package/lib/utils/logger.js +82 -0
- package/lib/utils/paths.d.ts +42 -0
- package/lib/utils/paths.js +189 -0
- package/lib/utils/proxy.d.ts +69 -0
- package/lib/utils/proxy.js +455 -0
- package/lib/utils/recursive-walk.d.ts +24 -0
- package/lib/utils/recursive-walk.js +134 -0
- package/lib/utils/zip.d.ts +54 -0
- package/lib/utils/zip.js +285 -0
- package/package.json +188 -0
- package/src/adapters/agent-instructions.ts +48 -0
- package/src/adapters/agent-presets.ts +14 -0
- package/src/adapters/credentials.test.ts +90 -0
- package/src/adapters/credentials.ts +143 -0
- package/src/adapters/file-collection.ts +137 -0
- package/src/adapters/files.test.ts +353 -0
- package/src/adapters/index.ts +94 -0
- package/src/adapters/link-report.test.ts +65 -0
- package/src/adapters/link-report.ts +61 -0
- package/src/adapters/mcp.test.ts +96 -0
- package/src/adapters/mcp.ts +185 -0
- package/src/adapters/plugin-files.ts +146 -0
- package/src/adapters/plugins.test.ts +326 -0
- package/src/adapters/plugins.ts +615 -0
- package/src/adapters/pnpm-workspace.test.ts +87 -0
- package/src/adapters/pnpm-workspace.ts +150 -0
- package/src/adapters/prompts.test.ts +110 -0
- package/src/adapters/prompts.ts +230 -0
- package/src/adapters/providers.test.ts +126 -0
- package/src/adapters/providers.ts +173 -0
- package/src/adapters/roundtrip.test.ts +190 -0
- package/src/adapters/self.test.ts +92 -0
- package/src/adapters/self.ts +75 -0
- package/src/adapters/sessions.ts +15 -0
- package/src/adapters/settings.test.ts +100 -0
- package/src/adapters/settings.ts +187 -0
- package/src/adapters/skills.ts +14 -0
- package/src/adapters/test-helpers.ts +248 -0
- package/src/adapters/ui.test.ts +57 -0
- package/src/adapters/ui.ts +108 -0
- package/src/adapters/workspaces.test.ts +106 -0
- package/src/adapters/workspaces.ts +109 -0
- package/src/client/ConfigManagerSection.tsx +90 -0
- package/src/client/api.ts +66 -0
- package/src/client/client-types.ts +18 -0
- package/src/client/common/ErrorBanner.tsx +73 -0
- package/src/client/common/Icon.tsx +129 -0
- package/src/client/common/Modal.tsx +173 -0
- package/src/client/common/ToastViewport.tsx +73 -0
- package/src/client/common/toast-store.test.ts +192 -0
- package/src/client/common/toast-store.ts +181 -0
- package/src/client/common/toast-types.ts +7 -0
- package/src/client/common/ui.tsx +342 -0
- package/src/client/config-manager.module.css +2971 -0
- package/src/client/css-modules.d.ts +8 -0
- package/src/client/index.ts +65 -0
- package/src/client/locales.ts +56 -0
- package/src/client/lucide-icons.d.ts +38 -0
- package/src/client/run-store.test.ts +223 -0
- package/src/client/run-store.ts +372 -0
- package/src/client/sync/SyncConfirmView.tsx +379 -0
- package/src/client/sync/SyncHistoryView.test.ts +40 -0
- package/src/client/sync/SyncHistoryView.tsx +220 -0
- package/src/client/sync/SyncSettingsView.tsx +1535 -0
- package/src/client/sync/history-model.test.ts +194 -0
- package/src/client/sync/history-model.ts +195 -0
- package/src/client/sync/sync-api.test.ts +585 -0
- package/src/client/sync/sync-api.ts +543 -0
- package/src/client/sync/sync-locales.ts +416 -0
- package/src/client/sync/sync-push-preview.test.ts +52 -0
- package/src/client/sync/sync-view-v2.test.ts +213 -0
- package/src/client/sync/sync-view.test.ts +487 -0
- package/src/client/sync/sync-view.ts +759 -0
- package/src/core/analyzer.ts +1068 -0
- package/src/core/backup.ts +605 -0
- package/src/core/boot-rescue.test.ts +847 -0
- package/src/core/boot-rescue.ts +737 -0
- package/src/core/cache-cleaner.test.ts +169 -0
- package/src/core/cache-cleaner.ts +121 -0
- package/src/core/config-lifecycle.test.ts +727 -0
- package/src/core/config-lifecycle.ts +573 -0
- package/src/core/config-snapshot.test.ts +369 -0
- package/src/core/config-snapshot.ts +522 -0
- package/src/core/config-state.test.ts +381 -0
- package/src/core/config-state.ts +207 -0
- package/src/core/consult-source.test.ts +199 -0
- package/src/core/consult-source.ts +293 -0
- package/src/core/crash-report.test.ts +380 -0
- package/src/core/crash-report.ts +369 -0
- package/src/core/exporter.ts +417 -0
- package/src/core/ghost-sweep.test.ts +94 -0
- package/src/core/ghost-sweep.ts +67 -0
- package/src/core/importer.ts +83 -0
- package/src/core/index.ts +56 -0
- package/src/core/journal.test.ts +257 -0
- package/src/core/journal.ts +518 -0
- package/src/core/local-plugin-host.ts +152 -0
- package/src/core/local-plugin-pack.test.ts +359 -0
- package/src/core/local-plugin-pack.ts +363 -0
- package/src/core/messages.test.ts +54 -0
- package/src/core/messages.ts +664 -0
- package/src/core/migration-consult.test.ts +315 -0
- package/src/core/migration-consult.ts +494 -0
- package/src/core/migration-history.test.ts +319 -0
- package/src/core/migration-history.ts +484 -0
- package/src/core/model-tools.test.ts +190 -0
- package/src/core/model-tools.ts +208 -0
- package/src/core/msg-types.ts +10 -0
- package/src/core/phase3-child-crash.ts +95 -0
- package/src/core/phase3-host.ts +411 -0
- package/src/core/phase3-p1.test.ts +212 -0
- package/src/core/phase3-p2b-fingerprint.test.ts +189 -0
- package/src/core/phase3-prod-child.ts +36 -0
- package/src/core/phase3-production-integration.test.ts +286 -0
- package/src/core/phase4-crash-child.ts +101 -0
- package/src/core/phase4-crash-injection.test.ts +166 -0
- package/src/core/plugin-cli.fs.test.ts +184 -0
- package/src/core/plugin-cli.test.ts +137 -0
- package/src/core/plugin-cli.ts +563 -0
- package/src/core/reconcile.test.ts +392 -0
- package/src/core/reconcile.ts +498 -0
- package/src/core/recovery-orchestrator.test.ts +145 -0
- package/src/core/recovery-orchestrator.ts +366 -0
- package/src/core/restore-manage.test.ts +94 -0
- package/src/core/restore.ts +849 -0
- package/src/core/rollback.ts +151 -0
- package/src/core/run-progress.test.ts +237 -0
- package/src/core/run-registry.test.ts +232 -0
- package/src/core/run-registry.ts +209 -0
- package/src/core/smoke.test.ts +842 -0
- package/src/core/startup-barrier.ts +99 -0
- package/src/core/types.ts +542 -0
- package/src/core/undo.ts +116 -0
- package/src/core/validator.ts +84 -0
- package/src/core/verify-recovery.ts +265 -0
- package/src/core/watcher.test.ts +326 -0
- package/src/core/watcher.ts +263 -0
- package/src/index.facade.test.ts +265 -0
- package/src/index.sync.test.ts +217 -0
- package/src/index.ts +2975 -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 +282 -0
- package/src/profiles/profile-manager.ts +630 -0
- package/src/schema/config.ts +165 -0
- package/src/schema/index.ts +71 -0
- package/src/schema/manifest-schema.test.ts +519 -0
- package/src/schema/manifest.ts +124 -0
- package/src/schema/tombstones.test.ts +131 -0
- package/src/schema/tombstones.ts +89 -0
- package/src/schema/types.ts +245 -0
- package/src/schema/versions.ts +55 -0
- package/src/security/encryption.ts +335 -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 +445 -0
- package/src/security/security.test.ts +1215 -0
- package/src/security/vault.test.ts +164 -0
- package/src/security/vault.ts +198 -0
- package/src/security/zip-security.ts +180 -0
- package/src/sync/autosync-config.test.ts +147 -0
- package/src/sync/autosync-config.ts +179 -0
- package/src/sync/autosync-scheduler.test.ts +490 -0
- package/src/sync/autosync-scheduler.ts +646 -0
- package/src/sync/fs.ts +48 -0
- package/src/sync/git/git-transport.test.ts +673 -0
- package/src/sync/git/git-transport.ts +491 -0
- package/src/sync/github-auth.test.ts +280 -0
- package/src/sync/github-auth.ts +356 -0
- package/src/sync/layout.test.ts +270 -0
- package/src/sync/layout.ts +222 -0
- package/src/sync/snapshot-json.test.ts +119 -0
- package/src/sync/snapshot-json.ts +128 -0
- package/src/sync/sync-config.test.ts +310 -0
- package/src/sync/sync-config.ts +299 -0
- package/src/sync/sync-engine.test.ts +1074 -0
- package/src/sync/sync-engine.ts +754 -0
- package/src/sync/sync-history.test.ts +116 -0
- package/src/sync/sync-history.ts +120 -0
- package/src/sync/sync-selection.test.ts +186 -0
- package/src/sync/sync-selection.ts +143 -0
- package/src/sync/sync-session.test.ts +143 -0
- package/src/sync/sync-session.ts +77 -0
- package/src/sync/sync-state.test.ts +164 -0
- package/src/sync/sync-state.ts +131 -0
- package/src/sync/transport.test.ts +111 -0
- package/src/sync/transport.ts +118 -0
- package/src/sync/ui-prefs.test.ts +152 -0
- package/src/sync/ui-prefs.ts +135 -0
- package/src/sync/webdav/webdav-transport.test.ts +871 -0
- package/src/sync/webdav/webdav-transport.ts +535 -0
- package/src/ui/errors.test.ts +47 -0
- package/src/ui/errors.ts +100 -0
- package/src/ui/export-flow.test.ts +87 -0
- package/src/ui/export-flow.ts +156 -0
- package/src/ui/i18n.ts +590 -0
- package/src/ui/migration-consult-view.test.ts +151 -0
- package/src/ui/migration-consult-view.ts +178 -0
- package/src/ui/progress.test.ts +47 -0
- package/src/ui/progress.ts +102 -0
- package/src/ui/report.test.ts +101 -0
- package/src/ui/report.ts +146 -0
- package/src/ui/test-helpers.ts +184 -0
- package/src/ui/types.ts +325 -0
- package/src/utils/atomic-write.test.ts +312 -0
- package/src/utils/atomic-write.ts +422 -0
- package/src/utils/env-lock.test.ts +999 -0
- package/src/utils/env-lock.ts +1086 -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 +176 -0
- package/src/utils/proxy.test.ts +465 -0
- package/src/utils/proxy.ts +493 -0
- package/src/utils/recursive-walk.test.ts +148 -0
- package/src/utils/recursive-walk.ts +145 -0
- package/src/utils/zip.ts +332 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* providers adapter 测试:llm-deepseek(单 provider)与 llm-pi-ai(多 provider)导出、
|
|
3
|
+
* Create/Skip/Conflict 分析、整体 namespace 写回(raw + 乐观锁)。
|
|
4
|
+
*/
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { ProvidersAdapter } from './providers.ts';
|
|
8
|
+
import { createSecretScanner } from '../security/secret-scanner.ts';
|
|
9
|
+
import { makeContext, makeImportContext } from './test-helpers.ts';
|
|
10
|
+
import type { PlanItem } from '../core/types.ts';
|
|
11
|
+
|
|
12
|
+
test('providers: 导出单/多 provider section(含 revision 与 raw)', async () => {
|
|
13
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
14
|
+
ctx.settings.ns.set('llm-deepseek', {
|
|
15
|
+
value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://api.deepseek.com', model: 'deepseek-chat', apiKey: 'sk-secret' },
|
|
16
|
+
revision: 5,
|
|
17
|
+
secrets: [{ path: ['apiKey'], set: true }],
|
|
18
|
+
});
|
|
19
|
+
ctx.settings.ns.set('llm-pi-ai', {
|
|
20
|
+
value: {
|
|
21
|
+
providers: {
|
|
22
|
+
openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://api.openai.com', models: ['gpt-4o'] },
|
|
23
|
+
local: { apiKeyEnv: 'LOCAL_KEY', baseURL: 'http://127.0.0.1:11434' },
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
revision: 2,
|
|
27
|
+
secrets: [],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const adapter = new ProvidersAdapter();
|
|
31
|
+
const out = await adapter.export(ctx, { includeSecrets: false });
|
|
32
|
+
assert.equal(out.data.version, 1);
|
|
33
|
+
const routes = Object.keys(out.data.providers).sort();
|
|
34
|
+
assert.deepEqual(routes, ['llm-deepseek', 'local', 'openai']);
|
|
35
|
+
const deepseek = out.data.providers['llm-deepseek'];
|
|
36
|
+
assert.equal(deepseek?.namespace, 'llm-deepseek');
|
|
37
|
+
assert.equal(deepseek?.revision, 5);
|
|
38
|
+
assert.equal(deepseek?.apiKeyEnv, 'DEEPSEEK_API_KEY');
|
|
39
|
+
assert.ok(deepseek?.raw, 'raw 保存完整 redacted namespace 值');
|
|
40
|
+
const openai = out.data.providers['openai'];
|
|
41
|
+
assert.equal(openai?.namespace, 'llm-pi-ai');
|
|
42
|
+
assert.equal(openai?.baseURL, 'https://api.openai.com');
|
|
43
|
+
assert.equal(out.counts.providers, 3);
|
|
44
|
+
|
|
45
|
+
const v = await adapter.validate(out.data);
|
|
46
|
+
assert.equal(v.valid, true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('providers: analyzeImport Create/Skip/Conflict + applyItem 写回整个 namespace', async () => {
|
|
50
|
+
const src = makeContext('win32', 'C:\\Users\\alice');
|
|
51
|
+
src.settings.ns.set('llm-pi-ai', {
|
|
52
|
+
value: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://api.openai.com' } } },
|
|
53
|
+
revision: 2,
|
|
54
|
+
secrets: [],
|
|
55
|
+
});
|
|
56
|
+
const adapter = new ProvidersAdapter();
|
|
57
|
+
const exported = await adapter.export(src, { includeSecrets: false });
|
|
58
|
+
const sections = new Map([['providers', exported.data]]);
|
|
59
|
+
|
|
60
|
+
const dst = makeContext('linux', '/home/bob');
|
|
61
|
+
dst.settings.registered.add('llm-pi-ai'); // 目标已安装提供该命名空间的插件(空值 → Create 初始化)
|
|
62
|
+
let items = await adapter.analyzeImport(exported.data, makeImportContext(dst, sections));
|
|
63
|
+
assert.equal(items.length, 1);
|
|
64
|
+
assert.equal(items[0]?.kind, 'Create');
|
|
65
|
+
assert.equal(items[0]?.id, 'provider:openai');
|
|
66
|
+
assert.equal(items[0]?.target?.ref, 'llm-pi-ai', '快照定位 namespace');
|
|
67
|
+
let r = await adapter.applyItem(items[0]!, makeImportContext(dst, sections));
|
|
68
|
+
assert.equal(r.ok, true);
|
|
69
|
+
assert.deepEqual(dst.settings.ns.get('llm-pi-ai')?.value, { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://api.openai.com' } } });
|
|
70
|
+
|
|
71
|
+
// 幂等:一致 → Skip
|
|
72
|
+
items = await adapter.analyzeImport(exported.data, makeImportContext(dst, sections));
|
|
73
|
+
assert.equal(items[0]?.kind, 'Skip');
|
|
74
|
+
|
|
75
|
+
// 目标不同 → Conflict;useImported 覆盖
|
|
76
|
+
dst.settings.ns.set('llm-pi-ai', {
|
|
77
|
+
value: { providers: { openai: { apiKeyEnv: 'OTHER_KEY', baseURL: 'https://other.example.com' } } },
|
|
78
|
+
revision: 8,
|
|
79
|
+
secrets: [],
|
|
80
|
+
});
|
|
81
|
+
items = await adapter.analyzeImport(exported.data, makeImportContext(dst, sections));
|
|
82
|
+
assert.equal(items[0]?.kind, 'Conflict');
|
|
83
|
+
const useItem: PlanItem = { ...items[0]!, kind: 'Update' };
|
|
84
|
+
await adapter.applyItem(useItem, makeImportContext(dst, sections));
|
|
85
|
+
assert.deepEqual(dst.settings.ns.get('llm-pi-ai')?.value, { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://api.openai.com' } } });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('providers: validate 拒绝缺 namespace/revision 的记录', async () => {
|
|
89
|
+
const adapter = new ProvidersAdapter();
|
|
90
|
+
const bad = await adapter.validate({ version: 1, providers: { x: { route: 'x' } as never } });
|
|
91
|
+
assert.equal(bad.valid, false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('providers: 多 route 共享 secrets 引用不触发强化 scanner 循环误报(m4 createSecretScanner)', async () => {
|
|
95
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
96
|
+
ctx.settings.ns.set('llm-pi-ai', {
|
|
97
|
+
value: {
|
|
98
|
+
providers: {
|
|
99
|
+
openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://api.openai.com', models: ['gpt-4o'], apiKey: 'sk-openai-123' },
|
|
100
|
+
local: { apiKeyEnv: 'LOCAL_KEY', baseURL: 'http://127.0.0.1:11434', token: 'local-token-456' },
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
revision: 2,
|
|
104
|
+
secrets: [{ path: ['apiKey'], set: true }],
|
|
105
|
+
});
|
|
106
|
+
const adapter = new ProvidersAdapter();
|
|
107
|
+
const exported = await adapter.export(ctx, { includeSecrets: false });
|
|
108
|
+
assert.equal(Object.keys(exported.data.providers).length, 2);
|
|
109
|
+
|
|
110
|
+
// 隔离断言:route 间 secrets / raw 必须独立引用(共享引用会被 visited 循环检测误判)
|
|
111
|
+
const openai = exported.data.providers['openai'];
|
|
112
|
+
const local = exported.data.providers['local'];
|
|
113
|
+
assert.notStrictEqual(openai?.secrets, local?.secrets, 'route 间 secrets 数组必须独立');
|
|
114
|
+
assert.notStrictEqual(openai?.raw, local?.raw, 'route 间 raw 必须独立');
|
|
115
|
+
|
|
116
|
+
// m4 强化版 scanner 扫描导出数据:不得抛「检测到循环引用」,且敏感字段被剥离
|
|
117
|
+
const scanner = createSecretScanner();
|
|
118
|
+
let scanned: { sanitized: unknown; hits: import('../core/types.ts').SensitiveHit[] };
|
|
119
|
+
assert.doesNotThrow(() => { scanned = scanner.scanAndRedact(exported.data); }, '共享引用不得被误判为循环');
|
|
120
|
+
assert.ok(scanned!.hits.length >= 2, `apiKey/token 字段应被剥离(实际 ${scanned!.hits.length})`);
|
|
121
|
+
const sanitizedProviders = (scanned!.sanitized as { providers: Record<string, Record<string, unknown>> }).providers;
|
|
122
|
+
assert.equal(sanitizedProviders['openai']?.['apiKey'], '', 'apiKey 值剥离');
|
|
123
|
+
assert.equal(sanitizedProviders['local']?.['token'], '', 'token 值剥离');
|
|
124
|
+
assert.equal(sanitizedProviders['openai']?.['apiKeyEnv'], 'OPENAI_API_KEY', '引用字段 apiKeyEnv 保留');
|
|
125
|
+
assert.equal(sanitizedProviders['openai']?.['baseURL'], 'https://api.openai.com', '非敏感字段保留');
|
|
126
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* providers 分区 adapter(设计 §3.3/§13.1):
|
|
3
|
+
* 数据源 = settings.yaml 的 llm-deepseek / llm-pi-ai section(providers 与 models 天然同 section,不拆文件)。
|
|
4
|
+
*
|
|
5
|
+
* 导入走 settings 写通道(replace llm-* namespace + 乐观锁):
|
|
6
|
+
* 每项携带 raw(该 namespace 的完整 redacted 值)用于整体写回,apiKey 值经 credentials 状态占位(不导出值)。
|
|
7
|
+
*/
|
|
8
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
9
|
+
import { msgOf, zhMsg } from '../core/messages.ts';
|
|
10
|
+
import type { MsgFunc } from '../core/messages.ts';
|
|
11
|
+
import type { ProviderEntry } from '../schema/types.ts';
|
|
12
|
+
import type {
|
|
13
|
+
ApplyResult, ConfigAdapter, ExportOptions, ExportSection, HostContext,
|
|
14
|
+
ImportContext, NamespaceInfo, PlanItem, ValidationResult,
|
|
15
|
+
} from '../core/types.ts';
|
|
16
|
+
import { resolveNamespaces, isEmptyValue, type NamespaceProvider } from './settings.ts';
|
|
17
|
+
|
|
18
|
+
/** 导出记录:ProviderEntry 之外附加 namespace 级元数据(导入写回用),满足 ProvidersSection 形状 */
|
|
19
|
+
export interface ProviderExportEntry extends ProviderEntry {
|
|
20
|
+
/** 来源 settings namespace(llm-deepseek / llm-pi-ai) */
|
|
21
|
+
namespace: string;
|
|
22
|
+
/** 乐观锁 revision(导入时检测并发修改) */
|
|
23
|
+
revision: number;
|
|
24
|
+
secrets?: { path: string[]; set: boolean }[];
|
|
25
|
+
/** 该 namespace 的完整 redacted 值(导入整体写回,避免按字段重建丢失结构) */
|
|
26
|
+
raw?: Record<string, unknown>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ProviderExportSection { version: 1; providers: Record<string, ProviderExportEntry>; }
|
|
30
|
+
|
|
31
|
+
export const DEFAULT_PROVIDER_NAMESPACES: readonly string[] = ['llm-deepseek', 'llm-pi-ai'];
|
|
32
|
+
|
|
33
|
+
/** 无 raw 时的兜底重建:只取 ProviderEntry 已知字段 */
|
|
34
|
+
function stripEntry(entry: ProviderExportEntry): Record<string, unknown> {
|
|
35
|
+
const out: Record<string, unknown> = {};
|
|
36
|
+
for (const key of ['apiKeyEnv', 'displayName', 'baseURL', 'models', 'modelOverrides', 'reasoning', 'transport', 'retryPolicy'] as const) {
|
|
37
|
+
if (entry[key] !== undefined) out[key] = entry[key];
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class ProvidersAdapter implements ConfigAdapter<ProviderExportSection> {
|
|
43
|
+
readonly id = 'providers' as const;
|
|
44
|
+
readonly displayName = 'Providers & Models';
|
|
45
|
+
readonly defaultIncluded = true;
|
|
46
|
+
readonly portability = 'portable' as const;
|
|
47
|
+
private readonly namespaces: string[] | NamespaceProvider;
|
|
48
|
+
|
|
49
|
+
constructor(namespaces: string[] | NamespaceProvider = DEFAULT_PROVIDER_NAMESPACES as unknown as string[]) {
|
|
50
|
+
this.namespaces = namespaces;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async export(ctx: HostContext, _options: ExportOptions): Promise<ExportSection<ProviderExportSection>> {
|
|
54
|
+
const providers: Record<string, ProviderExportEntry> = {};
|
|
55
|
+
const warnings: string[] = [];
|
|
56
|
+
for (const ns of await resolveNamespaces(this.namespaces, ctx)) {
|
|
57
|
+
let info: NamespaceInfo;
|
|
58
|
+
try {
|
|
59
|
+
info = await ctx.settings.describe(ns, { redactSecrets: true });
|
|
60
|
+
} catch (err) {
|
|
61
|
+
warnings.push(msgOf(ctx)('adapter.providerNsReadFailed', { ns, reason: err instanceof Error ? err.message : String(err) }));
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const value = (info.value ?? {}) as Record<string, unknown>;
|
|
65
|
+
// llm-pi-ai 形态:value.providers = { route: {...} };llm-deepseek 形态:value 即单 provider 配置
|
|
66
|
+
const routes: [string, unknown][] =
|
|
67
|
+
value.providers !== null && typeof value.providers === 'object'
|
|
68
|
+
? Object.entries(value.providers as Record<string, unknown>)
|
|
69
|
+
: [[ns, value]];
|
|
70
|
+
for (const [route, pv] of routes) {
|
|
71
|
+
// 每个 route 独立深拷贝 secrets / pv / raw:同一 namespace 的多 route 若共享引用,
|
|
72
|
+
// m4 强化版 createSecretScanner 的 visited 循环检测会误判「检测到循环引用」(见 providers.test.ts)。
|
|
73
|
+
providers[route] = {
|
|
74
|
+
route,
|
|
75
|
+
namespace: ns,
|
|
76
|
+
revision: info.revision,
|
|
77
|
+
secrets: structuredClone(info.secrets),
|
|
78
|
+
...structuredClone(pv as Record<string, unknown>),
|
|
79
|
+
raw: structuredClone(value),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
sectionId: 'providers',
|
|
85
|
+
data: { version: 1, providers },
|
|
86
|
+
counts: { providers: Object.keys(providers).length },
|
|
87
|
+
warnings,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async analyzeImport(data: ProviderExportSection, ctx: ImportContext): Promise<PlanItem[]> {
|
|
92
|
+
const msg = ctx.msg;
|
|
93
|
+
const items: PlanItem[] = [];
|
|
94
|
+
for (const [route, entry] of Object.entries(data.providers)) {
|
|
95
|
+
const id = `provider:${route}`;
|
|
96
|
+
let current: NamespaceInfo | null = null;
|
|
97
|
+
let targetUnavailable = false;
|
|
98
|
+
try {
|
|
99
|
+
current = await ctx.target.settings.describe(entry.namespace, { redactSecrets: true });
|
|
100
|
+
} catch {
|
|
101
|
+
// 目标未注册该 namespace(缺少提供它的插件)→ 写入必失败,按依赖缺失处理(§15)
|
|
102
|
+
targetUnavailable = true;
|
|
103
|
+
}
|
|
104
|
+
if (targetUnavailable) {
|
|
105
|
+
items.push({
|
|
106
|
+
id, kind: 'MissingDependency', adapter: 'providers',
|
|
107
|
+
description: msg('adapter.providerUnregistered', { route, ns: entry.namespace }),
|
|
108
|
+
severity: 'warning', target: { adapter: 'providers', ref: entry.namespace },
|
|
109
|
+
});
|
|
110
|
+
} else if (current === null || isEmptyValue(current.value)) {
|
|
111
|
+
// 目标已注册但为空 → 初始化(Create)
|
|
112
|
+
items.push({
|
|
113
|
+
id, kind: 'Create', adapter: 'providers',
|
|
114
|
+
description: msg('adapter.providerCreate', { route, ns: entry.namespace }), severity: 'info',
|
|
115
|
+
target: { adapter: 'providers', ref: entry.namespace },
|
|
116
|
+
});
|
|
117
|
+
} else if (isDeepStrictEqual(current.value, entry.raw ?? stripEntry(entry))) {
|
|
118
|
+
items.push({ id, kind: 'Skip', adapter: 'providers', description: msg('adapter.providerSame', { route }), severity: 'info' });
|
|
119
|
+
} else {
|
|
120
|
+
items.push({
|
|
121
|
+
id, kind: 'Conflict', adapter: 'providers',
|
|
122
|
+
description: msg('adapter.providerDiff', { route }),
|
|
123
|
+
detail: `namespace=${entry.namespace} current=${JSON.stringify(current.value)} imported=${JSON.stringify(entry.raw ?? stripEntry(entry))}`.slice(0, 200),
|
|
124
|
+
severity: 'warning', target: { adapter: 'providers', ref: entry.namespace },
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return items;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async applyItem(item: PlanItem, ctx: ImportContext): Promise<ApplyResult> {
|
|
132
|
+
const route = item.id.replace(/^provider:/, '');
|
|
133
|
+
const data = ctx.sections.get('providers') as ProviderExportSection | undefined;
|
|
134
|
+
const entry = data?.providers[route];
|
|
135
|
+
if (!entry) return { ok: false, message: ctx.msg('adapter.providerMissing', { route }) };
|
|
136
|
+
const value = entry.raw ?? stripEntry(entry);
|
|
137
|
+
let expected: number | undefined;
|
|
138
|
+
try {
|
|
139
|
+
expected = (await ctx.target.settings.describe(entry.namespace, { redactSecrets: true })).revision;
|
|
140
|
+
} catch {
|
|
141
|
+
expected = undefined; // 目标 namespace 不存在(Create)
|
|
142
|
+
}
|
|
143
|
+
await ctx.target.settings.replace(entry.namespace, value, expected);
|
|
144
|
+
return { ok: true };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async validate(data: ProviderExportSection, msg: MsgFunc = zhMsg): Promise<ValidationResult> {
|
|
148
|
+
const issues: ValidationResult['issues'] = [];
|
|
149
|
+
if (data === null || typeof data !== 'object') {
|
|
150
|
+
return { valid: false, issues: [{ path: '$', message: msg('adapter.validate.object', { subject: 'providers' }), severity: 'error' }] };
|
|
151
|
+
}
|
|
152
|
+
if (data.version !== 1) {
|
|
153
|
+
issues.push({ path: 'version', message: msg('adapter.validate.version', { value: String(data.version) }), severity: 'error' });
|
|
154
|
+
}
|
|
155
|
+
if (data.providers === null || typeof data.providers !== 'object') {
|
|
156
|
+
issues.push({ path: 'providers', message: msg('adapter.validate.missingObject', { subject: 'providers' }), severity: 'error' });
|
|
157
|
+
} else {
|
|
158
|
+
for (const [route, entry] of Object.entries(data.providers)) {
|
|
159
|
+
if (entry === null || typeof entry !== 'object') {
|
|
160
|
+
issues.push({ path: `providers.${route}`, message: msg('adapter.validate.recordObject', { subject: 'provider' }), severity: 'error' });
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (typeof entry.namespace !== 'string' || entry.namespace === '') {
|
|
164
|
+
issues.push({ path: `providers.${route}.namespace`, message: msg('adapter.validate.sourceNamespace'), severity: 'error' });
|
|
165
|
+
}
|
|
166
|
+
if (typeof entry.revision !== 'number') {
|
|
167
|
+
issues.push({ path: `providers.${route}.revision`, message: msg('adapter.validate.number', { subject: 'revision' }), severity: 'error' });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return { valid: issues.filter((i) => i.severity === 'error').length === 0, issues };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 适配器集成往返测试:真实 adapter 列表(createAdapters)+ 核心引擎
|
|
3
|
+
* Exporter → ZIP → Importer(win32 → linux),验证:
|
|
4
|
+
* - 全分区导出、Secret 值不进入 ZIP、路径映射、凭据补录报告、
|
|
5
|
+
* - MCP/插件写 patch 的 needsRestart、重复导入幂等。
|
|
6
|
+
*/
|
|
7
|
+
import test from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import fs from 'node:fs/promises';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { Exporter } from '../core/exporter.ts';
|
|
14
|
+
import { Importer } from '../core/importer.ts';
|
|
15
|
+
import { parseZip } from '../utils/zip.ts';
|
|
16
|
+
import { normalizePath } from '../utils/paths.ts';
|
|
17
|
+
import { createAdapters } from './index.ts';
|
|
18
|
+
import { makeContext, MemSnapshotStore, type MockHostContext } from './test-helpers.ts';
|
|
19
|
+
|
|
20
|
+
const NS = ['general', 'theme', 'llm-deepseek', 'llm-pi-ai'];
|
|
21
|
+
|
|
22
|
+
async function seedSource(ctx: MockHostContext): Promise<void> {
|
|
23
|
+
ctx.settings.ns.set('general', { value: { theme: 'dark', language: 'zh-CN' }, revision: 3, secrets: [] });
|
|
24
|
+
ctx.settings.ns.set('theme', { value: { mode: 'dark' }, revision: 1, secrets: [] });
|
|
25
|
+
ctx.settings.ns.set('llm-deepseek', {
|
|
26
|
+
value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://api.deepseek.com', model: 'deepseek-chat', apiKey: 'sk-super-secret-value-123' },
|
|
27
|
+
revision: 5,
|
|
28
|
+
secrets: [{ path: ['apiKey'], set: true }],
|
|
29
|
+
});
|
|
30
|
+
ctx.settings.ns.set('llm-pi-ai', {
|
|
31
|
+
value: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://api.openai.com', models: ['gpt-4o'] } } },
|
|
32
|
+
revision: 2,
|
|
33
|
+
secrets: [],
|
|
34
|
+
});
|
|
35
|
+
ctx.credentials.values.set('DEEPSEEK_API_KEY', 'sk-super-secret-value-123');
|
|
36
|
+
ctx.credentials.values.set('OPENAI_API_KEY', 'sk-openai-456');
|
|
37
|
+
|
|
38
|
+
await ctx.fs.writeFile('skills/coding.md', Buffer.from('# Coding skill\nUse deepseek.\n', 'utf8'));
|
|
39
|
+
await ctx.fs.writeFile('.agent-presets/work/agent.cordis.yml', Buffer.from('services:\n - name: work\n', 'utf8'));
|
|
40
|
+
await ctx.fs.writeFile('dsh-ssh.json', Buffer.from('{"hosts":[]}', 'utf8'));
|
|
41
|
+
|
|
42
|
+
ctx.workspace.records.set('ws-ops', {
|
|
43
|
+
id: 'ws-ops', path: 'C:\\Users\\alice\\projects\\ops', title: 'OpsFlow', sessionIds: [],
|
|
44
|
+
});
|
|
45
|
+
ctx.patchFile.lines.set('mcp-fs', {
|
|
46
|
+
lineId: 'mcp-fs',
|
|
47
|
+
raw: { id: 'mcp-fs', name: 'dsh-mcp-client', config: { serverName: 'filesystem', command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem'], cwd: 'C:\\Users\\alice\\projects' } },
|
|
48
|
+
});
|
|
49
|
+
ctx.patchFile.lines.set('persona-line', {
|
|
50
|
+
lineId: 'persona-line',
|
|
51
|
+
raw: { id: 'persona-line', name: '@deepseek-ai/dsh-web', config: { systemPrompt: { persona: 'You are a helpful assistant.' } } },
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
test('集成往返:win32 → linux(路径映射 + Secret 不泄 + 补录报告 + needsRestart + 幂等)', async () => {
|
|
56
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-cm-adapters-'));
|
|
57
|
+
try {
|
|
58
|
+
const src = makeContext('win32', 'C:\\Users\\alice');
|
|
59
|
+
await seedSource(src);
|
|
60
|
+
const adapters = createAdapters({ namespaces: NS });
|
|
61
|
+
const zipPath = path.join(tmp, 'dsh-config-roundtrip.zip');
|
|
62
|
+
const exporter = new Exporter({
|
|
63
|
+
ctx: src, adapters, exporterVersion: '0.1.0',
|
|
64
|
+
now: () => new Date('2026-08-14T12:00:00.000Z'),
|
|
65
|
+
});
|
|
66
|
+
const result = await exporter.export({ includeSecrets: false, outPath: zipPath });
|
|
67
|
+
assert.equal(result.manifest.schemaVersion, 1);
|
|
68
|
+
assert.equal(result.manifest.security.containsSecrets, false);
|
|
69
|
+
assert.equal(result.report.security.redactedHits >= 1, true, 'apiKey 字段应被 scanner 剥离计数');
|
|
70
|
+
|
|
71
|
+
// ZIP 内容检查:secret 值绝不出现;插件二进制未打包
|
|
72
|
+
const archive = parseZip(await fs.readFile(zipPath));
|
|
73
|
+
const allText = archive.names()
|
|
74
|
+
.filter((n) => n !== 'integrity/checksums.json')
|
|
75
|
+
.map((n) => Buffer.from(archive.readEntry(n)).toString('utf8'))
|
|
76
|
+
.join('\n');
|
|
77
|
+
assert.ok(!allText.includes('sk-super-secret-value-123'), 'secret 值不得写入导出');
|
|
78
|
+
assert.ok(archive.has('config/settings.json'));
|
|
79
|
+
assert.ok(archive.has('config/ui.json'));
|
|
80
|
+
assert.ok(archive.has('ai/providers.json'));
|
|
81
|
+
assert.ok(archive.has('plugins/plugins.json'));
|
|
82
|
+
assert.ok(archive.has('mcp/servers.json'));
|
|
83
|
+
assert.ok(archive.has('custom/prompts.json'));
|
|
84
|
+
assert.ok(archive.has('custom/skills/coding.md'));
|
|
85
|
+
assert.ok(archive.has('agents/presets/work/agent.cordis.yml'));
|
|
86
|
+
assert.ok(archive.has('workspaces/workspaces.json'));
|
|
87
|
+
assert.ok(archive.has('security/credentials.json'));
|
|
88
|
+
|
|
89
|
+
// —— 目标机器(linux)导入 ——
|
|
90
|
+
const dst = makeContext('linux', '/home/bob');
|
|
91
|
+
// 目标已装相同插件(命名空间已注册但为空)→ 导入走 Create 初始化;未注册的命名空间会按 MissingDependency 跳过
|
|
92
|
+
for (const n of NS) dst.settings.registered.add(n);
|
|
93
|
+
const importer = new Importer({ ctx: dst, adapters, snapshotStore: new MemSnapshotStore() });
|
|
94
|
+
|
|
95
|
+
const analysis = await importer.analyzeImport(zipPath);
|
|
96
|
+
assert.equal(analysis.valid, true);
|
|
97
|
+
assert.equal(analysis.secretCount, 2, '两个已配置凭据(DEEPSEEK/OPENAI)需补录');
|
|
98
|
+
assert.equal(analysis.compatibility, 'partial'); // 跨平台
|
|
99
|
+
assert.ok(analysis.pathIssues.some((p) => p.kind === 'platformMismatch' && p.value.includes('C:\\Users\\alice')), '跨平台路径检测');
|
|
100
|
+
|
|
101
|
+
const plan = await importer.createImportPlan(zipPath, {
|
|
102
|
+
strategy: 'merge',
|
|
103
|
+
resolutions: {},
|
|
104
|
+
pathMappings: [{ oldPrefix: 'C:\\Users\\alice', newPrefix: '/home/bob', appliesTo: ['workspaces', 'mcp', 'pluginConfig', 'skills'] }],
|
|
105
|
+
});
|
|
106
|
+
assert.ok(plan.pathMappings.length >= 1);
|
|
107
|
+
assert.equal(plan.needsRestart, true, 'MCP/patch 行写入需要重启');
|
|
108
|
+
|
|
109
|
+
// Dry Run 零写入
|
|
110
|
+
assert.equal(dst.settings.ns.size, 0);
|
|
111
|
+
assert.equal(dst.patchFile.lines.size, 0);
|
|
112
|
+
|
|
113
|
+
const r = await importer.executeImportPlan(zipPath, plan, {
|
|
114
|
+
confirm: true,
|
|
115
|
+
secretInputs: { DEEPSEEK_API_KEY: 'sk-reentered' },
|
|
116
|
+
});
|
|
117
|
+
assert.equal(r.ok, true);
|
|
118
|
+
|
|
119
|
+
// settings/ui/providers 写回
|
|
120
|
+
assert.deepEqual(dst.settings.ns.get('general')?.value, { theme: 'dark', language: 'zh-CN' });
|
|
121
|
+
assert.deepEqual(dst.settings.ns.get('theme')?.value, { mode: 'dark' });
|
|
122
|
+
const llm = dst.settings.ns.get('llm-deepseek')?.value as Record<string, unknown>;
|
|
123
|
+
assert.equal(llm.apiKeyEnv, 'DEEPSEEK_API_KEY');
|
|
124
|
+
assert.equal(llm.apiKey, '', 'secret 字段剥离为空串');
|
|
125
|
+
|
|
126
|
+
// 文件类写回
|
|
127
|
+
assert.equal(Buffer.from(await dst.fs.readFile('skills/coding.md')).toString(), '# Coding skill\nUse deepseek.\n');
|
|
128
|
+
assert.equal(Buffer.from(await dst.fs.readFile('.agent-presets/work/agent.cordis.yml')).toString(), 'services:\n - name: work\n');
|
|
129
|
+
|
|
130
|
+
// workspaces 路径映射
|
|
131
|
+
const ws = dst.workspace.records.get('ws-ops');
|
|
132
|
+
assert.ok(ws);
|
|
133
|
+
assert.equal(normalizePath(ws.path), '/home/bob/projects/ops', '绝对路径被映射');
|
|
134
|
+
|
|
135
|
+
// MCP / prompt patch 行写入(needsRestart 语义)
|
|
136
|
+
assert.ok(dst.patchFile.lines.has('mcp-fs'), 'MCP patch 行写入');
|
|
137
|
+
const mcpLine = dst.patchFile.lines.get('mcp-fs')?.raw as Record<string, unknown>;
|
|
138
|
+
assert.equal((mcpLine['config'] as Record<string, unknown>)['cwd'], '/home/bob/projects', 'MCP cwd 被映射');
|
|
139
|
+
const persona = dst.patchFile.lines.get('persona-line')?.raw as Record<string, unknown>;
|
|
140
|
+
assert.equal(((persona['config'] as Record<string, unknown>)['systemPrompt'] as Record<string, unknown>)['persona'], 'You are a helpful assistant.');
|
|
141
|
+
|
|
142
|
+
// 凭据:补录写入 + 未补录如实报告
|
|
143
|
+
assert.equal(dst.credentials.values.get('DEEPSEEK_API_KEY'), 'sk-reentered');
|
|
144
|
+
assert.ok(r.missingSecrets.includes('OPENAI_API_KEY'), '未补录的凭据进入报告');
|
|
145
|
+
assert.ok(!dst.credentials.values.has('OPENAI_API_KEY'), '未提供值不得写入');
|
|
146
|
+
|
|
147
|
+
// 幂等:再次导入 → 无新写入项(除 MissingSecret 占位)
|
|
148
|
+
const plan2 = await importer.createImportPlan(zipPath, {
|
|
149
|
+
strategy: 'merge',
|
|
150
|
+
resolutions: {},
|
|
151
|
+
pathMappings: [{ oldPrefix: 'C:\\Users\\alice', newPrefix: '/home/bob', appliesTo: ['workspaces', 'mcp', 'pluginConfig', 'skills'] }],
|
|
152
|
+
});
|
|
153
|
+
assert.ok(
|
|
154
|
+
!plan2.items.some((i) => i.kind === 'Create' || i.kind === 'Update' || i.kind === 'Install'),
|
|
155
|
+
'重复导入不产生新写入项',
|
|
156
|
+
);
|
|
157
|
+
const r2 = await importer.executeImportPlan(zipPath, plan2, { confirm: true, secretInputs: {} });
|
|
158
|
+
assert.ok(r2.executed.every((e) => e.status !== 'failed'), '第二次导入无失败项');
|
|
159
|
+
assert.equal(dst.patchFile.lines.size, 2, 'patch 行未被重复插入');
|
|
160
|
+
} finally {
|
|
161
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('集成:pluginFiles 默认不导出 / sessions 默认不导出(manifest 标记 false)', async () => {
|
|
166
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-cm-adapters-opt-'));
|
|
167
|
+
try {
|
|
168
|
+
const src = makeContext('win32', 'C:\\Users\\alice');
|
|
169
|
+
await src.fs.writeFile('dsh-ssh.json', Buffer.from('{"hosts":[]}', 'utf8'));
|
|
170
|
+
await src.fs.writeFile('sessions/p/s/s.jsonl.zstd', Buffer.from('x', 'utf8'));
|
|
171
|
+
const adapters = createAdapters({ namespaces: NS, includeSessions: true });
|
|
172
|
+
const zipPath = path.join(tmp, 'opt.zip');
|
|
173
|
+
const exporter = new Exporter({ ctx: src, adapters, exporterVersion: '0.1.0' });
|
|
174
|
+
const result = await exporter.export({ includeSecrets: false, outPath: zipPath });
|
|
175
|
+
assert.equal(result.manifest.sections.pluginFiles, false, 'pluginFiles 默认不导出');
|
|
176
|
+
assert.equal(result.manifest.sections.sessions, false, 'sessions 默认不导出');
|
|
177
|
+
const archive = parseZip(await fs.readFile(zipPath));
|
|
178
|
+
assert.ok(!archive.has('plugin-files/dsh-ssh.json'));
|
|
179
|
+
assert.ok(!archive.has('sessions/p/s/s.jsonl.zstd'));
|
|
180
|
+
|
|
181
|
+
// 显式勾选后导出
|
|
182
|
+
const zipPath2 = path.join(tmp, 'opt2.zip');
|
|
183
|
+
await exporter.export({ includeSecrets: false, only: ['pluginFiles', 'sessions'], outPath: zipPath2 });
|
|
184
|
+
const archive2 = parseZip(await fs.readFile(zipPath2));
|
|
185
|
+
assert.ok(archive2.has('plugin-files/dsh-ssh.json'));
|
|
186
|
+
assert.ok(archive2.has('sessions/p/s/s.jsonl.zstd'));
|
|
187
|
+
} finally {
|
|
188
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
189
|
+
}
|
|
190
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* self 分区 adapter 测试(插件自身配置备份):
|
|
3
|
+
* 白名单收集(存在才收、白名单外不收、不递归)、Create/Skip/Conflict 分析、
|
|
4
|
+
* applyItem 写回 $DSH_HOME/dsh-config-manager/<rel>、默认包含 + portable 语义、
|
|
5
|
+
* createAdapters 的 selfDir 挂载行为(缺省挂载 / '' 不挂)。
|
|
6
|
+
*/
|
|
7
|
+
import test from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { createAdapters } from './index.ts';
|
|
10
|
+
import { SelfAdapter, SELF_CONFIG_FILES } from './self.ts';
|
|
11
|
+
import { makeContext, makeImportContext } from './test-helpers.ts';
|
|
12
|
+
|
|
13
|
+
test('self: 白名单收集(存在才收,子目录路径保留,白名单外不收集)', async () => {
|
|
14
|
+
const src = makeContext('win32', 'C:\\Users\\alice');
|
|
15
|
+
await src.fs.writeFile('dsh-config-manager/sync/sync-config.json', Buffer.from('{"transport":"git","git":{"repoUrl":"https://x"},"webdav":{}}', 'utf8'));
|
|
16
|
+
await src.fs.writeFile('dsh-config-manager/sync/sync-selection.json', Buffer.from('{"schemaVersion":1,"mode":"default"}', 'utf8'));
|
|
17
|
+
await src.fs.writeFile('dsh-config-manager/sync/ui-prefs.json', Buffer.from('{"schemaVersion":1,"lastSyncChannel":"webdav"}', 'utf8'));
|
|
18
|
+
// 白名单外:历史/缓存/快照/临时产物不得收集
|
|
19
|
+
await src.fs.writeFile('dsh-config-manager/sync/sync-history.json', Buffer.from('{"schemaVersion":1}', 'utf8'));
|
|
20
|
+
await src.fs.writeFile('dsh-config-manager/market/cache/index.json', Buffer.from('{}', 'utf8'));
|
|
21
|
+
await src.fs.writeFile('dsh-config-manager/snapshots/x/snapshot.json', Buffer.from('{}', 'utf8'));
|
|
22
|
+
await src.fs.writeFile('dsh-config-manager/tmp/tmp.zip', Buffer.from('PK', 'utf8'));
|
|
23
|
+
|
|
24
|
+
const adapter = new SelfAdapter();
|
|
25
|
+
const out = await adapter.export(src, { includeSecrets: false });
|
|
26
|
+
const rels = out.data.files.map((f) => f.relativePath).sort();
|
|
27
|
+
assert.deepEqual(rels, [
|
|
28
|
+
'sync/sync-config.json',
|
|
29
|
+
'sync/sync-selection.json',
|
|
30
|
+
'sync/ui-prefs.json',
|
|
31
|
+
]);
|
|
32
|
+
assert.equal(out.counts.files, 3);
|
|
33
|
+
assert.equal(out.warnings.length, 0, '存在文件时不告警');
|
|
34
|
+
// 白名单常量齐全(sync-autosync 未创建时自然跳过)
|
|
35
|
+
assert.ok(SELF_CONFIG_FILES.includes('sync/sync-autosync.json'));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('self: 默认包含 + portable(Quick Export 推荐项)', () => {
|
|
39
|
+
const adapter = new SelfAdapter();
|
|
40
|
+
assert.equal(adapter.defaultIncluded, true, '插件自身配置默认导出');
|
|
41
|
+
assert.equal(adapter.portability, 'portable');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('self: 全部缺失 → 空分区 + dirEmpty 警告', async () => {
|
|
45
|
+
const src = makeContext('win32', 'C:\\Users\\alice');
|
|
46
|
+
const adapter = new SelfAdapter();
|
|
47
|
+
const out = await adapter.export(src, { includeSecrets: false });
|
|
48
|
+
assert.equal(out.data.files.length, 0);
|
|
49
|
+
assert.ok(out.warnings.length > 0, '缺失时给出提示');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('self: 导入往返(Create → 写回 $DSH_HOME/dsh-config-manager/<rel>;幂等 Skip;不同 Conflict)', async () => {
|
|
53
|
+
const src = makeContext('win32', 'C:\\Users\\alice');
|
|
54
|
+
await src.fs.writeFile('dsh-config-manager/sync/sync-config.json', Buffer.from('{"transport":"git"}', 'utf8'));
|
|
55
|
+
const adapter = new SelfAdapter();
|
|
56
|
+
const out = await adapter.export(src, { includeSecrets: false });
|
|
57
|
+
|
|
58
|
+
const sections = new Map([['self', out.data]]);
|
|
59
|
+
const dst = makeContext('linux', '/home/bob');
|
|
60
|
+
let items = await adapter.analyzeImport(out.data, makeImportContext(dst, sections));
|
|
61
|
+
assert.equal(items.length, 1);
|
|
62
|
+
assert.equal(items[0]?.kind, 'Create');
|
|
63
|
+
const r = await adapter.applyItem(items[0]!, makeImportContext(dst, sections));
|
|
64
|
+
assert.equal(r.ok, true);
|
|
65
|
+
assert.equal(
|
|
66
|
+
Buffer.from(await dst.fs.readFile('dsh-config-manager/sync/sync-config.json')).toString(),
|
|
67
|
+
'{"transport":"git"}',
|
|
68
|
+
'导入写回 $DSH_HOME/dsh-config-manager/sync/sync-config.json(基准目录 + 相对路径)',
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
// 幂等:一致 → Skip
|
|
72
|
+
items = await adapter.analyzeImport(out.data, makeImportContext(dst, sections));
|
|
73
|
+
assert.equal(items[0]?.kind, 'Skip');
|
|
74
|
+
|
|
75
|
+
// 内容不同 → Conflict
|
|
76
|
+
await dst.fs.writeFile('dsh-config-manager/sync/sync-config.json', Buffer.from('{"transport":"webdav"}', 'utf8'));
|
|
77
|
+
items = await adapter.analyzeImport(out.data, makeImportContext(dst, sections));
|
|
78
|
+
assert.equal(items[0]?.kind, 'Conflict');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('createAdapters: selfDir 缺省挂载 dsh-config-manager;空串不挂载', () => {
|
|
82
|
+
const withSelf = createAdapters({ selfDir: undefined });
|
|
83
|
+
assert.ok(withSelf.some((a) => a.id === 'self'), '缺省挂载 self adapter');
|
|
84
|
+
|
|
85
|
+
const withoutSelf = createAdapters({ selfDir: '' });
|
|
86
|
+
assert.ok(!withoutSelf.some((a) => a.id === 'self'), '空串 = 不挂载(自定义 dataDir 在 homeDir 外)');
|
|
87
|
+
|
|
88
|
+
const custom = createAdapters({ selfDir: 'my-config-data' });
|
|
89
|
+
const self = custom.find((a) => a.id === 'self') as SelfAdapter | undefined;
|
|
90
|
+
assert.ok(self !== undefined);
|
|
91
|
+
assert.equal(self.baseDir, 'my-config-data', '自定义相对目录透传');
|
|
92
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* self 分区 adapter(插件自身配置,设计「self 分区」):
|
|
3
|
+
* 数据源 = $DSH_HOME/dsh-config-manager/ 下的插件自身配置文件(白名单收集,非递归):
|
|
4
|
+
* - sync/sync-config.json 同步通道配置(git repoUrl / webdav url+username;凭据值走 credentials 槽位,不含 secret)
|
|
5
|
+
* - sync/sync-autosync.json 自动同步调度配置
|
|
6
|
+
* - sync/sync-selection.json 同步分区选择(默认/高级模式 + 勾选分区)
|
|
7
|
+
* - sync/ui-prefs.json 插件 UI 偏好(如上次选择的同步通道;从 localStorage 迁入)
|
|
8
|
+
*
|
|
9
|
+
* 排除项:sync/sync-history.json(执行记录,属数据非配置)、market/cache/(缓存)、
|
|
10
|
+
* snapshots/(快照)、tmp/ exports/(临时/导出产物)——只备份「配置」,不备份数据。
|
|
11
|
+
*
|
|
12
|
+
* 实现:继承 FileCollectionAdapter 复用 analyzeImport/applyItem/validate(幂等 hash 比对、
|
|
13
|
+
* 快照/回滚路径一致),仅覆写 export() 为白名单收集(self 目录内存在大量非配置子目录,
|
|
14
|
+
* 不能像 skills/sessions 那样整体递归)。
|
|
15
|
+
*
|
|
16
|
+
* relativePath 一律是「相对 baseDir」的路径(如 sync/sync-config.json),与
|
|
17
|
+
* FileCollectionAdapter 的基准目录语义一致:导入时按 path.join(baseDir, rel) 写回
|
|
18
|
+
* $DSH_HOME/dsh-config-manager/<rel>;ZIP 内位于 self/<rel>。
|
|
19
|
+
*
|
|
20
|
+
* 安全不变量:配置文件本身不含凭据值(同步凭据走 DSH credentials 槽位引用),
|
|
21
|
+
* 且文件类分区不进 SecretScanner(与 pluginFiles/skills 同语义)。
|
|
22
|
+
*/
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { msgOf } from '../core/messages.ts';
|
|
25
|
+
import type { ExportOptions, ExportSection, HostContext } from '../core/types.ts';
|
|
26
|
+
import type { FilesSection } from '../schema/types.ts';
|
|
27
|
+
import { sha256Hex } from '../utils/hashing.ts';
|
|
28
|
+
import { FileCollectionAdapter } from './file-collection.ts';
|
|
29
|
+
|
|
30
|
+
/** self 分区白名单文件(相对 baseDir,即 $DSH_HOME/dsh-config-manager/)。 */
|
|
31
|
+
export const SELF_CONFIG_FILES: readonly string[] = [
|
|
32
|
+
'sync/sync-config.json',
|
|
33
|
+
'sync/sync-autosync.json',
|
|
34
|
+
'sync/sync-selection.json',
|
|
35
|
+
'sync/ui-prefs.json',
|
|
36
|
+
// P0-④:导出产物备注清单(exports/.backup-notes.json)——随 self 分区迁移,
|
|
37
|
+
// 换机器后备份列表仍能看到手动导出时填写的备注
|
|
38
|
+
'exports/.backup-notes.json',
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
export class SelfAdapter extends FileCollectionAdapter {
|
|
42
|
+
readonly id = 'self' as const;
|
|
43
|
+
readonly displayName = 'Plugin Self Config';
|
|
44
|
+
readonly defaultIncluded = true;
|
|
45
|
+
readonly portability = 'portable' as const;
|
|
46
|
+
/** 插件自身配置目录(相对 homeDir;宿主按 dataDir 解析注入,缺省 dsh-config-manager) */
|
|
47
|
+
readonly baseDir: string;
|
|
48
|
+
|
|
49
|
+
constructor(baseDir = 'dsh-config-manager') {
|
|
50
|
+
super();
|
|
51
|
+
this.baseDir = baseDir;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 白名单收集:只导出配置类文件(存在才收),不递归(排除快照/历史/缓存/临时产物)。
|
|
55
|
+
* relativePath 产出相对 baseDir 的路径(与 analyzeImport/applyItem 的 path.join(baseDir, ref) 匹配)。 */
|
|
56
|
+
override async export(ctx: HostContext, _options: ExportOptions): Promise<ExportSection<FilesSection>> {
|
|
57
|
+
const files: FilesSection['files'] = [];
|
|
58
|
+
const warnings: string[] = [];
|
|
59
|
+
for (const rel of SELF_CONFIG_FILES) {
|
|
60
|
+
// ctx.fs.readFile 语义 = 相对 homeDir 的完整路径 → 拼接 baseDir;产出仍为相对 baseDir
|
|
61
|
+
const data = await ctx.fs.readFile(path.join(this.baseDir, rel)).catch(() => null);
|
|
62
|
+
if (data === null) continue; // 未创建过的配置文件跳过(如从未配置市场/同步)
|
|
63
|
+
files.push({ relativePath: rel, data, contentHash: sha256Hex(data) });
|
|
64
|
+
}
|
|
65
|
+
if (files.length === 0) {
|
|
66
|
+
warnings.push(msgOf(ctx)('adapter.dirEmpty', { type: this.displayName }));
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
sectionId: this.id,
|
|
70
|
+
data: { version: 1, files },
|
|
71
|
+
counts: { files: files.length },
|
|
72
|
+
warnings,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|