@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,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SyncSessionStore 测试:内存登记临时 ZIP + ImportPlan,TTL 30 分钟,惰性清理,同 id 覆盖。
|
|
3
|
+
*/
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
|
|
8
|
+
import { SyncSessionStore } from './sync-session.ts';
|
|
9
|
+
import type { SyncConfig } from './sync-config.ts';
|
|
10
|
+
import type { ImportAnalysis, ImportPlan } from '../core/types.ts';
|
|
11
|
+
import type { SectionId } from '../schema/types.ts';
|
|
12
|
+
|
|
13
|
+
const GIT_CONFIG: SyncConfig = { schemaVersion: 2, transport: 'git', git: { repoUrl: 'https://github.com/foo/bar.git' } };
|
|
14
|
+
|
|
15
|
+
const DEFAULT_TTL_MS = 30 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
function makePlan(seed: string): ImportPlan {
|
|
18
|
+
return {
|
|
19
|
+
items: [{ id: `item-${seed}`, kind: 'Update', adapter: 'settings', description: `desc-${seed}`, severity: 'info', target: { adapter: 'settings', ref: 'general' } }],
|
|
20
|
+
globalStrategy: 'merge',
|
|
21
|
+
pathMappings: [],
|
|
22
|
+
missingSecrets: [],
|
|
23
|
+
needsRestart: false,
|
|
24
|
+
estimatedActions: { settings: 1 } as unknown as Record<SectionId, number>,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeAnalysis(seed: string): ImportAnalysis {
|
|
29
|
+
return {
|
|
30
|
+
valid: true,
|
|
31
|
+
errors: [],
|
|
32
|
+
warnings: [],
|
|
33
|
+
compatibility: 'excellent',
|
|
34
|
+
sectionsInZip: ['settings'] as SectionId[],
|
|
35
|
+
unsupportedSections: [],
|
|
36
|
+
unsupportedVersions: [],
|
|
37
|
+
pluginSummary: { installed: 0, toInstall: 0 },
|
|
38
|
+
pathIssues: [],
|
|
39
|
+
secretCount: 0,
|
|
40
|
+
dependencyIssues: [],
|
|
41
|
+
encrypted: false,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function makeSession(store: SyncSessionStore, opts: { seed: string; nowMs?: number }): string {
|
|
46
|
+
const now = opts.nowMs ?? Date.now();
|
|
47
|
+
const id = crypto.randomUUID();
|
|
48
|
+
store.set({
|
|
49
|
+
id,
|
|
50
|
+
zipPath: `/tmp/session-${opts.seed}.zip`,
|
|
51
|
+
plan: makePlan(opts.seed),
|
|
52
|
+
analysis: makeAnalysis(opts.seed),
|
|
53
|
+
snapshotId: `snap-${opts.seed}`,
|
|
54
|
+
config: GIT_CONFIG,
|
|
55
|
+
createdAt: now,
|
|
56
|
+
expiresAt: now + DEFAULT_TTL_MS,
|
|
57
|
+
});
|
|
58
|
+
return id;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
test('SyncSessionStore: set/get 往返;同 id 覆盖', () => {
|
|
62
|
+
const store = new SyncSessionStore({ now: () => 5_000_000 });
|
|
63
|
+
const id = makeSession(store, { seed: 'a', nowMs: 5_000_000 });
|
|
64
|
+
const s1 = store.get(id);
|
|
65
|
+
assert.ok(s1, 'get 应返回 session');
|
|
66
|
+
assert.equal(s1!.zipPath, '/tmp/session-a.zip');
|
|
67
|
+
assert.equal(s1!.plan.items[0]!.id, 'item-a');
|
|
68
|
+
assert.equal(s1!.snapshotId, 'snap-a');
|
|
69
|
+
|
|
70
|
+
// 同 id 覆盖(仍在有效期内)
|
|
71
|
+
store.set({
|
|
72
|
+
id,
|
|
73
|
+
zipPath: '/tmp/session-b.zip',
|
|
74
|
+
plan: makePlan('b'),
|
|
75
|
+
analysis: makeAnalysis('b'),
|
|
76
|
+
snapshotId: 'snap-b',
|
|
77
|
+
config: GIT_CONFIG,
|
|
78
|
+
createdAt: 5_000_000,
|
|
79
|
+
expiresAt: 5_000_000 + DEFAULT_TTL_MS,
|
|
80
|
+
});
|
|
81
|
+
const s2 = store.get(id);
|
|
82
|
+
assert.equal(s2!.zipPath, '/tmp/session-b.zip');
|
|
83
|
+
assert.equal(s2!.plan.items[0]!.id, 'item-b');
|
|
84
|
+
assert.equal(s2!.snapshotId, 'snap-b');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('SyncSessionStore: get 过期条目 → undefined(惰性清理)', () => {
|
|
88
|
+
const store = new SyncSessionStore();
|
|
89
|
+
const now = 1_000_000;
|
|
90
|
+
const id = crypto.randomUUID();
|
|
91
|
+
store.set({
|
|
92
|
+
id,
|
|
93
|
+
zipPath: '/tmp/expired.zip',
|
|
94
|
+
plan: makePlan('x'),
|
|
95
|
+
analysis: makeAnalysis('x'),
|
|
96
|
+
snapshotId: 'snap-x',
|
|
97
|
+
config: GIT_CONFIG,
|
|
98
|
+
createdAt: now - DEFAULT_TTL_MS - 1,
|
|
99
|
+
expiresAt: now - 1,
|
|
100
|
+
});
|
|
101
|
+
const s = store.get(id);
|
|
102
|
+
assert.equal(s, undefined, '过期条目应返回 undefined');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('SyncSessionStore: delete 移除条目', () => {
|
|
106
|
+
const store = new SyncSessionStore({ now: () => 5_000_000 });
|
|
107
|
+
const id = makeSession(store, { seed: 'del', nowMs: 5_000_000 });
|
|
108
|
+
assert.ok(store.get(id), 'set 后存在');
|
|
109
|
+
store.delete(id);
|
|
110
|
+
assert.equal(store.get(id), undefined, 'delete 后不存在');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('SyncSessionStore: TTL 边界——过期临界点', () => {
|
|
114
|
+
const store = new SyncSessionStore({ now: () => 2_000_000 });
|
|
115
|
+
const now = 2_000_000;
|
|
116
|
+
const id = crypto.randomUUID();
|
|
117
|
+
// 恰好在 expiresAt(now === expiresAt)→ 视为过期(惰性清理)
|
|
118
|
+
store.set({
|
|
119
|
+
id,
|
|
120
|
+
zipPath: '/tmp/boundary.zip',
|
|
121
|
+
plan: makePlan('boundary'),
|
|
122
|
+
analysis: makeAnalysis('boundary'),
|
|
123
|
+
snapshotId: 'snap-b',
|
|
124
|
+
config: GIT_CONFIG,
|
|
125
|
+
createdAt: now - DEFAULT_TTL_MS,
|
|
126
|
+
expiresAt: now,
|
|
127
|
+
});
|
|
128
|
+
assert.equal(store.get(id), undefined, 'expiresAt === now 视为过期');
|
|
129
|
+
|
|
130
|
+
// 还差 1ms → 仍有效
|
|
131
|
+
const id2 = crypto.randomUUID();
|
|
132
|
+
store.set({
|
|
133
|
+
id: id2,
|
|
134
|
+
zipPath: '/tmp/boundary2.zip',
|
|
135
|
+
plan: makePlan('boundary2'),
|
|
136
|
+
analysis: makeAnalysis('boundary2'),
|
|
137
|
+
snapshotId: 'snap-b2',
|
|
138
|
+
config: GIT_CONFIG,
|
|
139
|
+
createdAt: now - DEFAULT_TTL_MS,
|
|
140
|
+
expiresAt: now + 1,
|
|
141
|
+
});
|
|
142
|
+
assert.ok(store.get(id2), 'expiresAt > now 仍应有效');
|
|
143
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SyncSessionStore:一键同步差异确认会话的内存登记。
|
|
3
|
+
*
|
|
4
|
+
* 会话把「拉取预览」与「逐项执行导入」解耦:一次 /sync/sync 在内存持有一份
|
|
5
|
+
* 临时 ZIP 路径 + ImportPlan + ImportAnalysis,配一个 syncSessionId;
|
|
6
|
+
* /sync/apply-items 凭 id 复用这些数据,避免重复拉取/重复 build ZIP。
|
|
7
|
+
*
|
|
8
|
+
* 生命周期:
|
|
9
|
+
* - 进程内存(不落盘),默认 TTL 30 分钟,过期条目惰性清理(get 时检查);
|
|
10
|
+
* - 同 id 覆盖(set 后旧 session 被新 session 替换,旧 ZIP 由上层负责清理);
|
|
11
|
+
* - delete 显式移除(/sync/cancel 与 apply-items 消费后调用)。
|
|
12
|
+
*/
|
|
13
|
+
import crypto from 'node:crypto';
|
|
14
|
+
import type { ImportAnalysis, ImportPlan } from '../core/types.ts';
|
|
15
|
+
import type { SyncConfig } from './sync-config.ts';
|
|
16
|
+
|
|
17
|
+
/** 默认会话 TTL(30 分钟) */
|
|
18
|
+
export const DEFAULT_SESSION_TTL_MS = 30 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
/** 一次差异确认会话的完整数据 */
|
|
21
|
+
export interface SyncSession {
|
|
22
|
+
id: string;
|
|
23
|
+
/** 临时标准 ZIP(apply-items 执行 executeImportPlan 需要) */
|
|
24
|
+
zipPath: string;
|
|
25
|
+
plan: ImportPlan;
|
|
26
|
+
analysis: ImportAnalysis;
|
|
27
|
+
/** 被拉取的远端快照 id */
|
|
28
|
+
snapshotId: string;
|
|
29
|
+
/** 该次同步使用的通道配置(git/webdav 通用;apply-items 据此重建引擎) */
|
|
30
|
+
config: SyncConfig;
|
|
31
|
+
/** 创建时间(epoch ms) */
|
|
32
|
+
createdAt: number;
|
|
33
|
+
/** 过期时间(epoch ms;过期条目惰性清理) */
|
|
34
|
+
expiresAt: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 内存会话存储;同 id 覆盖;过期条目视为不存在。 */
|
|
38
|
+
export class SyncSessionStore {
|
|
39
|
+
private readonly sessions = new Map<string, SyncSession>();
|
|
40
|
+
private readonly ttlMs: number;
|
|
41
|
+
private readonly now: () => number;
|
|
42
|
+
|
|
43
|
+
constructor(opts: { ttlMs?: number; now?: () => number } = {}) {
|
|
44
|
+
this.ttlMs = opts.ttlMs ?? DEFAULT_SESSION_TTL_MS;
|
|
45
|
+
this.now = opts.now ?? (() => Date.now());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 登记(或覆盖)会话;id 缺省时自动生成。createdAt/expiresAt 缺省自动填充。返回会话 id。 */
|
|
49
|
+
set(session: Omit<SyncSession, 'id' | 'createdAt' | 'expiresAt'> & { id?: string; createdAt?: number; expiresAt?: number }): string {
|
|
50
|
+
const id = session.id ?? `sync-session-${crypto.randomUUID()}`;
|
|
51
|
+
const createdAt = session.createdAt ?? this.now();
|
|
52
|
+
const expiresAt = session.expiresAt ?? createdAt + this.ttlMs;
|
|
53
|
+
this.sessions.set(id, {
|
|
54
|
+
...session,
|
|
55
|
+
id,
|
|
56
|
+
createdAt,
|
|
57
|
+
expiresAt,
|
|
58
|
+
});
|
|
59
|
+
return id;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 读取会话;过期条目返回 undefined(惰性清理)。 */
|
|
63
|
+
get(id: string): SyncSession | undefined {
|
|
64
|
+
const s = this.sessions.get(id);
|
|
65
|
+
if (s === undefined) return undefined;
|
|
66
|
+
if (this.now() >= s.expiresAt) {
|
|
67
|
+
this.sessions.delete(id);
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
return { ...s };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 删除会话(cancel / apply-items 消费后调用)。 */
|
|
74
|
+
delete(id: string): void {
|
|
75
|
+
this.sessions.delete(id);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* m-sync-transport:sync-state.json 模型测试。
|
|
3
|
+
* - hashSection:JSON 分区与文件类分区的内容 hash(确定性、键序无关、文件序无关)
|
|
4
|
+
* - saveSyncState / loadSyncState:往返、缺省、schemaVersion 校验、损坏 JSON 拒绝
|
|
5
|
+
*/
|
|
6
|
+
import test from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import fs from 'node:fs/promises';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { hashSection, loadSyncState, saveSyncState, SYNC_STATE_FILE, SYNC_STATE_SCHEMA_VERSION } from './sync-state.ts';
|
|
13
|
+
import type { SyncState } from './sync-state.ts';
|
|
14
|
+
import type { FileEntry, SectionData } from '../schema/types.ts';
|
|
15
|
+
|
|
16
|
+
test('hashSection: JSON 分区确定性 + 键序无关(同一内容不同插入序 → 相同 hash)', () => {
|
|
17
|
+
const a: SectionData = { version: 1, namespaces: { general: { value: { theme: 'dark' }, revision: 1, secrets: [] } } };
|
|
18
|
+
const b: SectionData = {
|
|
19
|
+
namespaces: { general: { secrets: [], revision: 1, value: { theme: 'dark' } } },
|
|
20
|
+
version: 1,
|
|
21
|
+
} as unknown as SectionData;
|
|
22
|
+
assert.equal(hashSection(a), hashSection(b), '键序无关');
|
|
23
|
+
const c: SectionData = { version: 1, namespaces: { general: { value: { theme: 'light' }, revision: 1, secrets: [] } } };
|
|
24
|
+
assert.notEqual(hashSection(a), hashSection(c), '内容变化 hash 必须变化');
|
|
25
|
+
// 确定性:同对象多次调用
|
|
26
|
+
assert.equal(hashSection(a), hashSection(a));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('hashSection: 文件类分区确定性 + 文件序无关,内容变化 → hash 变化', () => {
|
|
30
|
+
const mk = (files: { relativePath: string; data: Uint8Array; contentHash?: string }[]): SectionData => ({
|
|
31
|
+
version: 1,
|
|
32
|
+
files: files.map((f): FileEntry => ({ relativePath: f.relativePath, data: f.data, contentHash: f.contentHash ?? '' })),
|
|
33
|
+
});
|
|
34
|
+
const f1 = { relativePath: 'a.md', data: new TextEncoder().encode('# A\n') };
|
|
35
|
+
const f2 = { relativePath: 'b/skill.md', data: new TextEncoder().encode('# B\n') };
|
|
36
|
+
const x = hashSection(mk([f1, f2]));
|
|
37
|
+
const y = hashSection(mk([f2, f1]));
|
|
38
|
+
assert.equal(x, y, '文件数组顺序无关');
|
|
39
|
+
const z = hashSection(mk([{ ...f1, data: new TextEncoder().encode('# A changed\n') }, f2]));
|
|
40
|
+
assert.notEqual(x, z, '文件内容变化 hash 必须变化');
|
|
41
|
+
// contentHash 字段不参与(以字节为准)
|
|
42
|
+
const w = hashSection(mk([{ relativePath: f1.relativePath, data: f1.data, contentHash: 'trusted' }, f2]));
|
|
43
|
+
assert.equal(x, w, 'contentHash 字段值不影响分区 hash');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('hashSection: JSON 分区与文件类分区 hash 空间不冲突', () => {
|
|
47
|
+
const jsonHash = hashSection({ version: 1, namespaces: {} } as SectionData);
|
|
48
|
+
const fileHash = hashSection({ version: 1, files: [] } as SectionData);
|
|
49
|
+
assert.notEqual(jsonHash, fileHash);
|
|
50
|
+
assert.equal(jsonHash.length, 64);
|
|
51
|
+
assert.equal(fileHash.length, 64);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('saveSyncState / loadSyncState: 完整往返(含 transport 字段)', async () => {
|
|
55
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-state-'));
|
|
56
|
+
try {
|
|
57
|
+
const state: SyncState = {
|
|
58
|
+
schemaVersion: SYNC_STATE_SCHEMA_VERSION,
|
|
59
|
+
lastSyncAt: '2026-08-16T12:00:00.000Z',
|
|
60
|
+
sections: {
|
|
61
|
+
settings: { hash: 'a'.repeat(64), updatedAt: '2026-08-16T12:00:00.000Z' },
|
|
62
|
+
skills: { hash: 'b'.repeat(64), updatedAt: '2026-08-16T12:00:00.000Z' },
|
|
63
|
+
},
|
|
64
|
+
transport: { type: 'git', ref: 'main' },
|
|
65
|
+
lastSnapshotId: 'sync-abc',
|
|
66
|
+
};
|
|
67
|
+
await saveSyncState(tmp, state);
|
|
68
|
+
const raw = JSON.parse(await fs.readFile(path.join(tmp, SYNC_STATE_FILE), 'utf8'));
|
|
69
|
+
assert.equal(raw.schemaVersion, SYNC_STATE_SCHEMA_VERSION);
|
|
70
|
+
assert.deepEqual(await loadSyncState(tmp), state);
|
|
71
|
+
} finally {
|
|
72
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('loadSyncState: 文件不存在 → 返回缺省空状态(lastSyncAt 为空串,lastSnapshotId 为空串)', async () => {
|
|
77
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-state-missing-'));
|
|
78
|
+
try {
|
|
79
|
+
const state = await loadSyncState(tmp);
|
|
80
|
+
assert.equal(state.schemaVersion, SYNC_STATE_SCHEMA_VERSION);
|
|
81
|
+
assert.equal(state.lastSyncAt, '');
|
|
82
|
+
assert.equal(state.lastSnapshotId, '');
|
|
83
|
+
assert.deepEqual(state.sections, {});
|
|
84
|
+
assert.equal(state.transport, undefined);
|
|
85
|
+
} finally {
|
|
86
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('loadSyncState: 不支持的 schemaVersion → 拒绝', async () => {
|
|
91
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-state-ver-'));
|
|
92
|
+
try {
|
|
93
|
+
await fs.writeFile(path.join(tmp, SYNC_STATE_FILE), JSON.stringify({ schemaVersion: 99, lastSyncAt: '', sections: {} }), 'utf8');
|
|
94
|
+
await assert.rejects(() => loadSyncState(tmp), /schemaVersion/);
|
|
95
|
+
} finally {
|
|
96
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('loadSyncState: 损坏 JSON → 拒绝(不静默降级)', async () => {
|
|
101
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-state-bad-'));
|
|
102
|
+
try {
|
|
103
|
+
await fs.writeFile(path.join(tmp, SYNC_STATE_FILE), '{not-json', 'utf8');
|
|
104
|
+
await assert.rejects(() => loadSyncState(tmp));
|
|
105
|
+
} finally {
|
|
106
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('saveSyncState: 自动创建目录', async () => {
|
|
111
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-state-mkdir-'));
|
|
112
|
+
try {
|
|
113
|
+
const dir = path.join(tmp, 'nested', 'sync');
|
|
114
|
+
await saveSyncState(dir, { schemaVersion: SYNC_STATE_SCHEMA_VERSION, lastSyncAt: '', sections: {}, lastSnapshotId: '' });
|
|
115
|
+
const state = await loadSyncState(dir);
|
|
116
|
+
assert.equal(state.schemaVersion, SYNC_STATE_SCHEMA_VERSION);
|
|
117
|
+
} finally {
|
|
118
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('loadSyncState: v1 文件 → 内存迁移到 v2 形态(lastSnapshotId=""),不动磁盘', async () => {
|
|
123
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-state-v1-'));
|
|
124
|
+
try {
|
|
125
|
+
// 写入一个 schemaVersion=1 的旧文件(缺 lastSnapshotId)
|
|
126
|
+
await fs.writeFile(
|
|
127
|
+
path.join(tmp, SYNC_STATE_FILE),
|
|
128
|
+
JSON.stringify({
|
|
129
|
+
schemaVersion: 1,
|
|
130
|
+
lastSyncAt: '2026-08-15T00:00:00.000Z',
|
|
131
|
+
sections: { settings: { hash: 'c'.repeat(64), updatedAt: '2026-08-15T00:00:00.000Z' } },
|
|
132
|
+
}),
|
|
133
|
+
'utf8',
|
|
134
|
+
);
|
|
135
|
+
const state = await loadSyncState(tmp);
|
|
136
|
+
assert.equal(state.schemaVersion, SYNC_STATE_SCHEMA_VERSION, '内存中应已升级为 v2');
|
|
137
|
+
assert.equal(state.lastSnapshotId, '', 'v1 文件缺祖先指针时迁移为 ""');
|
|
138
|
+
assert.equal(state.lastSyncAt, '2026-08-15T00:00:00.000Z');
|
|
139
|
+
assert.equal(state.sections.settings?.hash, 'c'.repeat(64));
|
|
140
|
+
// 磁盘上仍是 v1(不就地升级)
|
|
141
|
+
const onDisk = JSON.parse(await fs.readFile(path.join(tmp, SYNC_STATE_FILE), 'utf8'));
|
|
142
|
+
assert.equal(onDisk.schemaVersion, 1);
|
|
143
|
+
} finally {
|
|
144
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('saveSyncState / loadSyncState: lastSnapshotId 非空往返', async () => {
|
|
149
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-state-ancestor-'));
|
|
150
|
+
try {
|
|
151
|
+
const state: SyncState = {
|
|
152
|
+
schemaVersion: SYNC_STATE_SCHEMA_VERSION,
|
|
153
|
+
lastSyncAt: '2026-08-16T12:00:00.000Z',
|
|
154
|
+
sections: {},
|
|
155
|
+
lastSnapshotId: 'sync-deadbeef-1234',
|
|
156
|
+
};
|
|
157
|
+
await saveSyncState(tmp, state);
|
|
158
|
+
const loaded = await loadSyncState(tmp);
|
|
159
|
+
assert.equal(loaded.lastSnapshotId, 'sync-deadbeef-1234');
|
|
160
|
+
assert.deepEqual(loaded, state);
|
|
161
|
+
} finally {
|
|
162
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
163
|
+
}
|
|
164
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* m-sync-transport:sync-state.json 模型。
|
|
3
|
+
* 记录上次同步时间与各分区内容 hash,供变更检测(与远端快照对比决定是否重传)。
|
|
4
|
+
* 纯逻辑 + 可注入 fs;不触碰真实 ~/.dsh。
|
|
5
|
+
*/
|
|
6
|
+
import { sha256Hex } from '../utils/hashing.ts';
|
|
7
|
+
import { parseJsonSafe, stringifyJsonSafe } from '../utils/json.ts';
|
|
8
|
+
import { zhMsg } from '../core/messages.ts';
|
|
9
|
+
import type { MsgFunc } from '../core/messages.ts';
|
|
10
|
+
import type { FilesSection, SectionData, SectionId } from '../schema/types.ts';
|
|
11
|
+
import { createSnapshotFs, joinFs } from './fs.ts';
|
|
12
|
+
import type { SnapshotFs } from './fs.ts';
|
|
13
|
+
|
|
14
|
+
export const SYNC_STATE_SCHEMA_VERSION = 2;
|
|
15
|
+
/** 历史可读取版本:v1 缺 lastSnapshotId 时做内存迁移到 v2(lastSnapshotId='')。 */
|
|
16
|
+
export const SYNC_STATE_SUPPORTED_VERSIONS: readonly number[] = [1, 2];
|
|
17
|
+
export const SYNC_STATE_FILE = 'sync-state.json';
|
|
18
|
+
|
|
19
|
+
/** 单个分区的同步状态:内容 hash + 最近变更时间 */
|
|
20
|
+
export interface SyncSectionState {
|
|
21
|
+
hash: string;
|
|
22
|
+
updatedAt: string; // ISO-8601 UTC
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** sync-state.json 结构 */
|
|
26
|
+
export interface SyncState {
|
|
27
|
+
schemaVersion: number;
|
|
28
|
+
lastSyncAt: string; // ISO-8601 UTC;'' = 从未同步
|
|
29
|
+
sections: Partial<Record<SectionId, SyncSectionState>>;
|
|
30
|
+
/** 当前绑定的传输通道(type = SyncTransport.type,ref = 通道内引用,如 git 分支) */
|
|
31
|
+
transport?: { type: string; ref: string };
|
|
32
|
+
/** 最近一次同步的快照 ID(= 共同祖先指针);'' = 从未同步或无祖先 */
|
|
33
|
+
lastSnapshotId: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 文件类分区判定:{ version: 1, files: [...] }(duck-typing,与 JSON 分区区分) */
|
|
37
|
+
function isFilesSection(data: SectionData): data is FilesSection {
|
|
38
|
+
const obj = data as { version?: unknown; files?: unknown };
|
|
39
|
+
return data !== null && typeof data === 'object' && obj.version === 1 && Array.isArray(obj.files);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 键序规范化的 JSON 序列化(跨机器/跨解析稳定,用于内容 hash) */
|
|
43
|
+
function canonicalJson(value: unknown): string {
|
|
44
|
+
if (value === null || typeof value !== 'object') {
|
|
45
|
+
if (value === undefined) return 'null'; // 防御:不产生 undefined 字面量
|
|
46
|
+
return JSON.stringify(value);
|
|
47
|
+
}
|
|
48
|
+
if (Array.isArray(value)) {
|
|
49
|
+
return `[${value.map((v) => canonicalJson(v)).join(',')}]`;
|
|
50
|
+
}
|
|
51
|
+
const obj = value as Record<string, unknown>;
|
|
52
|
+
const parts: string[] = [];
|
|
53
|
+
for (const key of Object.keys(obj).sort()) {
|
|
54
|
+
const v = obj[key];
|
|
55
|
+
if (v === undefined) continue; // 与 JSON.stringify 一致:跳过 undefined 键
|
|
56
|
+
parts.push(`${JSON.stringify(key)}:${canonicalJson(v)}`);
|
|
57
|
+
}
|
|
58
|
+
return `{${parts.join(',')}}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 分区内容 hash:
|
|
63
|
+
* - JSON 分区:键序规范化的 JSON 序列化 → SHA-256(同一内容不同键序 → 相同 hash)
|
|
64
|
+
* - 文件类分区:文件相对路径 + 文件字节 SHA-256 的有序清单 → SHA-256
|
|
65
|
+
* (文件数组顺序无关;contentHash 字段不参与,以字节为准)
|
|
66
|
+
*/
|
|
67
|
+
export function hashSection(data: SectionData): string {
|
|
68
|
+
if (isFilesSection(data)) {
|
|
69
|
+
const files = [...data.files]
|
|
70
|
+
.sort((a, b) => (a.relativePath < b.relativePath ? -1 : a.relativePath > b.relativePath ? 1 : 0))
|
|
71
|
+
.map((f) => `${f.relativePath}\u0000${sha256Hex(f.data)}`)
|
|
72
|
+
.join('\u0001');
|
|
73
|
+
return sha256Hex(`files:v1\u0000${files}`);
|
|
74
|
+
}
|
|
75
|
+
return sha256Hex(canonicalJson(data));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 读取同步状态;文件不存在 → 返回缺省空状态(从未同步)。
|
|
79
|
+
* v1 文件 → 内存迁移到 v2(lastSnapshotId=''),不在磁盘上就地升级。 */
|
|
80
|
+
export async function loadSyncState(dir: string, fsx: SnapshotFs = createSnapshotFs(), msg: MsgFunc = zhMsg): Promise<SyncState> {
|
|
81
|
+
const file = joinFs(dir, SYNC_STATE_FILE);
|
|
82
|
+
if (!(await fsx.exists(file))) {
|
|
83
|
+
return { schemaVersion: SYNC_STATE_SCHEMA_VERSION, lastSyncAt: '', sections: {}, lastSnapshotId: '' };
|
|
84
|
+
}
|
|
85
|
+
const raw = Buffer.from(await fsx.readFile(file)).toString('utf8');
|
|
86
|
+
const parsed = parseJsonSafe(raw);
|
|
87
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
88
|
+
throw new Error(msg('sync.state.notObject'));
|
|
89
|
+
}
|
|
90
|
+
const obj = parsed as Record<string, unknown>;
|
|
91
|
+
if (typeof obj['schemaVersion'] !== 'number' || !SYNC_STATE_SUPPORTED_VERSIONS.includes(obj['schemaVersion'])) {
|
|
92
|
+
throw new Error(msg('sync.state.schemaUnsupported', { version: String(obj['schemaVersion']), expected: String(SYNC_STATE_SUPPORTED_VERSIONS.join('/')) }));
|
|
93
|
+
}
|
|
94
|
+
if (typeof obj['lastSyncAt'] !== 'string') {
|
|
95
|
+
throw new Error(msg('sync.state.lastSyncAt'));
|
|
96
|
+
}
|
|
97
|
+
if (obj['sections'] === null || typeof obj['sections'] !== 'object' || Array.isArray(obj['sections'])) {
|
|
98
|
+
throw new Error(msg('sync.state.sections'));
|
|
99
|
+
}
|
|
100
|
+
const sections: SyncState['sections'] = {};
|
|
101
|
+
for (const [sid, rec] of Object.entries(obj['sections'] as Record<string, unknown>)) {
|
|
102
|
+
if (rec === null || typeof rec !== 'object' || Array.isArray(rec)) {
|
|
103
|
+
throw new Error(msg('sync.state.sectionRecord', { section: sid }));
|
|
104
|
+
}
|
|
105
|
+
const r = rec as Record<string, unknown>;
|
|
106
|
+
if (typeof r['hash'] !== 'string' || typeof r['updatedAt'] !== 'string') {
|
|
107
|
+
throw new Error(msg('sync.state.sectionFields', { section: sid }));
|
|
108
|
+
}
|
|
109
|
+
sections[sid as SectionId] = { hash: r['hash'], updatedAt: r['updatedAt'] };
|
|
110
|
+
}
|
|
111
|
+
const state: SyncState = {
|
|
112
|
+
schemaVersion: SYNC_STATE_SCHEMA_VERSION,
|
|
113
|
+
lastSyncAt: obj['lastSyncAt'],
|
|
114
|
+
sections,
|
|
115
|
+
lastSnapshotId: typeof obj['lastSnapshotId'] === 'string' ? obj['lastSnapshotId'] : '',
|
|
116
|
+
};
|
|
117
|
+
const t = obj['transport'];
|
|
118
|
+
if (t !== undefined) {
|
|
119
|
+
if (t === null || typeof t !== 'object' || typeof (t as Record<string, unknown>)['type'] !== 'string' || typeof (t as Record<string, unknown>)['ref'] !== 'string') {
|
|
120
|
+
throw new Error(msg('sync.state.transport'));
|
|
121
|
+
}
|
|
122
|
+
state.transport = { type: (t as { type: string }).type, ref: (t as { ref: string }).ref };
|
|
123
|
+
}
|
|
124
|
+
return state;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 保存同步状态(自动创建目录) */
|
|
128
|
+
export async function saveSyncState(dir: string, state: SyncState, fsx: SnapshotFs = createSnapshotFs()): Promise<void> {
|
|
129
|
+
await fsx.mkdir(dir);
|
|
130
|
+
await fsx.writeFile(joinFs(dir, SYNC_STATE_FILE), new TextEncoder().encode(stringifyJsonSafe(state, { space: 2 })));
|
|
131
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* m-sync-transport:SyncTransport 抽象层测试。
|
|
3
|
+
* - computeSnapshotMeta:sections hash 记录 + manifest 摘要透传
|
|
4
|
+
* - manifestSummaryFrom:Manifest → 摘要
|
|
5
|
+
* - 接口形状由类型系统保证(编译期),此处验证纯 helper 行为。
|
|
6
|
+
*/
|
|
7
|
+
import test from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
|
|
10
|
+
import { computeSnapshotMeta, manifestSummaryFrom } from './transport.ts';
|
|
11
|
+
import { hashSection } from './sync-state.ts';
|
|
12
|
+
import type { ManifestSummary, SyncSnapshot, SyncTransport } from './transport.ts';
|
|
13
|
+
import type { Manifest, SectionData, SectionId } from '../schema/types.ts';
|
|
14
|
+
|
|
15
|
+
function sampleSnapshot(overrides: Partial<SyncSnapshot> = {}): SyncSnapshot {
|
|
16
|
+
return {
|
|
17
|
+
id: 'snap-001',
|
|
18
|
+
createdAt: '2026-08-16T12:00:00.000Z',
|
|
19
|
+
manifest: {
|
|
20
|
+
schemaVersion: 1,
|
|
21
|
+
dshVersion: '1.2.3',
|
|
22
|
+
platform: 'win32',
|
|
23
|
+
sectionIds: ['settings', 'providers'],
|
|
24
|
+
containsSecrets: false,
|
|
25
|
+
},
|
|
26
|
+
sections: {
|
|
27
|
+
settings: { version: 1, namespaces: { general: { value: { theme: 'dark' }, revision: 1, secrets: [] } } },
|
|
28
|
+
providers: { version: 1, providers: { deepseek: { route: '/v1' } } },
|
|
29
|
+
},
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test('computeSnapshotMeta: 透传 id/createdAt/manifest,sections 为各分区内容 hash', () => {
|
|
35
|
+
const snap = sampleSnapshot();
|
|
36
|
+
const meta = computeSnapshotMeta(snap);
|
|
37
|
+
assert.equal(meta.id, 'snap-001');
|
|
38
|
+
assert.equal(meta.createdAt, snap.createdAt);
|
|
39
|
+
assert.deepEqual(meta.manifest, snap.manifest);
|
|
40
|
+
const snapPlain = snap.sections as Partial<Record<string, unknown>>;
|
|
41
|
+
assert.equal(meta.sections['settings'], hashSection(snapPlain['settings'] as SectionData));
|
|
42
|
+
assert.equal(meta.sections['providers'], hashSection(snapPlain['providers'] as SectionData));
|
|
43
|
+
assert.equal(Object.keys(meta.sections).length, 2);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('computeSnapshotMeta: 空 sections → 空 hash 记录', () => {
|
|
47
|
+
const meta = computeSnapshotMeta(sampleSnapshot({ sections: {} }));
|
|
48
|
+
assert.deepEqual(meta.sections, {});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('computeSnapshotMeta: hash 与分区内容一一对应,内容变化 → hash 变化', () => {
|
|
52
|
+
const a = computeSnapshotMeta(sampleSnapshot());
|
|
53
|
+
const b = computeSnapshotMeta(sampleSnapshot({
|
|
54
|
+
sections: {
|
|
55
|
+
settings: { version: 1, namespaces: { general: { value: { theme: 'light' }, revision: 1, secrets: [] } } },
|
|
56
|
+
providers: { version: 1, providers: { deepseek: { route: '/v1' } } },
|
|
57
|
+
},
|
|
58
|
+
}));
|
|
59
|
+
assert.notEqual(a.sections['settings'], b.sections['settings'], 'settings 内容变化 hash 必须变化');
|
|
60
|
+
assert.equal(a.sections['providers'], b.sections['providers'], 'providers 未变化 hash 必须相同');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('manifestSummaryFrom: 从导出 Manifest 提取摘要(只含启用分区)', () => {
|
|
64
|
+
const manifest: Manifest = {
|
|
65
|
+
schemaVersion: 1,
|
|
66
|
+
exporter: { name: 'dsh-config-manager', version: '0.1.0' },
|
|
67
|
+
source: { dshVersion: '2.0.0', platform: 'linux', arch: 'x64' },
|
|
68
|
+
exportedAt: '2026-08-16T12:00:00.000Z',
|
|
69
|
+
sections: {
|
|
70
|
+
settings: true, ui: true, providers: true, plugins: false, mcp: false,
|
|
71
|
+
prompts: false, skills: true, agentPresets: false, agentInstructions: false, workspaces: false,
|
|
72
|
+
pluginFiles: false, credentialsStatus: false, secrets: false, sessions: false, self: false,
|
|
73
|
+
},
|
|
74
|
+
security: { containsSecrets: true, encrypted: false, encryption: null },
|
|
75
|
+
};
|
|
76
|
+
const summary = manifestSummaryFrom(manifest);
|
|
77
|
+
assert.equal(summary.schemaVersion, 1);
|
|
78
|
+
assert.equal(summary.dshVersion, '2.0.0');
|
|
79
|
+
assert.equal(summary.platform, 'linux');
|
|
80
|
+
assert.deepEqual(summary.sectionIds, ['settings', 'ui', 'providers', 'skills']);
|
|
81
|
+
assert.equal(summary.containsSecrets, true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('SyncTransport 契约形状:实现对象必须具备 list/upload/download/delete', async () => {
|
|
85
|
+
const fake: SyncTransport = {
|
|
86
|
+
type: 'memory',
|
|
87
|
+
async list() { return []; },
|
|
88
|
+
async upload(s) { return computeSnapshotMeta(s); },
|
|
89
|
+
async download(id: string) { return sampleSnapshot({ id }); },
|
|
90
|
+
async delete(_id: string) {},
|
|
91
|
+
};
|
|
92
|
+
const snap = sampleSnapshot();
|
|
93
|
+
const meta = computeSnapshotMeta(snap);
|
|
94
|
+
assert.equal(fake.type, 'memory');
|
|
95
|
+
assert.equal((await fake.list()).length, 0);
|
|
96
|
+
assert.equal((await fake.download('x')).id, 'x');
|
|
97
|
+
assert.equal((await fake.upload(snap)).id, meta.id);
|
|
98
|
+
assert.doesNotThrow(() => fake.delete('x'));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('ManifestSummary 类型形状(编译期保证 + 运行期检查)', () => {
|
|
102
|
+
const s: ManifestSummary = {
|
|
103
|
+
schemaVersion: 1,
|
|
104
|
+
dshVersion: '1.0.0',
|
|
105
|
+
platform: 'darwin',
|
|
106
|
+
sectionIds: [] as SectionId[],
|
|
107
|
+
containsSecrets: false,
|
|
108
|
+
};
|
|
109
|
+
assert.equal(s.schemaVersion, 1);
|
|
110
|
+
assert.ok(Array.isArray(s.sectionIds));
|
|
111
|
+
});
|