@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,673 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* m-git-channel:Git 私有仓库通道测试(TDD:先红后绿)。
|
|
3
|
+
* - mock exec 单元测试:命令序列 / token 不泄漏(argv、commit message、错误消息、credential 文件生命周期)
|
|
4
|
+
* - 真实 git 集成测试:本地 bare repo 模拟远端(无真实网络),端到端 list/upload/download/delete
|
|
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
|
+
import { execFile } from 'node:child_process';
|
|
12
|
+
|
|
13
|
+
import { GitTransport, GitTransportError } from './git-transport.ts';
|
|
14
|
+
import type { GitExecFn, GitExecResult, GitTransportOptions } from './git-transport.ts';
|
|
15
|
+
import { encryptSectionsPayload } from '../../../tests/fixtures/legacy-snapshot-crypto.ts';
|
|
16
|
+
import { computeSnapshotMeta, isEncryptedSections } from '../transport.ts';
|
|
17
|
+
import type { SyncSnapshot } from '../transport.ts';
|
|
18
|
+
import type { FilesSection, SectionData, SectionId } from '../../schema/types.ts';
|
|
19
|
+
|
|
20
|
+
/* ---------------- helpers ---------------- */
|
|
21
|
+
|
|
22
|
+
const TEST_TOKEN = 'secret-token-xyz-123';
|
|
23
|
+
|
|
24
|
+
function sampleSnapshot(overrides: Partial<SyncSnapshot> = {}): SyncSnapshot & { sections: Record<string, unknown> } {
|
|
25
|
+
return {
|
|
26
|
+
id: 'snap-001',
|
|
27
|
+
createdAt: '2026-08-16T12:00:00.000Z',
|
|
28
|
+
manifest: {
|
|
29
|
+
schemaVersion: 1,
|
|
30
|
+
dshVersion: '1.2.3',
|
|
31
|
+
platform: 'win32',
|
|
32
|
+
sectionIds: ['settings', 'providers'],
|
|
33
|
+
containsSecrets: false,
|
|
34
|
+
},
|
|
35
|
+
sections: {
|
|
36
|
+
settings: { version: 1, namespaces: { general: { value: { theme: 'dark' }, revision: 1, secrets: [] } } },
|
|
37
|
+
providers: { version: 1, providers: { deepseek: { route: '/v1' } } },
|
|
38
|
+
},
|
|
39
|
+
...overrides,
|
|
40
|
+
} as SyncSnapshot & { sections: Record<string, unknown> };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 记录每次 git 调用的 mock exec:默认全部成功;按需特判 */
|
|
44
|
+
interface CallRecord { cmd: string; args: string[]; }
|
|
45
|
+
function mockExec(
|
|
46
|
+
calls: CallRecord[],
|
|
47
|
+
rules: {
|
|
48
|
+
/** args.join(' ') 包含该子串时返回 code(用于 rev-parse/diff/ls-remote 特判) */
|
|
49
|
+
codeBy?: Record<string, number>;
|
|
50
|
+
/** 模拟失败:返回 code + stderr(可含 token 变体,验证 sanitize) */
|
|
51
|
+
failOn?: { match: string; code: number; stderr: string };
|
|
52
|
+
} = {},
|
|
53
|
+
): GitExecFn {
|
|
54
|
+
return async (_cmd, args): Promise<GitExecResult> => {
|
|
55
|
+
calls.push({ cmd: 'git', args });
|
|
56
|
+
const joined = args.join(' ');
|
|
57
|
+
if (rules.failOn && joined.includes(rules.failOn.match)) {
|
|
58
|
+
return { stdout: '', stderr: rules.failOn.stderr, code: rules.failOn.code };
|
|
59
|
+
}
|
|
60
|
+
for (const [sub, code] of Object.entries(rules.codeBy ?? {})) {
|
|
61
|
+
if (joined.includes(sub)) return { stdout: '', stderr: '', code };
|
|
62
|
+
}
|
|
63
|
+
// 默认:diff --cached --quiet = 有变更(code 1);其余成功
|
|
64
|
+
if (joined.includes('diff') && joined.includes('--quiet')) return { stdout: '', stderr: '', code: 1 };
|
|
65
|
+
return { stdout: '', stderr: '', code: 0 };
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function makeTempDir(t: test.TestContext): Promise<string> {
|
|
70
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-git-test-'));
|
|
71
|
+
t.after(async () => { await fs.rm(dir, { recursive: true, force: true }); });
|
|
72
|
+
return dir;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 真实 git:初始化一个非空 git 工作树(.git 存在,使 ensureRepo 走已 clone 分支) */
|
|
76
|
+
async function makeGitWorkDir(t: test.TestContext): Promise<string> {
|
|
77
|
+
const dir = await makeTempDir(t);
|
|
78
|
+
await runRealGit(['init'], dir);
|
|
79
|
+
await runRealGit(['config', 'user.name', 'test'], dir);
|
|
80
|
+
await runRealGit(['config', 'user.email', 'test@local'], dir);
|
|
81
|
+
return dir;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function runRealGit(args: string[], cwd: string): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
85
|
+
return new Promise((resolve) => {
|
|
86
|
+
execFile('git', args, { cwd, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
87
|
+
if (err) {
|
|
88
|
+
const e = err as { code?: number };
|
|
89
|
+
resolve({ stdout: String(stdout), stderr: String(stderr), code: e.code ?? 1 });
|
|
90
|
+
} else {
|
|
91
|
+
resolve({ stdout: String(stdout), stderr: String(stderr), code: 0 });
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 本地 bare 仓库(模拟远端,无认证需求) */
|
|
98
|
+
async function makeBareRepo(t: test.TestContext): Promise<string> {
|
|
99
|
+
const dir = await makeTempDir(t);
|
|
100
|
+
const bare = path.join(dir, 'remote.git');
|
|
101
|
+
const r = await runRealGit(['init', '--bare', bare], dir);
|
|
102
|
+
assert.equal(r.code, 0, `git init --bare 失败: ${r.stderr}`);
|
|
103
|
+
return bare;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function makeOptions(overrides: Partial<GitTransportOptions> = {}): GitTransportOptions {
|
|
107
|
+
return {
|
|
108
|
+
repoUrl: 'https://github.com/example/private-config.git',
|
|
109
|
+
workDir: 'UNSET',
|
|
110
|
+
credentials: { getToken: async () => TEST_TOKEN },
|
|
111
|
+
...overrides,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function joinedArgs(calls: CallRecord[], needle: string): string[] {
|
|
116
|
+
return calls.filter((c) => c.args.join(' ').includes(needle)).map((c) => c.args.join(' '));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 构造加密快照(sections 为 EncryptedSections 密文载荷;含文件分区以覆盖字节往返)。 */
|
|
120
|
+
async function encryptedSnapshot(overrides: Partial<SyncSnapshot> = {}): Promise<SyncSnapshot> {
|
|
121
|
+
const base = sampleSnapshot();
|
|
122
|
+
const plain: Partial<Record<SectionId, SectionData>> = {};
|
|
123
|
+
for (const [id, data] of Object.entries(base.sections)) {
|
|
124
|
+
plain[id as SectionId] = data as SectionData;
|
|
125
|
+
}
|
|
126
|
+
plain.skills = {
|
|
127
|
+
version: 1,
|
|
128
|
+
files: [
|
|
129
|
+
{ relativePath: 'coding.md', data: new Uint8Array(Buffer.from('# Coding\n', 'utf8')), contentHash: 'h1' },
|
|
130
|
+
],
|
|
131
|
+
} as FilesSection;
|
|
132
|
+
const enc = await encryptSectionsPayload(plain, 'pw-12345678');
|
|
133
|
+
return {
|
|
134
|
+
...base,
|
|
135
|
+
id: 'snap-enc',
|
|
136
|
+
manifest: { ...base.manifest, sectionIds: ['settings', 'providers', 'skills'], encrypted: true, containsSecrets: true },
|
|
137
|
+
sections: enc,
|
|
138
|
+
...overrides,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* ---------------- 单元测试(mock exec) ---------------- */
|
|
143
|
+
|
|
144
|
+
test('非 git 仓库:workDir 非空且无 .git → 报错清晰(含 workDir 路径)', async (t) => {
|
|
145
|
+
const dir = await makeTempDir(t);
|
|
146
|
+
await fs.writeFile(path.join(dir, 'stray.txt'), 'not a repo');
|
|
147
|
+
const calls: CallRecord[] = [];
|
|
148
|
+
const transport = new GitTransport(makeOptions({ workDir: dir, exec: mockExec(calls) }));
|
|
149
|
+
await assert.rejects(
|
|
150
|
+
transport.list(),
|
|
151
|
+
(err: unknown) => {
|
|
152
|
+
assert.ok(err instanceof GitTransportError, '必须是 GitTransportError');
|
|
153
|
+
assert.match(err.message, /不是 git 仓库/);
|
|
154
|
+
assert.ok(err.message.includes(dir), '错误消息应含 workDir 路径');
|
|
155
|
+
return true;
|
|
156
|
+
},
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('首次使用(空目录无 .git)→ 触发 clone(credential helper 注入,argv 不含 token)', async (t) => {
|
|
161
|
+
const dir = await makeTempDir(t); // 空目录
|
|
162
|
+
const calls: CallRecord[] = [];
|
|
163
|
+
const transport = new GitTransport(makeOptions({ workDir: dir, exec: mockExec(calls) }));
|
|
164
|
+
await transport.list();
|
|
165
|
+
const clones = joinedArgs(calls, 'clone');
|
|
166
|
+
assert.equal(clones.length, 1, '应恰好调用一次 clone');
|
|
167
|
+
const cloneArgs = calls.find((c) => c.args.includes('clone'))!.args;
|
|
168
|
+
// credential helper 注入到 clone 参数
|
|
169
|
+
assert.ok(cloneArgs.some((a) => a.startsWith('credential.helper=') && a.includes('store --file=')), 'clone 应带 store credential helper');
|
|
170
|
+
// argv 不含 token
|
|
171
|
+
for (const arg of cloneArgs) assert.ok(!arg.includes(TEST_TOKEN), `argv 泄漏 token: ${arg}`);
|
|
172
|
+
// clone 的 URL 参数不带 token
|
|
173
|
+
const urlArg = cloneArgs[cloneArgs.indexOf('clone') + 1]!;
|
|
174
|
+
assert.ok(!urlArg.includes(TEST_TOKEN), 'clone URL 不得含 token');
|
|
175
|
+
assert.ok(!urlArg.includes('oauth2:'), 'clone URL 不得含内嵌凭据');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('token 不泄漏:credential 文件在 git 调用期间存在、用后删除、内容含 token 但 argv 不含', async (t) => {
|
|
179
|
+
const dir = await makeTempDir(t);
|
|
180
|
+
const calls: CallRecord[] = [];
|
|
181
|
+
const credFileContents: string[] = [];
|
|
182
|
+
const exec: GitExecFn = async (_cmd, args) => {
|
|
183
|
+
calls.push({ cmd: 'git', args });
|
|
184
|
+
const helper = args.find((a) => a.startsWith('credential.helper=') && a.includes('store --file='));
|
|
185
|
+
if (helper) {
|
|
186
|
+
const file = helper.slice(helper.indexOf('--file=') + '--file='.length).replace(/^"|"$/g, '');
|
|
187
|
+
credFileContents.push(await fs.readFile(file, 'utf8'));
|
|
188
|
+
}
|
|
189
|
+
return { stdout: '', stderr: '', code: 0 };
|
|
190
|
+
};
|
|
191
|
+
const transport = new GitTransport(makeOptions({ workDir: dir, exec }));
|
|
192
|
+
await transport.list(); // 触发 clone(带 credential)
|
|
193
|
+
// credential 文件在调用期间存在(被读到内容)
|
|
194
|
+
assert.ok(credFileContents.length >= 1, 'credential 文件应被创建并被 git 调用读取');
|
|
195
|
+
assert.ok(credFileContents.some((c) => c.includes(TEST_TOKEN)), 'credential 文件应含 token');
|
|
196
|
+
// 调用后无残留:credential 临时目录已删除(找不到任何含 token 的文件)
|
|
197
|
+
const tmpRoot = path.join(os.tmpdir());
|
|
198
|
+
const leftovers = (await fs.readdir(tmpRoot)).filter((n) => n.startsWith('dsh-git-cred-'));
|
|
199
|
+
for (const n of leftovers) {
|
|
200
|
+
const p = path.join(tmpRoot, n);
|
|
201
|
+
try {
|
|
202
|
+
const stat = await fs.stat(p);
|
|
203
|
+
if (stat.isDirectory()) {
|
|
204
|
+
const files = await fs.readdir(p);
|
|
205
|
+
assert.equal(files.length, 0, `credential 临时目录应有残留: ${n}`);
|
|
206
|
+
}
|
|
207
|
+
} catch { /* 已删除 */ }
|
|
208
|
+
}
|
|
209
|
+
// argv 不含 token
|
|
210
|
+
for (const c of calls) for (const arg of c.args) assert.ok(!arg.includes(TEST_TOKEN), `argv 泄漏 token: ${arg}`);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('upload 契约:写快照目录 + add + commit(add) + push,返回 computeSnapshotMeta', async (t) => {
|
|
214
|
+
const dir = await makeGitWorkDir(t);
|
|
215
|
+
const calls: CallRecord[] = [];
|
|
216
|
+
const transport = new GitTransport(makeOptions({ workDir: dir, exec: mockExec(calls) }));
|
|
217
|
+
const snap = sampleSnapshot();
|
|
218
|
+
const meta = await transport.upload(snap);
|
|
219
|
+
assert.deepEqual(meta, computeSnapshotMeta(snap));
|
|
220
|
+
// 命令序列:add snapshots/<id> → diff --cached --quiet → commit -m add → push
|
|
221
|
+
const adds = joinedArgs(calls, 'add');
|
|
222
|
+
assert.ok(adds.some((a) => a.includes('snapshots/snap-001')), `应 add 快照目录: ${JSON.stringify(adds)}`);
|
|
223
|
+
assert.equal(joinedArgs(calls, 'diff --cached --quiet').length, 1);
|
|
224
|
+
const commits = joinedArgs(calls, 'commit');
|
|
225
|
+
assert.equal(commits.length, 1);
|
|
226
|
+
assert.match(commits[0]!, /-m sync: add snapshot snap-001/);
|
|
227
|
+
assert.equal(joinedArgs(calls, 'push').length, 1, 'upload 应 push');
|
|
228
|
+
// commit message 不含 token
|
|
229
|
+
for (const c of commits) assert.ok(!c.includes(TEST_TOKEN));
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('upload 幂等:工作树内容与 HEAD 一致 → 不 commit 不 push,仍返回 meta', async (t) => {
|
|
233
|
+
const dir = await makeGitWorkDir(t);
|
|
234
|
+
const calls: CallRecord[] = [];
|
|
235
|
+
const transport = new GitTransport(makeOptions({
|
|
236
|
+
workDir: dir,
|
|
237
|
+
exec: mockExec(calls, { codeBy: { 'diff --cached --quiet': 0 } }),
|
|
238
|
+
}));
|
|
239
|
+
const meta = await transport.upload(sampleSnapshot());
|
|
240
|
+
assert.equal(meta.id, 'snap-001');
|
|
241
|
+
assert.equal(joinedArgs(calls, 'commit').length, 0, '无变更不得 commit');
|
|
242
|
+
assert.equal(joinedArgs(calls, 'push').length, 0, '无变更不得 push');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('upload 覆盖:同 id 再次上传 → commit message 为 update', async (t) => {
|
|
246
|
+
const dir = await makeGitWorkDir(t);
|
|
247
|
+
const calls: CallRecord[] = [];
|
|
248
|
+
const transport = new GitTransport(makeOptions({ workDir: dir, exec: mockExec(calls) }));
|
|
249
|
+
await transport.upload(sampleSnapshot());
|
|
250
|
+
calls.length = 0; // 清空,模拟第二次 upload(同 id 覆盖)
|
|
251
|
+
await transport.upload(sampleSnapshot({ createdAt: '2026-08-16T13:00:00.000Z' }));
|
|
252
|
+
const commits = joinedArgs(calls, 'commit');
|
|
253
|
+
assert.equal(commits.length, 1);
|
|
254
|
+
assert.match(commits[0]!, /-m sync: update snapshot snap-001/);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('download 契约:不存在的 id → 抛错(消息含 id)', async (t) => {
|
|
258
|
+
const dir = await makeGitWorkDir(t);
|
|
259
|
+
const calls: CallRecord[] = [];
|
|
260
|
+
const transport = new GitTransport(makeOptions({ workDir: dir, exec: mockExec(calls) }));
|
|
261
|
+
await assert.rejects(
|
|
262
|
+
transport.download('missing-001'),
|
|
263
|
+
(err: unknown) => {
|
|
264
|
+
assert.ok(err instanceof GitTransportError);
|
|
265
|
+
assert.match(err.message, /missing-001/);
|
|
266
|
+
assert.match(err.message, /不存在/);
|
|
267
|
+
return true;
|
|
268
|
+
},
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('delete 契约:存在 → 删除目录 + add -A + commit(delete) + push;不存在 → 静默成功', async (t) => {
|
|
273
|
+
const dir = await makeGitWorkDir(t);
|
|
274
|
+
const calls: CallRecord[] = [];
|
|
275
|
+
const transport = new GitTransport(makeOptions({ workDir: dir, exec: mockExec(calls) }));
|
|
276
|
+
// 准备一个已存在快照目录
|
|
277
|
+
await fs.mkdir(path.join(dir, 'snapshots', 'snap-001'), { recursive: true });
|
|
278
|
+
await fs.writeFile(path.join(dir, 'snapshots', 'snap-001', 'manifest.json'), '{}');
|
|
279
|
+
await transport.delete('snap-001');
|
|
280
|
+
const adds = joinedArgs(calls, 'add -A');
|
|
281
|
+
assert.ok(adds.some((a) => a.includes('snapshots/snap-001')), 'delete 应 stage 删除');
|
|
282
|
+
assert.equal(joinedArgs(calls, 'commit').length, 1);
|
|
283
|
+
assert.match(joinedArgs(calls, 'commit')[0]!, /-m sync: delete snapshot snap-001/);
|
|
284
|
+
assert.equal(joinedArgs(calls, 'push').length, 1);
|
|
285
|
+
// 不存在 → 成功且不产生 git 写操作
|
|
286
|
+
calls.length = 0;
|
|
287
|
+
await transport.delete('never-existed');
|
|
288
|
+
assert.equal(joinedArgs(calls, 'commit').length, 0);
|
|
289
|
+
assert.equal(joinedArgs(calls, 'push').length, 0);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('错误消息 sanitize:git 失败 stderr 含 token → 抛出消息被替换为 [REDACTED]', async (t) => {
|
|
293
|
+
const dir = await makeGitWorkDir(t);
|
|
294
|
+
const calls: CallRecord[] = [];
|
|
295
|
+
const transport = new GitTransport(makeOptions({
|
|
296
|
+
workDir: dir,
|
|
297
|
+
exec: mockExec(calls, {
|
|
298
|
+
failOn: { match: 'push', code: 128, stderr: `fatal: unable to access 'https://oauth2:${TEST_TOKEN}@github.com/example/private-config.git/': authentication failed` },
|
|
299
|
+
}),
|
|
300
|
+
}));
|
|
301
|
+
await assert.rejects(
|
|
302
|
+
transport.upload(sampleSnapshot()),
|
|
303
|
+
(err: unknown) => {
|
|
304
|
+
assert.ok(err instanceof GitTransportError);
|
|
305
|
+
assert.ok(!err.message.includes(TEST_TOKEN), `错误消息泄漏 token: ${err.message}`);
|
|
306
|
+
assert.match(err.message, /\[REDACTED\]/);
|
|
307
|
+
return true;
|
|
308
|
+
},
|
|
309
|
+
);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('checkIsPrivate:匿名可达 → false;匿名失败+认证可达 → true;都失败 → 抛错', async (t) => {
|
|
313
|
+
// 匿名成功
|
|
314
|
+
const c1: CallRecord[] = [];
|
|
315
|
+
const t1 = new GitTransport(makeOptions({ workDir: await makeTempDir(t), exec: mockExec(c1) }));
|
|
316
|
+
assert.equal(await t1.checkIsPrivate(), false);
|
|
317
|
+
assert.equal(t1.isPrivateHint, false);
|
|
318
|
+
|
|
319
|
+
// 匿名失败(404),带 credential 成功
|
|
320
|
+
const c2: CallRecord[] = [];
|
|
321
|
+
const exec2: GitExecFn = async (_cmd, args) => {
|
|
322
|
+
c2.push({ cmd: 'git', args });
|
|
323
|
+
if (args.join(' ').includes('ls-remote')) {
|
|
324
|
+
const authed = args.some((a) => a.includes('credential.helper='));
|
|
325
|
+
return authed
|
|
326
|
+
? { stdout: '', stderr: '', code: 0 }
|
|
327
|
+
: { stdout: '', stderr: 'fatal: could not read Username', code: 128 };
|
|
328
|
+
}
|
|
329
|
+
return { stdout: '', stderr: '', code: 0 };
|
|
330
|
+
};
|
|
331
|
+
const t2 = new GitTransport(makeOptions({ workDir: await makeTempDir(t), exec: exec2 }));
|
|
332
|
+
assert.equal(await t2.checkIsPrivate(), true);
|
|
333
|
+
assert.equal(t2.isPrivateHint, true);
|
|
334
|
+
// 第二次调用走缓存,不重复探测
|
|
335
|
+
const lsBefore = joinedArgs(c2, 'ls-remote').length;
|
|
336
|
+
await t2.checkIsPrivate();
|
|
337
|
+
assert.equal(joinedArgs(c2, 'ls-remote').length, lsBefore);
|
|
338
|
+
|
|
339
|
+
// 匿名与认证都失败
|
|
340
|
+
const c3: CallRecord[] = [];
|
|
341
|
+
const t3 = new GitTransport(makeOptions({
|
|
342
|
+
workDir: await makeTempDir(t),
|
|
343
|
+
exec: mockExec(c3, { codeBy: { 'ls-remote': 128 }, failOn: { match: 'credential.helper=', code: 128, stderr: 'fatal: could not read Username' } }),
|
|
344
|
+
}));
|
|
345
|
+
await assert.rejects(t3.checkIsPrivate(), /不可达|认证失败/);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test('快照 id 安全:非法 id(路径穿越/特殊字符)→ upload/download/delete 均拒绝', async (t) => {
|
|
349
|
+
const dir = await makeGitWorkDir(t);
|
|
350
|
+
const calls: CallRecord[] = [];
|
|
351
|
+
const transport = new GitTransport(makeOptions({ workDir: dir, exec: mockExec(calls) }));
|
|
352
|
+
for (const bad of ['../evil', 'a/b', 'a\\b', '.', '..', 'snap\ninject']) {
|
|
353
|
+
await assert.rejects(transport.upload(sampleSnapshot({ id: bad })), /非法快照 id/);
|
|
354
|
+
await assert.rejects(transport.download(bad), /非法快照 id/);
|
|
355
|
+
await assert.rejects(transport.delete(bad), /非法快照 id/);
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test('upload 加密快照:写 snapshots-encrypted/<id>.json 密文单文件(不落散文件目录),add/commit/push', async (t) => {
|
|
360
|
+
const dir = await makeGitWorkDir(t);
|
|
361
|
+
const calls: CallRecord[] = [];
|
|
362
|
+
const transport = new GitTransport(makeOptions({ workDir: dir, exec: mockExec(calls) }));
|
|
363
|
+
const snap = await encryptedSnapshot();
|
|
364
|
+
assert.ok(isEncryptedSections(snap.sections), '前置:加密载荷');
|
|
365
|
+
const meta = await transport.upload(snap);
|
|
366
|
+
assert.deepEqual(meta, computeSnapshotMeta(snap));
|
|
367
|
+
// 密文单文件已写入工作副本
|
|
368
|
+
const encFile = path.join(dir, 'snapshots-encrypted', 'snap-enc.json');
|
|
369
|
+
const raw = JSON.parse(await fs.readFile(encFile, 'utf8'));
|
|
370
|
+
assert.equal(raw.id, 'snap-enc');
|
|
371
|
+
assert.ok(isEncryptedSections(raw.sections), '远端文件保持加密载荷(不含明文)');
|
|
372
|
+
assert.ok(!JSON.stringify(raw).includes('# Coding'), '序列化不得泄漏明文文件内容');
|
|
373
|
+
// 散文件目录不产生
|
|
374
|
+
assert.equal(await fs.stat(path.join(dir, 'snapshots', 'snap-enc')).catch(() => null), null, '加密快照不写散文件目录');
|
|
375
|
+
// git 命令:add snapshots-encrypted/<id>.json
|
|
376
|
+
const adds = joinedArgs(calls, 'add');
|
|
377
|
+
assert.ok(adds.some((a) => a.includes('snapshots-encrypted/snap-enc.json')), `应 add 密文文件: ${JSON.stringify(adds)}`);
|
|
378
|
+
const commits = joinedArgs(calls, 'commit');
|
|
379
|
+
assert.equal(commits.length, 1);
|
|
380
|
+
assert.match(commits[0]!, /-m sync: add snapshot snap-enc/);
|
|
381
|
+
assert.equal(joinedArgs(calls, 'push').length, 1);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test('upload 加密快照覆盖:同 id 明文→加密 切换时清掉旧散文件目录(双形态互斥)', async (t) => {
|
|
385
|
+
const dir = await makeGitWorkDir(t);
|
|
386
|
+
const calls: CallRecord[] = [];
|
|
387
|
+
const transport = new GitTransport(makeOptions({ workDir: dir, exec: mockExec(calls) }));
|
|
388
|
+
// 先传明文(snap-enc 走散文件目录)
|
|
389
|
+
await transport.upload(sampleSnapshot({ id: 'snap-enc' }));
|
|
390
|
+
assert.ok(await fs.stat(path.join(dir, 'snapshots', 'snap-enc')));
|
|
391
|
+
calls.length = 0;
|
|
392
|
+
// 同 id 再传加密 → 旧散文件目录被清除,只留密文单文件
|
|
393
|
+
await transport.upload(await encryptedSnapshot());
|
|
394
|
+
assert.equal(await fs.stat(path.join(dir, 'snapshots', 'snap-enc')).catch(() => null), null, '旧明文散文件目录已删');
|
|
395
|
+
assert.ok(await fs.stat(path.join(dir, 'snapshots-encrypted', 'snap-enc.json')));
|
|
396
|
+
const commits = joinedArgs(calls, 'commit');
|
|
397
|
+
assert.match(commits[0]!, /-m sync: update snapshot snap-enc/, '形态切换视为 update');
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test('download 加密快照:读回密文单文件并还原(sections 保持密文载荷)', async (t) => {
|
|
401
|
+
const dir = await makeGitWorkDir(t);
|
|
402
|
+
const calls: CallRecord[] = [];
|
|
403
|
+
const transport = new GitTransport(makeOptions({ workDir: dir, exec: mockExec(calls) }));
|
|
404
|
+
const snap = await encryptedSnapshot();
|
|
405
|
+
await transport.upload(snap);
|
|
406
|
+
const roundtrip = await transport.download('snap-enc');
|
|
407
|
+
assert.equal(roundtrip.id, 'snap-enc');
|
|
408
|
+
assert.deepEqual(roundtrip.manifest, snap.manifest);
|
|
409
|
+
assert.deepEqual(roundtrip.sections, snap.sections, '加密载荷逐字节一致(解密后文件字节应无损)');
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test('delete 加密快照:从 snapshots-encrypted 移除 + commit(delete) + push', async (t) => {
|
|
413
|
+
const dir = await makeGitWorkDir(t);
|
|
414
|
+
const calls: CallRecord[] = [];
|
|
415
|
+
const transport = new GitTransport(makeOptions({ workDir: dir, exec: mockExec(calls) }));
|
|
416
|
+
await transport.upload(await encryptedSnapshot());
|
|
417
|
+
calls.length = 0;
|
|
418
|
+
await transport.delete('snap-enc');
|
|
419
|
+
const adds = joinedArgs(calls, 'add -A');
|
|
420
|
+
assert.ok(adds.some((a) => a.includes('snapshots-encrypted/snap-enc.json')), 'delete 应 stage 密文文件删除');
|
|
421
|
+
assert.equal(joinedArgs(calls, 'commit').length, 1);
|
|
422
|
+
assert.match(joinedArgs(calls, 'commit')[0]!, /-m sync: delete snapshot snap-enc/);
|
|
423
|
+
// 不存在 → 静默成功
|
|
424
|
+
await transport.delete('snap-enc');
|
|
425
|
+
assert.equal(joinedArgs(calls, 'commit').length, 1, '再次删除不产生 commit');
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
/* ---------------- 集成测试(真实 git,本地 bare repo) ---------------- */
|
|
429
|
+
|
|
430
|
+
test('集成:upload → list → download → delete 端到端(真实 git + 本地 bare repo)', async (t) => {
|
|
431
|
+
const bare = await makeBareRepo(t);
|
|
432
|
+
const workDir = await makeTempDir(t);
|
|
433
|
+
let tokenReads = 0;
|
|
434
|
+
const transport = new GitTransport({
|
|
435
|
+
repoUrl: bare,
|
|
436
|
+
workDir,
|
|
437
|
+
credentials: { getToken: async () => { tokenReads += 1; return TEST_TOKEN; } },
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// upload
|
|
441
|
+
const snap = sampleSnapshot();
|
|
442
|
+
const meta = await transport.upload(snap);
|
|
443
|
+
assert.equal(meta.id, 'snap-001');
|
|
444
|
+
// 快照散文件目录落在工作副本
|
|
445
|
+
const manifestAbs = path.join(workDir, 'snapshots', 'snap-001', 'manifest.json');
|
|
446
|
+
const manifestRaw = JSON.parse(await fs.readFile(manifestAbs, 'utf8'));
|
|
447
|
+
assert.equal(manifestRaw.id, 'snap-001');
|
|
448
|
+
assert.equal(manifestRaw.createdAt, snap.createdAt);
|
|
449
|
+
// 分区文件落盘
|
|
450
|
+
assert.ok(await fs.stat(path.join(workDir, 'snapshots', 'snap-001', 'config', 'settings.json')));
|
|
451
|
+
|
|
452
|
+
// 提交已 push 到远端 bare repo
|
|
453
|
+
const log = await runRealGit(['log', '--oneline', '--all'], bare);
|
|
454
|
+
assert.equal(log.code, 0, `git log 失败: ${log.stderr}`);
|
|
455
|
+
assert.match(log.stdout, /sync: add snapshot snap-001/);
|
|
456
|
+
|
|
457
|
+
// list:远端 + 工作副本 → 1 条,createdAt 升序
|
|
458
|
+
const listed = await transport.list();
|
|
459
|
+
assert.equal(listed.length, 1);
|
|
460
|
+
assert.equal(listed[0]!.id, 'snap-001');
|
|
461
|
+
assert.equal(listed[0]!.createdAt, snap.createdAt);
|
|
462
|
+
assert.deepEqual(listed[0]!.manifest, snap.manifest);
|
|
463
|
+
|
|
464
|
+
// download:读回完整载荷,与上传一致
|
|
465
|
+
const roundtrip = await transport.download('snap-001');
|
|
466
|
+
assert.deepEqual(roundtrip, snap);
|
|
467
|
+
|
|
468
|
+
// 覆盖:同 id 上传新内容 → update commit,目录唯一
|
|
469
|
+
const snap2 = sampleSnapshot({ createdAt: '2026-08-16T14:00:00.000Z' });
|
|
470
|
+
snap2.sections.settings = { version: 1, namespaces: { general: { value: { theme: 'light' }, revision: 2, secrets: [] } } };
|
|
471
|
+
await transport.upload(snap2);
|
|
472
|
+
const dirs = await fs.readdir(path.join(workDir, 'snapshots'));
|
|
473
|
+
assert.deepEqual(dirs, ['snap-001']);
|
|
474
|
+
const log2 = await runRealGit(['log', '--oneline', '--all'], bare);
|
|
475
|
+
assert.match(log2.stdout, /sync: update snapshot snap-001/);
|
|
476
|
+
const roundtrip2 = await transport.download('snap-001');
|
|
477
|
+
assert.deepEqual(roundtrip2, snap2);
|
|
478
|
+
|
|
479
|
+
// delete:存在 → 移除;再 delete 不存在 id → 静默成功
|
|
480
|
+
await transport.delete('snap-001');
|
|
481
|
+
assert.equal((await transport.list()).length, 0);
|
|
482
|
+
const log3 = await runRealGit(['log', '--oneline', '--all'], bare);
|
|
483
|
+
assert.match(log3.stdout, /sync: delete snapshot snap-001/);
|
|
484
|
+
await transport.delete('snap-001'); // 不存在视为成功
|
|
485
|
+
await assert.rejects(transport.download('snap-001'), /不存在/);
|
|
486
|
+
|
|
487
|
+
// 本地路径仓库不触发认证读取(token 提供者不被调用)
|
|
488
|
+
assert.equal(tokenReads, 0, '本地仓库无需 token,getToken 不应被调用');
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test('集成:同内容重复 upload → 第二次不产生新 commit(git 天然增量,内容无变化不提交)', async (t) => {
|
|
492
|
+
const bare = await makeBareRepo(t);
|
|
493
|
+
const workDir = await makeTempDir(t);
|
|
494
|
+
const transport = new GitTransport({ repoUrl: bare, workDir, credentials: { getToken: async () => TEST_TOKEN } });
|
|
495
|
+
|
|
496
|
+
await transport.upload(sampleSnapshot());
|
|
497
|
+
let log = await runRealGit(['log', '--oneline', '--all'], bare);
|
|
498
|
+
assert.equal(log.code, 0, `git log 失败: ${log.stderr}`);
|
|
499
|
+
assert.equal(log.stdout.trim().split('\n').filter(Boolean).length, 1, '首次上传应恰好 1 个 commit');
|
|
500
|
+
|
|
501
|
+
// 同 id 同内容(快照完全一致)重复上传 → diff --cached --quiet 无变更 → 不 commit 不 push
|
|
502
|
+
await transport.upload(sampleSnapshot());
|
|
503
|
+
log = await runRealGit(['log', '--oneline', '--all'], bare);
|
|
504
|
+
assert.equal(log.stdout.trim().split('\n').filter(Boolean).length, 1, '内容无变化 → 不得产生新 commit');
|
|
505
|
+
const listed = await transport.list();
|
|
506
|
+
assert.equal(listed.length, 1, '远端仍只有 1 条快照(同 id 覆盖语义)');
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test('集成:token 不泄漏到快照文件内容与 commit message(真实 git 全链路)', async (t) => {
|
|
510
|
+
const bare = await makeBareRepo(t);
|
|
511
|
+
const workDir = await makeTempDir(t);
|
|
512
|
+
const transport = new GitTransport({
|
|
513
|
+
repoUrl: bare,
|
|
514
|
+
workDir,
|
|
515
|
+
credentials: { getToken: async () => TEST_TOKEN },
|
|
516
|
+
});
|
|
517
|
+
await transport.upload(sampleSnapshot());
|
|
518
|
+
// 工作副本 snapshots 下所有文件内容不得含 token
|
|
519
|
+
const files: string[] = [];
|
|
520
|
+
const walk = async (p: string): Promise<void> => {
|
|
521
|
+
for (const name of await fs.readdir(p)) {
|
|
522
|
+
const full = path.join(p, name);
|
|
523
|
+
const stat = await fs.stat(full);
|
|
524
|
+
if (stat.isDirectory()) await walk(full);
|
|
525
|
+
else files.push(full);
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
await walk(path.join(workDir, 'snapshots'));
|
|
529
|
+
for (const f of files) {
|
|
530
|
+
const content = await fs.readFile(f, 'utf8');
|
|
531
|
+
assert.ok(!content.includes(TEST_TOKEN), `文件内容泄漏 token: ${f}`);
|
|
532
|
+
}
|
|
533
|
+
// commit message 不含 token
|
|
534
|
+
const log = await runRealGit(['log', '--format=%s', '--all'], bare);
|
|
535
|
+
assert.ok(!log.stdout.includes(TEST_TOKEN), `commit message 泄漏 token: ${log.stdout}`);
|
|
536
|
+
// 工作副本根目录无 credential 残留文件
|
|
537
|
+
const rootEntries = await fs.readdir(workDir);
|
|
538
|
+
assert.ok(!rootEntries.some((n) => n.includes('cred') || n.includes('dsh-git-cred')), `credential 残留: ${rootEntries.join(', ')}`);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
test('集成:非 git 仓库报错清晰(真实 git 验证消息)', async (t) => {
|
|
542
|
+
const workDir = await makeTempDir(t);
|
|
543
|
+
await fs.writeFile(path.join(workDir, 'placeholder.txt'), 'x');
|
|
544
|
+
const transport = new GitTransport({
|
|
545
|
+
repoUrl: 'https://github.com/example/private-config.git',
|
|
546
|
+
workDir,
|
|
547
|
+
credentials: { getToken: async () => TEST_TOKEN },
|
|
548
|
+
});
|
|
549
|
+
await assert.rejects(
|
|
550
|
+
transport.list(),
|
|
551
|
+
(err: unknown) => {
|
|
552
|
+
assert.ok(err instanceof GitTransportError);
|
|
553
|
+
assert.match(err.message, /不是 git 仓库/);
|
|
554
|
+
return true;
|
|
555
|
+
},
|
|
556
|
+
);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
test('集成:list 按 createdAt 升序(真实 git 多快照)', async (t) => {
|
|
560
|
+
const bare = await makeBareRepo(t);
|
|
561
|
+
const workDir = await makeTempDir(t);
|
|
562
|
+
const transport = new GitTransport({ repoUrl: bare, workDir, credentials: { getToken: async () => TEST_TOKEN } });
|
|
563
|
+
await transport.upload(sampleSnapshot({ id: 'snap-b', createdAt: '2026-08-16T11:00:00.000Z' }));
|
|
564
|
+
await transport.upload(sampleSnapshot({ id: 'snap-a', createdAt: '2026-08-16T09:00:00.000Z' }));
|
|
565
|
+
await transport.upload(sampleSnapshot({ id: 'snap-c', createdAt: '2026-08-16T12:00:00.000Z' }));
|
|
566
|
+
const listed = await transport.list();
|
|
567
|
+
assert.deepEqual(listed.map((m) => m.id), ['snap-a', 'snap-b', 'snap-c']);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test('集成:加密快照端到端(真实 git + 本地 bare repo)—— upload → list → download → delete', async (t) => {
|
|
571
|
+
const bare = await makeBareRepo(t);
|
|
572
|
+
const workDir = await makeTempDir(t);
|
|
573
|
+
const transport = new GitTransport({ repoUrl: bare, workDir, credentials: { getToken: async () => TEST_TOKEN } });
|
|
574
|
+
const snap = await encryptedSnapshot();
|
|
575
|
+
|
|
576
|
+
// upload:密文单文件提交并推送
|
|
577
|
+
const meta = await transport.upload(snap);
|
|
578
|
+
assert.equal(meta.id, 'snap-enc');
|
|
579
|
+
assert.equal(meta.manifest.encrypted, true);
|
|
580
|
+
assert.deepEqual(meta.sections, {}, '加密快照的 sections hash 记录为空(密文不可与本地明文比较)');
|
|
581
|
+
const log = await runRealGit(['log', '--oneline', '--all'], bare);
|
|
582
|
+
assert.equal(log.code, 0, `git log 失败: ${log.stderr}`);
|
|
583
|
+
assert.match(log.stdout, /sync: add snapshot snap-enc/);
|
|
584
|
+
|
|
585
|
+
// list:密文快照可见(工作副本 pull 后重建)
|
|
586
|
+
const listed = await transport.list();
|
|
587
|
+
assert.equal(listed.length, 1);
|
|
588
|
+
assert.equal(listed[0]!.id, 'snap-enc');
|
|
589
|
+
assert.equal(listed[0]!.manifest.encrypted, true);
|
|
590
|
+
assert.deepEqual(listed[0]!.manifest, snap.manifest);
|
|
591
|
+
|
|
592
|
+
// download:读回密文载荷(明文内容不可见)
|
|
593
|
+
const roundtrip = await transport.download('snap-enc');
|
|
594
|
+
assert.ok(isEncryptedSections(roundtrip.sections), '下载返回加密载荷');
|
|
595
|
+
assert.deepEqual(roundtrip.sections, snap.sections);
|
|
596
|
+
assert.ok(!JSON.stringify(roundtrip).includes('# Coding'), '载荷序列化不得泄漏明文');
|
|
597
|
+
|
|
598
|
+
// 明文 + 加密快照共存于 list(按 createdAt 升序)
|
|
599
|
+
await transport.upload(sampleSnapshot({ id: 'snap-plain', createdAt: '2026-08-16T09:00:00.000Z' }));
|
|
600
|
+
const mixed = await transport.list();
|
|
601
|
+
assert.deepEqual(mixed.map((m) => m.id), ['snap-plain', 'snap-enc']);
|
|
602
|
+
|
|
603
|
+
// delete 加密快照
|
|
604
|
+
await transport.delete('snap-enc');
|
|
605
|
+
assert.deepEqual((await transport.list()).map((m) => m.id), ['snap-plain']);
|
|
606
|
+
await assert.rejects(transport.download('snap-enc'), /不存在/);
|
|
607
|
+
await transport.delete('snap-enc'); // 不存在视为成功
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
test('集成:空文件类分区(skills files:[])upload 后,全新 clone 的 B 机 download 应读回空分区(git 不跟踪空目录)', async (t) => {
|
|
611
|
+
const bare = await makeBareRepo(t);
|
|
612
|
+
// 机器 A:上传含空 skills 分区(~/.dsh/skills 不存在 → files: [])的快照
|
|
613
|
+
const workDirA = await makeTempDir(t);
|
|
614
|
+
const transportA = new GitTransport({ repoUrl: bare, workDir: workDirA, credentials: { getToken: async () => TEST_TOKEN } });
|
|
615
|
+
const snap = sampleSnapshot();
|
|
616
|
+
snap.sections.skills = { version: 1, files: [] } as unknown as FilesSection;
|
|
617
|
+
snap.manifest = { ...snap.manifest, sectionIds: ['settings', 'providers', 'skills'] };
|
|
618
|
+
await transportA.upload(snap);
|
|
619
|
+
|
|
620
|
+
// 机器 B:全新 workDir(重新 clone 远端),模拟另一台机器的首次拉取
|
|
621
|
+
const workDirB = await makeTempDir(t);
|
|
622
|
+
const transportB = new GitTransport({ repoUrl: bare, workDir: workDirB, credentials: { getToken: async () => TEST_TOKEN } });
|
|
623
|
+
|
|
624
|
+
// 复现前提:git 不跟踪空目录 → B 端工作副本里不存在 custom/skills/ 目录
|
|
625
|
+
const skillsAbsB = path.join(workDirB, 'snapshots', 'snap-001', 'custom', 'skills');
|
|
626
|
+
const skillsStat = await fs.stat(skillsAbsB).catch(() => null);
|
|
627
|
+
assert.equal(skillsStat, null, 'git 应不跟踪空目录:B 端工作副本不应存在 custom/skills/');
|
|
628
|
+
|
|
629
|
+
// 修复后:download 应降级读回空分区,而不是抛「快照缺少文件分区目录」
|
|
630
|
+
const roundtrip = await transportB.download('snap-001');
|
|
631
|
+
const skills = (roundtrip.sections as Partial<Record<SectionId, SectionData>>)['skills'];
|
|
632
|
+
assert.deepEqual(skills, { version: 1, files: [] });
|
|
633
|
+
assert.deepEqual(roundtrip.manifest, snap.manifest);
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
// ─── t5:远端快照裁剪的 git 契约(upload 先 push,再 delete 删旧,各自独立 commit+push) ───
|
|
637
|
+
|
|
638
|
+
test('集成:裁剪删除旧快照 → 每次 delete 独立 commit+push,add 提交先于 delete 提交', async (t) => {
|
|
639
|
+
const bare = await makeBareRepo(t);
|
|
640
|
+
const workDir = await makeTempDir(t);
|
|
641
|
+
const transport = new GitTransport({ repoUrl: bare, workDir, credentials: { getToken: async () => TEST_TOKEN } });
|
|
642
|
+
|
|
643
|
+
// 模拟裁剪场景:先上传多个快照(adds),随后逐个 delete 旧的
|
|
644
|
+
for (let i = 1; i <= 4; i++) {
|
|
645
|
+
await transport.upload(sampleSnapshot({ id: `snap-0${i}`, createdAt: `2026-08-16T0${i}:00:00.000Z` }));
|
|
646
|
+
}
|
|
647
|
+
// delete 3 个旧的,保留最新 1 个
|
|
648
|
+
await transport.delete('snap-01');
|
|
649
|
+
await transport.delete('snap-02');
|
|
650
|
+
await transport.delete('snap-03');
|
|
651
|
+
|
|
652
|
+
// list 只反映保留的快照
|
|
653
|
+
const listed = await transport.list();
|
|
654
|
+
assert.deepEqual(listed.map((m) => m.id), ['snap-04'], '裁剪后远端只保留最新快照');
|
|
655
|
+
|
|
656
|
+
// git 提交历史:先 add 后 delete(顺序正确,新快照先推再删旧)
|
|
657
|
+
const log = await runRealGit(['log', '--oneline', '--all'], bare);
|
|
658
|
+
assert.equal(log.code, 0, `git log 失败: ${log.stderr}`);
|
|
659
|
+
const addIdx = log.stdout.indexOf('sync: add snapshot snap-04');
|
|
660
|
+
const delIdx = log.stdout.indexOf('sync: delete snapshot snap-01');
|
|
661
|
+
assert.ok(addIdx >= 0, 'add 提交应存在');
|
|
662
|
+
assert.ok(delIdx >= 0, 'delete 提交应存在');
|
|
663
|
+
// git log 默认按最新提交在前;delete 是较新的提交,应出现在 add 之前(add 更旧在列表更靠后)
|
|
664
|
+
assert.ok(delIdx < addIdx, 'delete 提交应晚于(在 log 中靠前于)add 提交 → 先推新再删旧');
|
|
665
|
+
|
|
666
|
+
// 每个 delete 独立 commit+push
|
|
667
|
+
const delCount = (log.stdout.match(/sync: delete snapshot snap-0/g) ?? []).length;
|
|
668
|
+
assert.equal(delCount, 3, '每个被删快照对应一次独立 delete 提交');
|
|
669
|
+
|
|
670
|
+
// 已删除快照目录从工作副本移除
|
|
671
|
+
const dirs = await fs.readdir(path.join(workDir, 'snapshots'));
|
|
672
|
+
assert.deepEqual(dirs, ['snap-04']);
|
|
673
|
+
});
|