@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,417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export 编排(规范 §1/§18/§21,设计 §4.4):
|
|
3
|
+
* adapter 收集各分区 → Secret 过滤 → manifest → checksum → ZIP。
|
|
4
|
+
*
|
|
5
|
+
* 安全不变量:
|
|
6
|
+
* - Secret 值默认永不进入导出数据(结构化分区逐一过 SecretScanner);
|
|
7
|
+
* - includeSecrets=true 必须注入 EncryptionProvider(m4 实现),否则拒绝导出;
|
|
8
|
+
* - 注入 EncryptionProvider 时备份标记为加密(encrypted=true):includeSecrets=false
|
|
9
|
+
* 时 secrets.enc 加密空内容占位,备份仍需要密码导入,但不含任何凭据值;
|
|
10
|
+
* - 加密密码/秘密值绝不写入 manifest 与日志。
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs/promises';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { buildChecksums } from '../utils/hashing.ts';
|
|
15
|
+
import { stringifyJsonSafe } from '../utils/json.ts';
|
|
16
|
+
import { buildManifest, CHECKSUMS_FILE, MANIFEST_FILE, EXPORTER_NAME } from '../schema/manifest.ts';
|
|
17
|
+
import { SECTION_JSON_PATHS, SECTION_FILE_PREFIXES, isFileSection } from '../schema/config.ts';
|
|
18
|
+
import { DEFAULT_SENSITIVE_RELS, refreshVault } from '../security/vault.ts';
|
|
19
|
+
import { writeZip } from '../utils/zip.ts';
|
|
20
|
+
import { msgOf } from './messages.ts';
|
|
21
|
+
import type { MsgFunc } from './messages.ts';
|
|
22
|
+
import type { Manifest, SectionId } from '../schema/types.ts';
|
|
23
|
+
import type {
|
|
24
|
+
ConfigAdapter, EncryptionProvider, ExportOptions, ExportReport,
|
|
25
|
+
ExportSection, HostContext, SecretScanner, SensitiveHit,
|
|
26
|
+
} from './types.ts';
|
|
27
|
+
|
|
28
|
+
/** m1:每导出一个分区前的进度回调信息(Host 侧 run 状态更新用;section = adapter id) */
|
|
29
|
+
export interface SectionProgress {
|
|
30
|
+
section: string;
|
|
31
|
+
/** 当前分区序号(1 起) */
|
|
32
|
+
index: number;
|
|
33
|
+
/** 选中分区总数 */
|
|
34
|
+
total: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ExporterOptions {
|
|
38
|
+
ctx: HostContext;
|
|
39
|
+
adapters: ConfigAdapter[];
|
|
40
|
+
/** Secret 扫描器;缺省用字段名黑名单剥离(m4 可注入强化版) */
|
|
41
|
+
scanner?: SecretScanner;
|
|
42
|
+
/** 加密提供者(m4 用 node:crypto 实现);includeSecrets 时必填;提供时备份标记 encrypted=true */
|
|
43
|
+
encryption?: EncryptionProvider | null;
|
|
44
|
+
/** 插件自身版本(manifest.exporter.version) */
|
|
45
|
+
exporterVersion?: string;
|
|
46
|
+
now?: () => Date;
|
|
47
|
+
/** 消息翻译器(缺省 ctx.msg ?? zh) */
|
|
48
|
+
msg?: MsgFunc;
|
|
49
|
+
/** m1:每导出一个分区前调用(真实进度埋点;不传则无埋点) */
|
|
50
|
+
onSection?: (info: SectionProgress) => void;
|
|
51
|
+
/**
|
|
52
|
+
* 插件数据目录(文件级 vault 位于 <vaultDataDir>/vault,缺省 <homeDir>/dsh-config-manager)。
|
|
53
|
+
* includeSecrets=false 时导出会自动刷新 vault;宿主自定义 dataDir 时应注入实际值
|
|
54
|
+
* (HostContext 不携带 dataDir,故经选项注入)。
|
|
55
|
+
*/
|
|
56
|
+
vaultDataDir?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 缺省 SecretScanner:递归黑名单字段剥离(字段名大小写不敏感;二进制/Uint8Array 原样跳过) */
|
|
60
|
+
export function defaultSecretScanner(): SecretScanner {
|
|
61
|
+
const SENSITIVE_FIELDS = [
|
|
62
|
+
'password', 'passwd', 'token', 'accesstoken', 'refreshtoken', 'apikey',
|
|
63
|
+
'secret', 'credential', 'authorization', 'cookie', 'privatekey', 'clientsecret',
|
|
64
|
+
];
|
|
65
|
+
/** 仅存「引用名」而非值的字段(如 apiKeyEnv=DEEPSEEK_API_KEY 是环境变量名,不是秘密) */
|
|
66
|
+
const REFERENCE_FIELDS = new Set([
|
|
67
|
+
'apikeyenv', 'api_key_env', 'apikeyname', 'tokenenv', 'accesstokenenv',
|
|
68
|
+
'refreshtokenenv', 'clientsecretenv', 'passwordenv',
|
|
69
|
+
]);
|
|
70
|
+
return {
|
|
71
|
+
scanAndRedact(data: unknown): { sanitized: unknown; hits: SensitiveHit[] } {
|
|
72
|
+
const hits: SensitiveHit[] = [];
|
|
73
|
+
const walk = (v: unknown, p: string): unknown => {
|
|
74
|
+
if (v === null || typeof v !== 'object') return v;
|
|
75
|
+
if (v instanceof Uint8Array) return v; // 二进制内容不按字段展开
|
|
76
|
+
if (Array.isArray(v)) return v.map((item, i) => walk(item, `${p}[${i}]`));
|
|
77
|
+
const out: Record<string, unknown> = {};
|
|
78
|
+
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
|
|
79
|
+
const lower = k.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
80
|
+
const isRefName = REFERENCE_FIELDS.has(lower);
|
|
81
|
+
const sensitive = !isRefName && SENSITIVE_FIELDS.some((f) => lower.includes(f));
|
|
82
|
+
if (sensitive && typeof val === 'string' && val !== '') {
|
|
83
|
+
hits.push({ path: p === '' ? k : `${p}.${k}`, field: k });
|
|
84
|
+
out[k] = ''; // 值剥离为空串(保留字段名与位置,供「需补录」提示)
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
out[k] = walk(val, p === '' ? k : `${p}.${k}`);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
};
|
|
91
|
+
return { sanitized: walk(data, ''), hits };
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* ---------------- G-09:文件类分区文本级扫描(只报告,绝不改写) ---------------- */
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 单文件扫描字节上限(1 MiB)。超大文件只扫描前 1 MiB:
|
|
100
|
+
* 凭据通常出现在配置/脚本的头部;超过上限的剩余部分放弃扫描,避免单个巨型文件拖垮导出。
|
|
101
|
+
*/
|
|
102
|
+
export const FILE_SECTION_SCAN_MAX_BYTES = 1024 * 1024;
|
|
103
|
+
|
|
104
|
+
/** 一次导出中所有文件类分区的累计扫描字节上限(16 MiB),防「成千上万小文件」拖慢导出 */
|
|
105
|
+
export const FILE_SECTION_SCAN_TOTAL_BUDGET_BYTES = 16 * 1024 * 1024;
|
|
106
|
+
|
|
107
|
+
/** 每个分区最多告警的**不同文件**数(不是 hit 条数);命中仍全量计入 redactedHits(报告统计通道),超出部分由一条汇总告警兜底,避免「真有凭据的文件被静默淹没」或「截断即静默丢失」 */
|
|
108
|
+
export const MAX_FILE_SECTION_WARNINGS_PER_SECTION = 5;
|
|
109
|
+
|
|
110
|
+
/** 二进制探测窗口(前 4 KiB 出现 NUL 即视为二进制,不按文本扫描) */
|
|
111
|
+
const BINARY_SNIFF_BYTES = 4096;
|
|
112
|
+
|
|
113
|
+
/** 是否为二进制内容(含 NUL 字节 → 不是 UTF-8 文本;避免对二进制做无意义的文本扫描/误报) */
|
|
114
|
+
function looksBinary(bytes: Uint8Array): boolean {
|
|
115
|
+
const n = Math.min(bytes.length, BINARY_SNIFF_BYTES);
|
|
116
|
+
for (let i = 0; i < n; i++) {
|
|
117
|
+
if (bytes[i] === 0) return true;
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 文件类分区内容扫描(G-09):
|
|
124
|
+
* - 走注入 scanner 的 `scanText`(只报告不改写;`scanAndRedact` 会剥离值,**不得**用于用户文件);
|
|
125
|
+
* - 扫描档位与 `src/market/prepare.ts` 的导出侧扫描保持一致:默认保守档 +
|
|
126
|
+
* 值形状启发式(`valuePatterns` 默认开),**不启用** `highEntropy`(默认关,误报率高);
|
|
127
|
+
* - 二进制文件(含 NUL)跳过:文本扫描对它无意义且必然误报;
|
|
128
|
+
* - 单文件上限 `FILE_SECTION_SCAN_MAX_BYTES`、累计上限 `FILE_SECTION_SCAN_TOTAL_BUDGET_BYTES`:
|
|
129
|
+
* 超限即停止扫描(不是停止导出),被跳过的部分不产生命中也不产生告警;
|
|
130
|
+
* - scanner 未实现 `scanText`(如 core 内置的字段名黑名单 defaultSecretScanner)→ 返回空,
|
|
131
|
+
* 行为与修复前一致(生产路径注入的是含 scanText 的强化扫描器)。
|
|
132
|
+
*
|
|
133
|
+
* 返回命中清单,`path` 为「分区内文件相对路径」,`field` 为 `line:N`(值永不外泄)。
|
|
134
|
+
*/
|
|
135
|
+
function scanFileSectionText(scanner: SecretScanner, data: unknown): SensitiveHit[] {
|
|
136
|
+
const scanText = scanner.scanText;
|
|
137
|
+
if (typeof scanText !== 'function') return [];
|
|
138
|
+
const files = extractFileEntries(data);
|
|
139
|
+
if (files === null) return [];
|
|
140
|
+
|
|
141
|
+
const hits: SensitiveHit[] = [];
|
|
142
|
+
let budget = FILE_SECTION_SCAN_TOTAL_BUDGET_BYTES;
|
|
143
|
+
for (const file of files) {
|
|
144
|
+
if (budget <= 0) break;
|
|
145
|
+
const bytes = file.data;
|
|
146
|
+
if (!(bytes instanceof Uint8Array) || bytes.length === 0) continue;
|
|
147
|
+
const window = bytes.subarray(0, Math.min(bytes.length, FILE_SECTION_SCAN_MAX_BYTES, budget));
|
|
148
|
+
budget -= window.length;
|
|
149
|
+
if (looksBinary(window)) continue;
|
|
150
|
+
let text: string;
|
|
151
|
+
try {
|
|
152
|
+
text = new TextDecoder('utf-8').decode(window);
|
|
153
|
+
} catch {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
for (const hit of scanText(text)) {
|
|
157
|
+
hits.push({ path: file.relativePath, field: hit.path });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return hits;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 从分区数据里取出文件条目(非文件类/结构异常 → null;只看 files 数组,不展开其他字段) */
|
|
164
|
+
function extractFileEntries(data: unknown): { relativePath: string; data: Uint8Array }[] | null {
|
|
165
|
+
if (data === null || typeof data !== 'object') return null;
|
|
166
|
+
const files = (data as { files?: unknown }).files;
|
|
167
|
+
if (!Array.isArray(files)) return null;
|
|
168
|
+
return files.filter((f): f is { relativePath: string; data: Uint8Array } => (
|
|
169
|
+
f !== null && typeof f === 'object'
|
|
170
|
+
&& typeof (f as { relativePath?: unknown }).relativePath === 'string'
|
|
171
|
+
&& (f as { data?: unknown }).data instanceof Uint8Array
|
|
172
|
+
));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export class Exporter {
|
|
176
|
+
private readonly ctx: HostContext;
|
|
177
|
+
private readonly adapters: ConfigAdapter[];
|
|
178
|
+
private readonly scanner: SecretScanner;
|
|
179
|
+
private readonly encryption: EncryptionProvider | null;
|
|
180
|
+
private readonly exporterVersion: string;
|
|
181
|
+
private readonly now: () => Date;
|
|
182
|
+
private readonly msg: MsgFunc;
|
|
183
|
+
private readonly onSection: ((info: SectionProgress) => void) | undefined;
|
|
184
|
+
private readonly vaultDataDir: string;
|
|
185
|
+
|
|
186
|
+
constructor(opts: ExporterOptions) {
|
|
187
|
+
this.ctx = opts.ctx;
|
|
188
|
+
this.adapters = opts.adapters;
|
|
189
|
+
this.scanner = opts.scanner ?? defaultSecretScanner();
|
|
190
|
+
this.encryption = opts.encryption ?? null;
|
|
191
|
+
this.exporterVersion = opts.exporterVersion ?? '0.1.0';
|
|
192
|
+
this.now = opts.now ?? (() => new Date());
|
|
193
|
+
this.msg = opts.msg ?? msgOf(opts.ctx);
|
|
194
|
+
this.onSection = opts.onSection;
|
|
195
|
+
this.vaultDataDir = opts.vaultDataDir ?? path.join(opts.ctx.homeDir, 'dsh-config-manager');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 导出:收集 → 过滤 → checksum → manifest → ZIP。
|
|
200
|
+
* 返回 zipPath(含文件名)、manifest、报告。
|
|
201
|
+
*/
|
|
202
|
+
async export(options: ExportOptions): Promise<{ zipPath: string; manifest: Manifest; report: ExportReport }> {
|
|
203
|
+
const { includeSecrets, only } = options;
|
|
204
|
+
if (includeSecrets && !this.encryption) {
|
|
205
|
+
throw new Error(this.msg('export.encryptionRequired'));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 1. 选定分区(only 过滤 + 默认包含)
|
|
209
|
+
const selected = this.adapters
|
|
210
|
+
.filter((a) => (only === undefined ? a.defaultIncluded : only.includes(a.id)))
|
|
211
|
+
.map((a) => a.id);
|
|
212
|
+
|
|
213
|
+
// 2. 逐 adapter 收集(导出数据)
|
|
214
|
+
const sections: ExportSection[] = [];
|
|
215
|
+
const warnings: string[] = [];
|
|
216
|
+
const redactedHits: SensitiveHit[] = [];
|
|
217
|
+
const included: ExportReport['included'] = [];
|
|
218
|
+
const excluded: SectionId[] = this.adapters.filter((a) => !selected.includes(a.id)).map((a) => a.id);
|
|
219
|
+
|
|
220
|
+
// m1 埋点:每导出一个分区前上报真实进度(onSection 抛错不得中断导出)
|
|
221
|
+
let sectionIndex = 0;
|
|
222
|
+
for (const adapter of this.adapters) {
|
|
223
|
+
if (!selected.includes(adapter.id)) continue;
|
|
224
|
+
sectionIndex += 1;
|
|
225
|
+
try {
|
|
226
|
+
this.onSection?.({ section: adapter.id, index: sectionIndex, total: selected.length });
|
|
227
|
+
} catch {
|
|
228
|
+
// 埋点回调失败不影响导出本身(进度是尽力而为)
|
|
229
|
+
}
|
|
230
|
+
let section: ExportSection;
|
|
231
|
+
try {
|
|
232
|
+
section = await adapter.export(this.ctx, options);
|
|
233
|
+
} catch (err) {
|
|
234
|
+
// 单个分区失败不拖垮整体(§34.17);如实告警并跳过
|
|
235
|
+
warnings.push(this.msg('export.sectionFailed', { adapter: adapter.id, reason: err instanceof Error ? err.message : String(err) }));
|
|
236
|
+
excluded.push(adapter.id);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
// 3. Secret 过滤:结构化数据逐一过 scanner(剥离值);
|
|
240
|
+
// 文件类分区(skills/agentPresets/agentInstructions/pluginFiles/sessions/self)是用户的真实文件,
|
|
241
|
+
// 只做**文本级扫描 + 告警**,绝不改写/剥离内容(见 scanFileSectionText)。
|
|
242
|
+
let sanitized = section.data;
|
|
243
|
+
if (!isFileSection(adapter.id)) {
|
|
244
|
+
const scanned = this.scanner.scanAndRedact(section.data);
|
|
245
|
+
redactedHits.push(...scanned.hits);
|
|
246
|
+
sanitized = scanned.sanitized;
|
|
247
|
+
} else {
|
|
248
|
+
const fileHits = scanFileSectionText(this.scanner, section.data);
|
|
249
|
+
if (fileHits.length > 0) {
|
|
250
|
+
// redactedHits 是**报告统计**通道(非告警通道):仍计入全量命中(含同一文件的多行/多形态命中)。
|
|
251
|
+
redactedHits.push(...fileHits);
|
|
252
|
+
// 告警按**文件**去重(G-09/H1):同一路径只告警一次。
|
|
253
|
+
// 修复前按 hit 计数,同一行同时命中「字段名」与「值形状」会产出两条同路径告警,
|
|
254
|
+
// 使少数文件就吃满上限,导致含真实明文凭据的其它文件被静默淹没(实测 redactedHits=8 而 5 条告警全属一个文件)。
|
|
255
|
+
const hitPaths: string[] = [];
|
|
256
|
+
const seenPaths = new Set<string>();
|
|
257
|
+
for (const hit of fileHits) {
|
|
258
|
+
if (seenPaths.has(hit.path)) continue;
|
|
259
|
+
seenPaths.add(hit.path);
|
|
260
|
+
hitPaths.push(hit.path);
|
|
261
|
+
}
|
|
262
|
+
// 上限语义 = 不同**文件**数 ≤ MAX_FILE_SECTION_WARNINGS_PER_SECTION(避免大分区刷屏)
|
|
263
|
+
const warnedPaths = hitPaths.slice(0, MAX_FILE_SECTION_WARNINGS_PER_SECTION);
|
|
264
|
+
for (const hitPath of warnedPaths) {
|
|
265
|
+
warnings.push(this.msg('export.fileSectionSecrets', { section: adapter.id, path: hitPath }));
|
|
266
|
+
}
|
|
267
|
+
// 被截断的文件数必须**显式**汇总告警,否则截断本身又变成静默丢失。
|
|
268
|
+
// 说明:此处未新增消息键(如 export.fileSectionSecretsTruncated)是为避免与并行任务冲突
|
|
269
|
+
// (src/core/messages.ts 属他人改动范围);故复用 export.fileSectionSecrets,仅 path 传汇总文案。
|
|
270
|
+
const truncatedCount = hitPaths.length - warnedPaths.length;
|
|
271
|
+
if (truncatedCount > 0) {
|
|
272
|
+
warnings.push(this.msg('export.fileSectionSecrets', { section: adapter.id, path: `(另有 ${truncatedCount} 个文件命中,详见报告)` }));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
sections.push({ ...section, data: sanitized });
|
|
277
|
+
included.push({ section: adapter.id, counts: section.counts });
|
|
278
|
+
warnings.push(...section.warnings);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// 4. 组装 ZIP 条目(JSON 分区 + 文件类分区 + secrets.enc + checksums + manifest)
|
|
282
|
+
const entries: { name: string; data: Uint8Array }[] = [];
|
|
283
|
+
const sectionFlags = buildSectionFlags(sections);
|
|
284
|
+
let containsSecrets = false;
|
|
285
|
+
let encrypted = false;
|
|
286
|
+
let encryption: Manifest['security']['encryption'] = null;
|
|
287
|
+
|
|
288
|
+
for (const section of sections) {
|
|
289
|
+
if (isFileSection(section.sectionId)) {
|
|
290
|
+
const prefix = SECTION_FILE_PREFIXES[section.sectionId]!;
|
|
291
|
+
const files = (section.data as { files?: { relativePath: string; data: Uint8Array }[] }).files ?? [];
|
|
292
|
+
for (const file of files) {
|
|
293
|
+
entries.push({ name: `${prefix}${file.relativePath}`, data: file.data });
|
|
294
|
+
}
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const jsonPath = SECTION_JSON_PATHS[section.sectionId];
|
|
298
|
+
if (jsonPath === undefined) continue;
|
|
299
|
+
entries.push({
|
|
300
|
+
name: jsonPath,
|
|
301
|
+
data: Buffer.from(stringifyJsonSafe(section.data, { space: 2 }), 'utf8'),
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// secrets.enc:有加密提供者即生成。includeSecrets=true 时加密真实的凭据原文;
|
|
306
|
+
// 只勾选加密(不导出密钥)时加密空内容占位,备份仍标记 encrypted(导入需密码),
|
|
307
|
+
// 但绝不把凭据值放进去(containsSecrets 保持 false;安全不变量不破)。
|
|
308
|
+
if (this.encryption) {
|
|
309
|
+
const credentialsFile = path.join(this.ctx.homeDir, '.credentials.yaml');
|
|
310
|
+
let plaintext: string;
|
|
311
|
+
if (includeSecrets) {
|
|
312
|
+
try {
|
|
313
|
+
const raw = await this.ctx.fs.readFile(credentialsFile);
|
|
314
|
+
plaintext = Buffer.from(raw).toString('utf8');
|
|
315
|
+
} catch (err) {
|
|
316
|
+
warnings.push(this.msg('export.credentialsReadFailed', { reason: err instanceof Error ? err.message : String(err) }));
|
|
317
|
+
plaintext = '';
|
|
318
|
+
}
|
|
319
|
+
} else {
|
|
320
|
+
plaintext = '';
|
|
321
|
+
}
|
|
322
|
+
const result = await this.encryption.encrypt(plaintext);
|
|
323
|
+
entries.push({ name: 'security/secrets.enc', data: result.blob });
|
|
324
|
+
encryption = result.info;
|
|
325
|
+
containsSecrets = includeSecrets && plaintext !== '';
|
|
326
|
+
encrypted = true;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// 4b. 文件级 vault(includeSecrets=false:敏感文件明文不进归档 → 镜像到本机 vault)。
|
|
330
|
+
// 尽力而为:任何失败仅记警告,不中断导出。includeSecrets=true 时秘密已加密进归档,
|
|
331
|
+
// 无需镜像(vault 只服务于「明文不进备份」的本机留存场景)。
|
|
332
|
+
let vaultRefreshed = 0;
|
|
333
|
+
if (!includeSecrets) {
|
|
334
|
+
try {
|
|
335
|
+
const vault = await refreshVault(this.ctx.fs, this.vaultDataDir, this.ctx.homeDir, DEFAULT_SENSITIVE_RELS);
|
|
336
|
+
vaultRefreshed = vault.mirrored.length;
|
|
337
|
+
if (vault.mirrored.length > 0) {
|
|
338
|
+
warnings.push(this.msg('export.vaultRefreshed', { count: vault.mirrored.length }));
|
|
339
|
+
}
|
|
340
|
+
for (const s of vault.skipped) {
|
|
341
|
+
warnings.push(this.msg('export.vaultRefreshSkipped', { rel: s.rel, reason: s.reason }));
|
|
342
|
+
}
|
|
343
|
+
} catch (err) {
|
|
344
|
+
warnings.push(this.msg('export.vaultRefreshFailed', { reason: err instanceof Error ? err.message : String(err) }));
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// 5. checksums(覆盖除 manifest/checksums 外的全部条目)
|
|
349
|
+
const checksums = buildChecksums(entries);
|
|
350
|
+
entries.push({ name: CHECKSUMS_FILE, data: Buffer.from(stringifyJsonSafe(checksums, { space: 2 }), 'utf8') });
|
|
351
|
+
|
|
352
|
+
// 6. manifest(最后写:需要完整分区与安全信息)
|
|
353
|
+
const manifest = buildManifest({
|
|
354
|
+
exporterVersion: this.exporterVersion,
|
|
355
|
+
dshVersion: this.ctx.dshVersion,
|
|
356
|
+
platform: this.ctx.platform as Manifest['source']['platform'],
|
|
357
|
+
arch: this.ctx.arch,
|
|
358
|
+
sections: sectionFlags,
|
|
359
|
+
containsSecrets,
|
|
360
|
+
encrypted,
|
|
361
|
+
encryption,
|
|
362
|
+
exportedAt: this.now().toISOString(),
|
|
363
|
+
});
|
|
364
|
+
entries.push({ name: MANIFEST_FILE, data: Buffer.from(stringifyJsonSafe(manifest, { space: 2 }), 'utf8') });
|
|
365
|
+
|
|
366
|
+
// 7. 落盘
|
|
367
|
+
const outPath = options.outPath ?? defaultOutPath(this.now());
|
|
368
|
+
await writeZip(outPath, entries);
|
|
369
|
+
const stat = await fs.stat(outPath);
|
|
370
|
+
|
|
371
|
+
// 日志不泄值:只记分区与命中数量
|
|
372
|
+
this.ctx.log.info('导出完成', {
|
|
373
|
+
file: path.basename(outPath),
|
|
374
|
+
sizeBytes: stat.size,
|
|
375
|
+
sections: Object.keys(sectionFlags).filter((k) => sectionFlags[k as SectionId]),
|
|
376
|
+
redactedFields: redactedHits.length,
|
|
377
|
+
containsSecrets,
|
|
378
|
+
encrypted,
|
|
379
|
+
vaultRefreshed,
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
const report: ExportReport = {
|
|
383
|
+
included,
|
|
384
|
+
excluded,
|
|
385
|
+
security: {
|
|
386
|
+
secretsExcluded: !includeSecrets,
|
|
387
|
+
containsSecrets,
|
|
388
|
+
encrypted,
|
|
389
|
+
redactedHits: redactedHits.length,
|
|
390
|
+
vaultRefreshed,
|
|
391
|
+
},
|
|
392
|
+
file: { name: path.basename(outPath), sizeBytes: stat.size },
|
|
393
|
+
warnings,
|
|
394
|
+
};
|
|
395
|
+
return { zipPath: outPath, manifest, report };
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** 构建 manifest.sections 布尔表(只含实际导出分区) */
|
|
400
|
+
function buildSectionFlags(sections: ExportSection[]): Manifest['sections'] {
|
|
401
|
+
const flags = {} as Manifest['sections'];
|
|
402
|
+
for (const id of Object.keys(SECTION_JSON_PATHS) as SectionId[]) flags[id] = false;
|
|
403
|
+
for (const id of Object.keys(SECTION_FILE_PREFIXES) as SectionId[]) flags[id] = false;
|
|
404
|
+
flags['secrets'] = false;
|
|
405
|
+
for (const section of sections) flags[section.sectionId] = true;
|
|
406
|
+
return flags;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function defaultOutPath(now: Date): string {
|
|
410
|
+
const y = now.getFullYear();
|
|
411
|
+
const m = String(now.getMonth() + 1).padStart(2, '0');
|
|
412
|
+
const d = String(now.getDate()).padStart(2, '0');
|
|
413
|
+
return `dsh-config-${y}-${m}-${d}.zip`;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** 供报告使用:导出器身份(避免与 manifest 常量重复维护) */
|
|
417
|
+
export const EXPORTER_INFO = { name: EXPORTER_NAME };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 幽灵会话检测纯函数单测(F5 失效归档清理)。
|
|
3
|
+
* 覆盖:sessionKeyOf 归一化(Unix/Windows 分隔符、缺段、空串)、
|
|
4
|
+
* sweepGhostSessions 缺失/存在/混合/去重/磁盘多余会话不算幽灵、
|
|
5
|
+
* expectedSessionRefs 只取 sessions 分区 existed=true 的 file 条目。
|
|
6
|
+
*/
|
|
7
|
+
import test from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
|
|
10
|
+
import { expectedSessionRefs, sessionKeyOf, sweepGhostSessions } from './ghost-sweep.ts';
|
|
11
|
+
import type { SnapshotEntry } from './types.ts';
|
|
12
|
+
|
|
13
|
+
test('G-01 sessionKeyOf:归一为 <projectKey>/<sessionId>', () => {
|
|
14
|
+
assert.equal(sessionKeyOf('proj-a/s1/session.jsonl.zstd'), 'proj-a/s1');
|
|
15
|
+
assert.equal(sessionKeyOf('proj-a/s1/meta.json'), 'proj-a/s1');
|
|
16
|
+
assert.equal(sessionKeyOf('proj-a/s1'), 'proj-a/s1', '两段路径自身就是目录键');
|
|
17
|
+
assert.equal(sessionKeyOf('proj-a/s1/sub/deep.jsonl'), 'proj-a/s1', '更深层路径取前两段');
|
|
18
|
+
assert.equal(sessionKeyOf('a\\b\\c.jsonl'), 'a/b', 'Windows 分隔符归一为 /');
|
|
19
|
+
assert.equal(sessionKeyOf('a//b///c'), 'a/b', '连续分隔符合并');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('G-02 sessionKeyOf:无法归一会话目录 → null', () => {
|
|
23
|
+
assert.equal(sessionKeyOf(''), null);
|
|
24
|
+
assert.equal(sessionKeyOf('proj-a'), null, '单段(项目级)无法归一会话');
|
|
25
|
+
assert.equal(sessionKeyOf('/'), null);
|
|
26
|
+
assert.equal(sessionKeyOf(' '), null);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('G-03 sweepGhostSessions:全部存在 → 无幽灵', () => {
|
|
30
|
+
const ghosts = sweepGhostSessions(
|
|
31
|
+
['proj-a/s1/session.jsonl.zstd', 'proj-b/s2/session.jsonl.zstd'],
|
|
32
|
+
['proj-a/s1/session.jsonl.zstd', 'proj-b/s2/meta.json'],
|
|
33
|
+
);
|
|
34
|
+
assert.deepEqual(ghosts, []);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('G-04 sweepGhostSessions:全部缺失(磁盘空)→ 全部幽灵', () => {
|
|
38
|
+
const ghosts = sweepGhostSessions(
|
|
39
|
+
['proj-a/s1/session.jsonl.zstd', 'proj-b/s2/session.jsonl.zstd'],
|
|
40
|
+
[],
|
|
41
|
+
);
|
|
42
|
+
assert.deepEqual(ghosts.sort(), ['proj-a/s1', 'proj-b/s2'].sort());
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('G-05 sweepGhostSessions:混合 —— 只有磁盘缺失的会话算幽灵', () => {
|
|
46
|
+
const ghosts = sweepGhostSessions(
|
|
47
|
+
['proj-a/s1/session.jsonl.zstd', 'proj-b/s2/session.jsonl.zstd', 'proj-c/s3/session.jsonl.zstd'],
|
|
48
|
+
['proj-a/s1/session.jsonl.zstd'],
|
|
49
|
+
);
|
|
50
|
+
assert.deepEqual(ghosts.sort(), ['proj-b/s2', 'proj-c/s3'].sort());
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('G-06 sweepGhostSessions:磁盘多余会话(备份后新建)不算幽灵', () => {
|
|
54
|
+
const ghosts = sweepGhostSessions(
|
|
55
|
+
['proj-a/s1/session.jsonl.zstd'],
|
|
56
|
+
['proj-a/s1/session.jsonl.zstd', 'proj-x/new-sess/session.jsonl.zstd'],
|
|
57
|
+
);
|
|
58
|
+
assert.deepEqual(ghosts, []);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('G-07 sweepGhostSessions:目录键粒度匹配 —— 会话目录尚存即不算幽灵', () => {
|
|
62
|
+
const ghosts = sweepGhostSessions(
|
|
63
|
+
['proj-a/s1/session.jsonl.zstd', 'proj-b/s2/session.jsonl.zstd'],
|
|
64
|
+
['proj-a/s1', 'proj-b/s2/meta.json'],
|
|
65
|
+
);
|
|
66
|
+
assert.deepEqual(ghosts, [], '磁盘出现会话目录(即使部分文件缺失)即视为会话存在');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('G-08 sweepGhostSessions:去重保序 + Windows 分隔符磁盘条目', () => {
|
|
70
|
+
const ghosts = sweepGhostSessions(
|
|
71
|
+
['proj-a/s1/a.jsonl', 'proj-a/s1/b.jsonl', 'proj-b/s2/x.jsonl'],
|
|
72
|
+
['proj-b\\s2\\x.jsonl'],
|
|
73
|
+
);
|
|
74
|
+
assert.deepEqual(ghosts, ['proj-a/s1'], '同一会话多个文件缺失只报一次');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('G-09 sweepGhostSessions:无法归一(单段/空)的期望条目不参与判定', () => {
|
|
78
|
+
const ghosts = sweepGhostSessions(
|
|
79
|
+
['proj-a', 'proj-b/s2/session.jsonl.zstd', ''],
|
|
80
|
+
[],
|
|
81
|
+
);
|
|
82
|
+
assert.deepEqual(ghosts, ['proj-b/s2']);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('G-10 expectedSessionRefs:只取 sessions 分区 existed=true 的 file 条目', () => {
|
|
86
|
+
const entries: SnapshotEntry[] = [
|
|
87
|
+
{ kind: 'file', adapter: 'sessions', ref: 'proj-a/s1/session.jsonl.zstd', before: null, existed: true, copiedTo: 'blobs/x' },
|
|
88
|
+
{ kind: 'file', adapter: 'sessions', ref: 'proj-b/s2/session.jsonl.zstd', before: null, existed: false },
|
|
89
|
+
{ kind: 'file', adapter: 'skills', ref: 'coding.md', before: null, existed: true, copiedTo: 'blobs/y' },
|
|
90
|
+
{ kind: 'settingsNamespace', adapter: 'settings', ref: 'general', before: null, revision: 1, existed: true },
|
|
91
|
+
];
|
|
92
|
+
assert.deepEqual(expectedSessionRefs(entries), ['proj-a/s1/session.jsonl.zstd']);
|
|
93
|
+
assert.deepEqual(expectedSessionRefs([]), []);
|
|
94
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 幽灵会话检测(F5 失效归档清理,对应 dsh-backup-sync 的 sweepArchives 设计)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:会话日志被删除 / 恢复覆盖后,DSH 的归档列表会产生「幽灵归档」——归档 ID
|
|
5
|
+
* 指向已不存在的会话日志,恢复后应识别这些失效归档。
|
|
6
|
+
*
|
|
7
|
+
* dsh-backup-sync 走 ctx.workspaceRegistry.archivedSessionIds + unarchiveSession;
|
|
8
|
+
* 本仓库 HostContext(core/types.ts)未暴露该能力(workspace facade 只有
|
|
9
|
+
* WorkspaceRecord 的 list/write/remove),故按仓库纪律降级为「本地校验」:
|
|
10
|
+
* 把快照/备份记录的会话文件清单与磁盘 sessions 目录实际文件对比,找出
|
|
11
|
+
* 「备份里有、磁盘上已不存在的会话」(幽灵),报告给用户人工确认清理。
|
|
12
|
+
*
|
|
13
|
+
* 本模块只含纯函数(零 IO、零 DSH 运行时依赖),供 restore.ts 与测试使用;
|
|
14
|
+
* 磁盘扫描等副作用放在 restore.ts 内部。
|
|
15
|
+
*/
|
|
16
|
+
import type { SectionId } from '../schema/types.ts';
|
|
17
|
+
import type { SnapshotEntry } from './types.ts';
|
|
18
|
+
|
|
19
|
+
/** 会话文件目录键提取:把会话相对路径归一为会话目录键 <projectKey>/<sessionId>。
|
|
20
|
+
* 分隔符统一为 '/'(兼容 Windows '\');不足两段(无法归一会话目录)返回 null。 */
|
|
21
|
+
export function sessionKeyOf(relPath: string): string | null {
|
|
22
|
+
if (typeof relPath !== 'string' || relPath === '') return null;
|
|
23
|
+
const norm = relPath.replace(/\\/g, '/').replace(/\/{2,}/g, '/').replace(/^\/+|\/+$/g, '');
|
|
24
|
+
if (norm === '') return null;
|
|
25
|
+
const segments = norm.split('/');
|
|
26
|
+
if (segments.length < 2) return null;
|
|
27
|
+
return `${segments[0]}/${segments[1]}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 幽灵会话检测(纯函数):
|
|
32
|
+
* 期望存在的会话(快照/备份记录的会话条目)在磁盘 sessions 目录已无任何对应文件 → 幽灵。
|
|
33
|
+
*
|
|
34
|
+
* @param sessionIds 快照/备份记录的会话条目(相对 sessions 基准目录的路径,如
|
|
35
|
+
* 'proj-a/s1/session.jsonl.zstd' 或目录键 'proj-a/s1')
|
|
36
|
+
* @param diskEntries 磁盘 sessions 目录扫描出的实际相对路径(同样相对 sessions 基准目录,
|
|
37
|
+
* 分隔符不限 Windows/Unix)
|
|
38
|
+
* @returns 失效(幽灵)会话键清单(<projectKey>/<sessionId>,去重保序);
|
|
39
|
+
* 仅磁盘有、备份没有的会话不是幽灵(属备份后新建),不返回。
|
|
40
|
+
*/
|
|
41
|
+
export function sweepGhostSessions(sessionIds: string[], diskEntries: string[]): string[] {
|
|
42
|
+
const disk = new Set<string>();
|
|
43
|
+
for (const d of diskEntries) {
|
|
44
|
+
const key = sessionKeyOf(d);
|
|
45
|
+
if (key !== null) disk.add(key);
|
|
46
|
+
}
|
|
47
|
+
const ghosts: string[] = [];
|
|
48
|
+
const seen = new Set<string>();
|
|
49
|
+
for (const id of sessionIds) {
|
|
50
|
+
const key = sessionKeyOf(id);
|
|
51
|
+
if (key === null) continue; // 无法归一会话目录的条目不参与判定
|
|
52
|
+
if (!disk.has(key) && !seen.has(key)) {
|
|
53
|
+
seen.add(key);
|
|
54
|
+
ghosts.push(key);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return ghosts;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 从快照条目中提取「恢复后应存在于磁盘」的会话文件相对路径(sessions 分区、existed=true)。
|
|
61
|
+
* 仅参与过导入且快照时存在的会话文件才算期望项;existed=false(导入新增、恢复时删除)不算。 */
|
|
62
|
+
export function expectedSessionRefs(entries: SnapshotEntry[]): string[] {
|
|
63
|
+
return entries
|
|
64
|
+
.filter((e): e is SnapshotEntry & { kind: 'file' } =>
|
|
65
|
+
e.kind === 'file' && e.adapter === ('sessions' as SectionId) && e.existed === true)
|
|
66
|
+
.map((e) => e.ref);
|
|
67
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Importer 门面:封装导入 14 步流程(规范 §9),委托 Analyzer 三段式。
|
|
3
|
+
*
|
|
4
|
+
* 1 选 ZIP → 2 校验 ZIP → 3 读 manifest → 4 完整性 → 5 schema → 6 兼容
|
|
5
|
+
* → 7 扫描 → 8 ImportPlan → 9 Preview(Dry Run) → 10 用户确认
|
|
6
|
+
* → 11 快照 → 12 执行 → 13 校验 → 14 结果(失败则回滚)
|
|
7
|
+
*
|
|
8
|
+
* 安全阀:executeImportPlan 必须显式 confirm,否则拒绝(ImportNotConfirmedError)。
|
|
9
|
+
* Dry Run:analyzeImport / createImportPlan 零写入(ZIP 全程内存解析,不进磁盘)。
|
|
10
|
+
*/
|
|
11
|
+
import type { ZipArchive, ZipSafetyLimits } from '../utils/zip.ts';
|
|
12
|
+
import type { MsgFunc } from './messages.ts';
|
|
13
|
+
import { Analyzer } from './analyzer.ts';
|
|
14
|
+
import type { PlanItemProgress } from './analyzer.ts';
|
|
15
|
+
import type {
|
|
16
|
+
ConfigAdapter, HostContext, ImportAnalysis, ImportDecisions, ImportPlan,
|
|
17
|
+
ImportResult, SnapshotStore, TransactionSnapshotContext,
|
|
18
|
+
} from './types.ts';
|
|
19
|
+
import type { SectionId } from '../schema/types.ts';
|
|
20
|
+
|
|
21
|
+
export interface ImporterOptions {
|
|
22
|
+
ctx: HostContext;
|
|
23
|
+
adapters: ConfigAdapter[];
|
|
24
|
+
snapshotStore: SnapshotStore;
|
|
25
|
+
limits?: ZipSafetyLimits;
|
|
26
|
+
/** 依赖存在性检查器(缺省不检查) */
|
|
27
|
+
dependencyChecker?: (command: string) => Promise<boolean>;
|
|
28
|
+
/** m4 可注入强化版 ZIP 安全解析 */
|
|
29
|
+
parseZipOverride?: (buf: Uint8Array, limits?: ZipSafetyLimits) => ZipArchive;
|
|
30
|
+
/** 消息翻译器(缺省 ctx.msg ?? zh) */
|
|
31
|
+
msg?: MsgFunc;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ExecuteOptions {
|
|
35
|
+
/** 用户确认(UI 收集);非 true 一律拒绝执行 */
|
|
36
|
+
confirm: boolean;
|
|
37
|
+
/** 用户补录的秘密值(仅内存) */
|
|
38
|
+
secretInputs?: Record<string, string>;
|
|
39
|
+
/** 加密备份解密结果(仅内存;解密必须经 m4 encryption provider) */
|
|
40
|
+
decryptedCredentials?: Map<string, string>;
|
|
41
|
+
/** 任一项失败立即整体回滚(默认 false:单项失败如实记录并继续其余项) */
|
|
42
|
+
rollbackOnError?: boolean;
|
|
43
|
+
/** m1:每完成一个计划项的进度回调(透传给 Analyzer;不传则无埋点) */
|
|
44
|
+
onItem?: (info: PlanItemProgress) => void;
|
|
45
|
+
/** m1:每开始一个计划项的进度回调(供 UI 显示「正在执行项 X」/ 判定跳过按钮;不传则无埋点) */
|
|
46
|
+
onItemStart?: (info: { adapter: SectionId; index: number; total: number; detail: string }) => void;
|
|
47
|
+
/** 执行日志回调(逐计划项操作 + 子进程命令行,仅非敏感文本;透传给 Analyzer → ImportContext) */
|
|
48
|
+
onLog?: (line: string) => void;
|
|
49
|
+
/** Phase 4 生产 journal↔snapshot 绑定(deferred;透传给 Analyzer;不传 = 无 journal 绑定) */
|
|
50
|
+
snapshotBinding?: TransactionSnapshotContext;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class Importer {
|
|
54
|
+
private readonly analyzer: Analyzer;
|
|
55
|
+
|
|
56
|
+
constructor(opts: ImporterOptions) {
|
|
57
|
+
this.analyzer = new Analyzer(opts);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 步骤 1-8 + 分析:只读,返回 ImportAnalysis(含兼容性/路径/依赖/秘密摘要) */
|
|
61
|
+
analyzeImport(zipPath: string): Promise<ImportAnalysis> {
|
|
62
|
+
return this.analyzer.analyzeImport(zipPath);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 步骤 9:Dry Run / Preview —— 合并用户冲突决策与路径映射,输出最终可执行计划(零写入) */
|
|
66
|
+
createImportPlan(zipPath: string, decisions: ImportDecisions): Promise<ImportPlan> {
|
|
67
|
+
return this.analyzer.createImportPlan(zipPath, decisions);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 步骤 10-14:确认 → 快照 → 执行 → 校验 → 结果;失败可整体回滚并如实报告 */
|
|
71
|
+
executeImportPlan(zipPath: string, plan: ImportPlan, opts: ExecuteOptions): Promise<ImportResult> {
|
|
72
|
+
return this.analyzer.executeImportPlan(zipPath, plan, {
|
|
73
|
+
confirm: opts.confirm,
|
|
74
|
+
secretInputs: opts.secretInputs,
|
|
75
|
+
decryptedCredentials: opts.decryptedCredentials,
|
|
76
|
+
rollbackOnError: opts.rollbackOnError,
|
|
77
|
+
onItem: opts.onItem,
|
|
78
|
+
onItemStart: opts.onItemStart,
|
|
79
|
+
onLog: opts.onLog,
|
|
80
|
+
snapshotBinding: opts.snapshotBinding,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|