@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
package/lib/index.js
ADDED
|
@@ -0,0 +1,2764 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-config-manager — host half.
|
|
3
|
+
*
|
|
4
|
+
* Mounts the backup / export / import engine (src/core Exporter + Importer
|
|
5
|
+
* three-stage flow) behind the `/api/dsh-config-manager/*` route family that
|
|
6
|
+
* the browser half (`./client`) calls, and wraps the real DSH host services
|
|
7
|
+
* into the engine's `HostContext` facade (src/core/types.ts):
|
|
8
|
+
*
|
|
9
|
+
* ctx.settings -> SettingsFacade (@deepseek-ai/dsh-settings)
|
|
10
|
+
* ctx.credentials -> CredentialsFacade (@deepseek-ai/dsh-credentials)
|
|
11
|
+
* ctx.plugins -> PluginsFacade (官方 dsh plugin CLI 通道 + profile 文件,
|
|
12
|
+
* 见 src/core/plugin-cli.ts)
|
|
13
|
+
* ctx.workspaceRegistry -> WorkspaceFacade (@deepseek-ai/dsh-workspace)
|
|
14
|
+
* ~/.dsh/cordis.patch.yml-> PatchFileFacade (js-yaml)
|
|
15
|
+
* $DSH_HOME files -> FileSystemFacade (node:fs, home-relative)
|
|
16
|
+
* resolveDshHome() -> homeDir (@deepseek-ai/dsh-home-paths)
|
|
17
|
+
*
|
|
18
|
+
* Security posture (mirrors the verified @linxin666/dsh-ssh@0.1.12 routes):
|
|
19
|
+
* - every route carries the loopback-only + same-origin trust fence
|
|
20
|
+
* (isLoopbackRequest); LAN-exposed deployments never serve these endpoints;
|
|
21
|
+
* - uploads/exported ZIPs are staged under $DSH_HOME/dsh-config-manager/{tmp,exports}
|
|
22
|
+
* and every `path`/`zipPath` reference is confined to those roots;
|
|
23
|
+
* - the encryption password is in-memory only: used to derive the AES-256-GCM
|
|
24
|
+
* key for secrets.enc, never written to any file, manifest, or log;
|
|
25
|
+
* - the import execute endpoint refuses to run without `confirm: true`
|
|
26
|
+
* (core ImportNotConfirmedError safety valve).
|
|
27
|
+
*
|
|
28
|
+
* Optional services are read with ctx.get() at call time (never injected), so
|
|
29
|
+
* the engine keeps working in profiles without the web-only workspace
|
|
30
|
+
* service; hard dependencies are the core `settings`/`credentials` services
|
|
31
|
+
* present in every profile. Plugin install/list no longer depend on the
|
|
32
|
+
* web-only pluginMarketplace/pluginInventory services: both go through the
|
|
33
|
+
* official `dsh plugin --profile <name>` CLI (pnpm forwarder) and read the
|
|
34
|
+
* profile's package.json / node_modules directly.
|
|
35
|
+
*/
|
|
36
|
+
import { randomBytes } from 'node:crypto';
|
|
37
|
+
import { execFile } from 'node:child_process';
|
|
38
|
+
import { createReadStream, createWriteStream, mkdirSync, readFileSync } from 'node:fs';
|
|
39
|
+
import fs from 'node:fs/promises';
|
|
40
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
41
|
+
import { promisify } from 'node:util';
|
|
42
|
+
import * as dshSettings from '@deepseek-ai/dsh-settings';
|
|
43
|
+
import * as dshCredentials from '@deepseek-ai/dsh-credentials';
|
|
44
|
+
import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths';
|
|
45
|
+
import * as yaml from 'js-yaml';
|
|
46
|
+
import { Exporter, FileSnapshotStore, Importer, verifySnapshot } from './core/index.js';
|
|
47
|
+
import { APPLY_ORDER, ProfileManager, isValidProfileName } from './profiles/index.js';
|
|
48
|
+
import { cleanupCaches } from './core/cache-cleaner.js';
|
|
49
|
+
import { deleteSnapshot, isValidSnapshotId, listSnapshots, planRestore, setSnapshotPinned, validateSnapshotForRestore } from './core/restore.js';
|
|
50
|
+
import { rollback as performRollback } from './core/rollback.js';
|
|
51
|
+
// Phase 1 P0-1/P0-2:配置生命周期(自动快照 / 撤销 / 重做)与 P0-5 崩溃归因。
|
|
52
|
+
// 监听工厂用真 fs.watch 注入(core 侧只依赖抽象,便于测试驱动时序)。
|
|
53
|
+
import { ConfigLifecycle } from './core/config-lifecycle.js';
|
|
54
|
+
import { deleteConfigSnapshot } from './core/config-snapshot.js';
|
|
55
|
+
import { adviceFor, beginBoot, computeBootAlert, listCandidateLogs, markBootOk, readBootState, readCrashLogTail, writeBootState, } from './core/crash-report.js';
|
|
56
|
+
// Phase 1 P0-3:启动救援模式(备份 patch/package.json → 写最小 patch → 中和 bundles)
|
|
57
|
+
import { enterRescueMode, exitRescueMode, rescueModeStatus } from './core/boot-rescue.js';
|
|
58
|
+
import { watch as fsWatch } from 'node:fs';
|
|
59
|
+
import { recomputeRecoveryDecision, executeRecovery } from './core/reconcile.js';
|
|
60
|
+
import { verifyRecovery, recoveryTerminalState } from './core/verify-recovery.js';
|
|
61
|
+
import { createRecoveryOrchestrator } from './core/recovery-orchestrator.js';
|
|
62
|
+
import { redactJournalText, isValidOperationId, isTerminalState, transitionJournalState, JournalStore } from './core/journal.js';
|
|
63
|
+
import { RunRegistry } from './core/run-registry.js';
|
|
64
|
+
import { registerModelTools } from './core/model-tools.js';
|
|
65
|
+
import { computeConsultReport } from './core/migration-consult.js';
|
|
66
|
+
import { readExportZipSource, buildLocalSnapshotSource, buildProfileSource } from './core/consult-source.js';
|
|
67
|
+
import { makeMsg, msgOf, zhMsg } from './core/messages.js';
|
|
68
|
+
import { cleanupAbortedInstall, hasDshBundlePatch, installErrorFor, installSpecFor, listInstalledPlugins, resolveProfileDir, resolveProfileNameFromArgv, readProfileManifest, runDshPlugin, validateProfileName, } from './core/plugin-cli.js';
|
|
69
|
+
import { ImportNotConfirmedError, ImportUserSkippedError } from './core/types.js';
|
|
70
|
+
import { createAdapters, USER_PATCH_FILE } from './adapters/index.js';
|
|
71
|
+
import { createLocalPluginPackHook } from './core/local-plugin-host.js';
|
|
72
|
+
import { createEncryptionProvider, decryptCredentials, decryptArchive, SecurityError, encryptArchive, isArchiveBlob, verifyEncryptedBlob } from './security/index.js';
|
|
73
|
+
import { createHardenedZipParser } from './security/zip-security.js';
|
|
74
|
+
import { atomicCopyFile, atomicWriteFile } from './utils/atomic-write.js';
|
|
75
|
+
import { EnvironmentLockManager, runWithMutationLock, EnvironmentLockUnavailableError } from './utils/env-lock.js';
|
|
76
|
+
import { activeProxySummary } from './utils/proxy.js';
|
|
77
|
+
import { listRecursiveFollowingLinks } from './utils/recursive-walk.js';
|
|
78
|
+
import { Phase3Recovery, TransactionRecoveryRequiredError, mapLockStateForStartup } from './core/phase3-host.js';
|
|
79
|
+
import { classifyStartup } from './core/startup-barrier.js';
|
|
80
|
+
import { GitTransport } from './sync/git/git-transport.js';
|
|
81
|
+
import { WebDavTransport } from './sync/webdav/webdav-transport.js';
|
|
82
|
+
import { DeviceFlowStore, GitHubAuthClient, GitHubAuthError } from './sync/github-auth.js';
|
|
83
|
+
import { SyncEngine } from './sync/sync-engine.js';
|
|
84
|
+
import { SyncSessionStore } from './sync/sync-session.js';
|
|
85
|
+
import { AutoSyncScheduler } from './sync/autosync-scheduler.js';
|
|
86
|
+
import { readAllAutosyncConfigs, readAutosyncConfig, writeAutosyncConfig } from './sync/autosync-config.js';
|
|
87
|
+
import { appendAutosyncEntry, readSyncHistory } from './sync/sync-history.js';
|
|
88
|
+
import { MigrationStore, queryHistory, summarizeHistory, renderExport, parseHistoryQuery, MIGRATION_HISTORY_DIR, } from './core/migration-history.js';
|
|
89
|
+
import { loadSyncState, saveSyncState } from './sync/sync-state.js';
|
|
90
|
+
import { readSyncConfig, readSyncConfigFor, readFullSyncConfig, writeSyncConfig, validateRepoUrl, validateWebDavUrl, isGitConfig, isWebDavConfig, } from './sync/sync-config.js';
|
|
91
|
+
import { defaultSyncSelection, effectiveSections, readAllSyncSelections, readSyncSelection, writeSyncSelection, SYNC_SELECTION_SCHEMA_VERSION, } from './sync/sync-selection.js';
|
|
92
|
+
import { readUiPrefs, updateUiPrefs } from './sync/ui-prefs.js';
|
|
93
|
+
import { redact } from './security/redaction.js';
|
|
94
|
+
import { createConfiguredSecretScanner } from './security/secret-scanner.js';
|
|
95
|
+
import { sha256Hex } from './utils/hashing.js';
|
|
96
|
+
import { MANIFEST_FILE, parseManifest } from './schema/manifest.js';
|
|
97
|
+
import { isFileSection, SECTION_IDS } from './schema/config.js';
|
|
98
|
+
import { stringifyJsonSafe } from './utils/json.js';
|
|
99
|
+
import { parseZip, zipToBuffer } from './utils/zip.js';
|
|
100
|
+
import { isSameOrChild, normalizePath } from './utils/paths.js';
|
|
101
|
+
import { createLogger } from './utils/logger.js';
|
|
102
|
+
/* ---------------------------------------------------------------- identity */
|
|
103
|
+
/** Stable cordis plugin name — must match the cordis.patch.yml row id. */
|
|
104
|
+
export const name = 'config-manager';
|
|
105
|
+
/** Services required before the engine can mount (present in every profile). */
|
|
106
|
+
export const inject = ['settings', 'credentials'];
|
|
107
|
+
/** Plugin version, kept in sync with package.json ("version"). */
|
|
108
|
+
const PLUGIN_VERSION = '0.1.60';
|
|
109
|
+
/** Plugin own package name — excluded from its own exported plugins list. */
|
|
110
|
+
const PLUGIN_NAME = 'dsh-config-manager';
|
|
111
|
+
/**
|
|
112
|
+
* Star 引导弹窗指向的 GitHub 仓库(用户引导点 Star 的目标)。
|
|
113
|
+
* 与 package.json 的 repository 字段保持一致;界面不可改(硬编码,参照
|
|
114
|
+
* 「一键上传」目标仓库先例)。仅在 GET /star-prompt 响应中返回,供弹窗按钮跳转。
|
|
115
|
+
*/
|
|
116
|
+
const STAR_PROMPT_REPO_URL = 'https://github.com/xiajiajun516/dsh-config-manager';
|
|
117
|
+
/** 缓存自动清理周期:24 小时(启动即清一次 + 此后每日一次;与 cache-cleaner 保留期独立) */
|
|
118
|
+
const CACHE_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
119
|
+
/**
|
|
120
|
+
* 内置 GitHub OAuth App 的 client_id(「使用 GitHub 登录」device flow 缺省值)。
|
|
121
|
+
* Client ID 是公开标识(GitHub 官方明确非机密):内置后所有安装者开箱即用,
|
|
122
|
+
* 无需各自注册/配置 OAuth App;token 仍按用户私有(各自授权、各自存 credentials)。
|
|
123
|
+
* 插件配置的 githubClientId 优先于本默认值(换自有 App 时覆盖)。
|
|
124
|
+
*/
|
|
125
|
+
export const DEFAULT_GITHUB_CLIENT_ID = 'Ov23liq4i7n8UsylGRfb';
|
|
126
|
+
/* ---------------------------------------------------------------- constants */
|
|
127
|
+
/** Route family — must match the browser half's CONFIG_MANAGER_API exactly. */
|
|
128
|
+
const API = {
|
|
129
|
+
// P2-⑫:导出前只读预览(不落盘 ZIP;返回各分区 counts + 估算大小)
|
|
130
|
+
// P1-⑧:快照管理(手动删除 + 置顶豁免自动清理)
|
|
131
|
+
// m-backup-schedule:定时全量备份(读/存 backup-schedule.json + 立即执行一次)
|
|
132
|
+
// m-backup-files:导出产物管理(列出 exports/*.zip + 删除;下载复用 /download)
|
|
133
|
+
// Phase 7:迁移前咨询(只读健康评分 + 建议;POST,loopback fence)
|
|
134
|
+
// m-sync-ui:远程同步(Git 私有仓库通道)
|
|
135
|
+
syncStatus: '/api/dsh-config-manager/sync/status',
|
|
136
|
+
syncPush: '/api/dsh-config-manager/sync/push',
|
|
137
|
+
syncPull: '/api/dsh-config-manager/sync/pull',
|
|
138
|
+
// m-github-oauth:GitHub OAuth device flow 登录(start → 展示授权码 → poll → token 入库)
|
|
139
|
+
syncGithubStart: '/api/dsh-config-manager/sync/github/start',
|
|
140
|
+
syncGithubPoll: '/api/dsh-config-manager/sync/github/poll',
|
|
141
|
+
syncGithubCancel: '/api/dsh-config-manager/sync/github/cancel',
|
|
142
|
+
// m-sync-github-valid:校验已存 token 是否有效(决定「已登录」→ 隐藏登录区块)
|
|
143
|
+
syncGithubValidate: '/api/dsh-config-manager/sync/github/validate',
|
|
144
|
+
// P2:同步历史 / 自动应用 / 一键回滚
|
|
145
|
+
syncHistory: '/api/dsh-config-manager/sync/history',
|
|
146
|
+
syncRollback: '/api/dsh-config-manager/sync/rollback',
|
|
147
|
+
// m-sync-v2:一键同步(差异确认会话)+ 自动同步 + 历史快照
|
|
148
|
+
syncSnapshotsList: '/api/dsh-config-manager/sync/snapshots-list',
|
|
149
|
+
syncSync: '/api/dsh-config-manager/sync/sync',
|
|
150
|
+
syncApplyItems: '/api/dsh-config-manager/sync/apply-items',
|
|
151
|
+
syncCancel: '/api/dsh-config-manager/sync/cancel',
|
|
152
|
+
syncAutosync: '/api/dsh-config-manager/sync/autosync',
|
|
153
|
+
// m-sync-selection:同步分区选择持久化(默认/高级模式 + 勾选分区;自动同步共用)
|
|
154
|
+
syncSelection: '/api/dsh-config-manager/sync/selection',
|
|
155
|
+
// m-sync-config:同步通道配置保存(UI 表单自动保存 /「保存配置」按钮;凭据写 DSH credentials)
|
|
156
|
+
syncConfig: '/api/dsh-config-manager/sync/config',
|
|
157
|
+
// m-self:插件 UI 偏好(如上次选择的同步通道;ui-prefs.json,随 self 分区进备份)
|
|
158
|
+
syncUiPrefs: '/api/dsh-config-manager/sync/ui-prefs',
|
|
159
|
+
// m-star-prompt:Star 引导弹窗状态(复用 ui-prefs.json;GET 读 + POST 局部更新)
|
|
160
|
+
// 版本更新内容弹窗状态(复用 ui-prefs.json;GET 读 + POST 局部更新)
|
|
161
|
+
// m-market:配置市场(内置单仓库,只读公开仓库:浏览 + 下载 + 安全校验;apply 复用 execute)
|
|
162
|
+
// m-my-configs:「一键上传 / 我的配置」(目标仓库固定 xiajiajun516/dsh-config-market;
|
|
163
|
+
// 登录复用 sync/github/start|poll|cancel,不重复实现;/me/items 401 → 未登录)
|
|
164
|
+
// m-profiles:配置档案(Profile = 一组可切换的配置快照;Save/List/Delete/Rename/Switch/Import)
|
|
165
|
+
// Phase 5:recovery 编排(prefix 路由,内部按 path 分发:status / <opId>/preview|confirm|execute|verify|retry|dismiss)
|
|
166
|
+
// Phase 6:迁移历史审计(统一历史引擎;只读 GET + 导出)
|
|
167
|
+
// Phase 1 P0-1/P0-2:配置生命周期(自动快照 / 撤销 / 重做)与崩溃归因
|
|
168
|
+
// Phase 1 P0-3:启动救援模式(禁用其它插件使 DSH 能启动)
|
|
169
|
+
};
|
|
170
|
+
/**
|
|
171
|
+
* 灾备子系统(Phase 1)总开关 —— 临时下线,待相关缺陷修复后再放出。
|
|
172
|
+
*
|
|
173
|
+
* 置 false 时整个灾备子系统停摆:
|
|
174
|
+
* - 不启动配置变更监听 → 不产生自动快照(也不再刷「配置快照超出上限」告警)
|
|
175
|
+
* - 不写 boot-state → 崩溃归因无观测数据(不影响启动)
|
|
176
|
+
* - /lifecycle /crash /rescue 三条路由一律 503 feature-disabled
|
|
177
|
+
*
|
|
178
|
+
* 与客户端导航入口开关配套:src/client/ConfigManagerSection.tsx 的 SHOW_LIFECYCLE_NAV。
|
|
179
|
+
* 两者都置 true 才是一套完整的灾备功能。
|
|
180
|
+
*
|
|
181
|
+
* 为什么整体下线而不是只停自动快照:自动快照的采集走**全部 adapter**,其中 sessions
|
|
182
|
+
* 分区(历史会话,本机实测 340 MB)远超快照 64 MiB 上限,必然持续失败并刷告警;
|
|
183
|
+
* 在该缺陷修好前,撤销/重做/救援也没有可信的快照基线可用。
|
|
184
|
+
*/
|
|
185
|
+
const LIFECYCLE_ENABLED = false;
|
|
186
|
+
/**
|
|
187
|
+
* 同步 token 的 DSH credentials 引用名(POSIX env-var 形态,满足 CredentialRef 品牌要求)。
|
|
188
|
+
* token 只经 credentialRef 读写(写入由请求体触发,读取在每次 git 网络操作时 resolve),
|
|
189
|
+
* 永不进 repoUrl / argv / commit / 同步文件 / 日志。
|
|
190
|
+
*/
|
|
191
|
+
export const SYNC_CREDENTIAL_REF = 'DSH_CONFIG_MANAGER_SYNC_TOKEN';
|
|
192
|
+
/**
|
|
193
|
+
* WebDAV 通道口令的独立 DSH credentials 引用(与 git token 槽位分离)。
|
|
194
|
+
* 口令只经 credentialRef 读写(写入由请求体触发,读取在每次 WebDAV 网络操作时
|
|
195
|
+
* by WebDavTransport 经注入的 getPassword() resolve),永不进 URL / 请求头 / 日志。
|
|
196
|
+
*/
|
|
197
|
+
export const SYNC_WEBDAV_CREDENTIAL_REF = 'DSH_CONFIG_MANAGER_SYNC_WEBDAV_PASSWORD';
|
|
198
|
+
/** Cap on JSON request bodies (import plans can be large: 4 MB). */
|
|
199
|
+
const MAX_JSON_BODY_BYTES = 4 * 1024 * 1024;
|
|
200
|
+
/** Cap on raw upload bodies (staged to the controlled tmp dir). */
|
|
201
|
+
const MAX_UPLOAD_BYTES = 256 * 1024 * 1024;
|
|
202
|
+
/* ---------------------------------------------------------- loopback fence */
|
|
203
|
+
/** Loopback literal check plus browser same-origin markers (dsh-ssh's fence). */
|
|
204
|
+
function isLoopbackRequest(request) {
|
|
205
|
+
const address = request.socket.remoteAddress;
|
|
206
|
+
if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1')
|
|
207
|
+
return false;
|
|
208
|
+
const host = request.headers.host;
|
|
209
|
+
if (typeof host !== 'string')
|
|
210
|
+
return false;
|
|
211
|
+
let hostUrl;
|
|
212
|
+
try {
|
|
213
|
+
hostUrl = new URL(`http://${host}`);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]')
|
|
219
|
+
return false;
|
|
220
|
+
if (request.headers['sec-fetch-site'] === 'cross-site')
|
|
221
|
+
return false;
|
|
222
|
+
const origin = request.headers.origin;
|
|
223
|
+
if (origin === undefined)
|
|
224
|
+
return true;
|
|
225
|
+
try {
|
|
226
|
+
return new URL(origin).host === hostUrl.host;
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/* ---------------------------------------------------------------- responses */
|
|
233
|
+
function writeJson(res, status, body) {
|
|
234
|
+
const payload = JSON.stringify(body);
|
|
235
|
+
res.writeHead(status, {
|
|
236
|
+
'content-type': 'application/json; charset=utf-8',
|
|
237
|
+
'referrer-policy': 'no-referrer',
|
|
238
|
+
});
|
|
239
|
+
res.end(payload);
|
|
240
|
+
}
|
|
241
|
+
async function readJsonBody(req) {
|
|
242
|
+
const chunks = [];
|
|
243
|
+
let size = 0;
|
|
244
|
+
for await (const chunk of req) {
|
|
245
|
+
const buffer = chunk;
|
|
246
|
+
size += buffer.length;
|
|
247
|
+
if (size > MAX_JSON_BODY_BYTES)
|
|
248
|
+
return undefined;
|
|
249
|
+
chunks.push(buffer);
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
253
|
+
return typeof parsed === 'object' && parsed !== null ? parsed : undefined;
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/** URL query helper (first value, decoded). */
|
|
260
|
+
function queryParam(url, name) {
|
|
261
|
+
const value = url.searchParams.get(name);
|
|
262
|
+
return value === null ? undefined : value;
|
|
263
|
+
}
|
|
264
|
+
/** Stream a raw request body to a file, enforcing a byte cap. */
|
|
265
|
+
async function writeRequestBodyToFile(req, dest, maxBytes) {
|
|
266
|
+
const sink = createWriteStream(dest);
|
|
267
|
+
let size = 0;
|
|
268
|
+
await new Promise((resolvePromise, reject) => {
|
|
269
|
+
req.on('error', reject);
|
|
270
|
+
sink.on('error', reject);
|
|
271
|
+
req.on('data', (chunk) => {
|
|
272
|
+
size += chunk.length;
|
|
273
|
+
if (size > maxBytes) {
|
|
274
|
+
sink.destroy();
|
|
275
|
+
req.destroy();
|
|
276
|
+
reject(new Error(`upload body exceeds ${maxBytes} bytes`));
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
req.pipe(sink);
|
|
280
|
+
sink.on('finish', () => resolvePromise());
|
|
281
|
+
});
|
|
282
|
+
return size;
|
|
283
|
+
}
|
|
284
|
+
/* -------------------------------------------------------------- dsh version */
|
|
285
|
+
/** 当前 DSH 应用语言(settings `locale` 命名空间的 preference;缺省 zh)。 */
|
|
286
|
+
function resolveAppLanguage(ctx) {
|
|
287
|
+
try {
|
|
288
|
+
const descriptors = ctx.settings.describe({ redactSecrets: true });
|
|
289
|
+
const locale = descriptors.find((d) => String(d.ns) === 'locale');
|
|
290
|
+
const pref = locale?.value?.preference;
|
|
291
|
+
return pref === 'en' ? 'en' : 'zh';
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return 'zh';
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/** Resolve the real DSH version from the profile dependency tree (read-only). */
|
|
298
|
+
function resolveDshVersion(home) {
|
|
299
|
+
const candidates = [
|
|
300
|
+
join(home, 'profiles', 'node_modules', '@deepseek-ai', 'dsh', 'package.json'),
|
|
301
|
+
join(home, 'profiles', 'web', 'node_modules', '@deepseek-ai', 'dsh', 'package.json'),
|
|
302
|
+
];
|
|
303
|
+
for (const p of candidates) {
|
|
304
|
+
try {
|
|
305
|
+
const parsed = JSON.parse(readFileSync(p, 'utf8'));
|
|
306
|
+
if (typeof parsed.version === 'string' && parsed.version !== '')
|
|
307
|
+
return parsed.version;
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
// try the next candidate
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return 'unknown';
|
|
314
|
+
}
|
|
315
|
+
/* ------------------------------------------------------------ profile name */
|
|
316
|
+
/** 解析管理的 profile:config.profile → 启动参数 --profile → 'web'。 */
|
|
317
|
+
function resolveProfileName(config) {
|
|
318
|
+
const configured = config?.profile;
|
|
319
|
+
if (configured !== undefined && configured !== '')
|
|
320
|
+
return validateProfileName(configured);
|
|
321
|
+
return resolveProfileNameFromArgv();
|
|
322
|
+
}
|
|
323
|
+
/* ------------------------------------------------------- HostContext facades */
|
|
324
|
+
/** Optional Cordis service reader (never injected → never blocks the fiber). */
|
|
325
|
+
function readService(ctx, serviceName) {
|
|
326
|
+
const candidate = ctx.get(serviceName);
|
|
327
|
+
return candidate === null || typeof candidate !== 'object' ? undefined : candidate;
|
|
328
|
+
}
|
|
329
|
+
/** Safe settings namespace converter compatible across DSH 0.1.1 and 0.1.2-alpha.x */
|
|
330
|
+
const SETTINGS_NAMESPACE_REGEX = /^[a-z][a-z0-9-]*$/;
|
|
331
|
+
function safeSettingsNamespace(namespace) {
|
|
332
|
+
const fn = dshSettings.settingsNamespace;
|
|
333
|
+
if (typeof fn === 'function') {
|
|
334
|
+
return fn(namespace);
|
|
335
|
+
}
|
|
336
|
+
if (!SETTINGS_NAMESPACE_REGEX.test(namespace)) {
|
|
337
|
+
throw new TypeError(`settings namespace "${namespace}" must match ${String(SETTINGS_NAMESPACE_REGEX)}`);
|
|
338
|
+
}
|
|
339
|
+
return namespace;
|
|
340
|
+
}
|
|
341
|
+
/** Safe credential ref converter compatible across DSH 0.1.1 and 0.1.2-alpha.x */
|
|
342
|
+
const CREDENTIAL_REF_REGEX = /^[A-Z_][A-Z0-9_]*$/;
|
|
343
|
+
function safeCredentialRef(ref) {
|
|
344
|
+
const fn = dshCredentials.credentialRef;
|
|
345
|
+
if (typeof fn === 'function') {
|
|
346
|
+
return fn(ref);
|
|
347
|
+
}
|
|
348
|
+
if (!CREDENTIAL_REF_REGEX.test(ref)) {
|
|
349
|
+
throw new TypeError(`credential ref "${ref}" must match ${String(CREDENTIAL_REF_REGEX)}`);
|
|
350
|
+
}
|
|
351
|
+
return ref;
|
|
352
|
+
}
|
|
353
|
+
const credentialRef = safeCredentialRef;
|
|
354
|
+
/** Settings facade over the real ctx.settings (describe() is namespace-less). */
|
|
355
|
+
class DshSettingsFacade {
|
|
356
|
+
ctx;
|
|
357
|
+
constructor(ctx) {
|
|
358
|
+
this.ctx = ctx;
|
|
359
|
+
}
|
|
360
|
+
provider() {
|
|
361
|
+
return this.ctx.settings;
|
|
362
|
+
}
|
|
363
|
+
async describe(namespace, opts) {
|
|
364
|
+
const all = this.provider().describe({ redactSecrets: opts?.redactSecrets ?? true });
|
|
365
|
+
const descriptor = all.find((d) => String(d.ns) === namespace);
|
|
366
|
+
if (!descriptor)
|
|
367
|
+
throw new Error(`namespace not found: ${namespace}`);
|
|
368
|
+
return {
|
|
369
|
+
value: descriptor.value,
|
|
370
|
+
base: descriptor.base,
|
|
371
|
+
revision: descriptor.revision,
|
|
372
|
+
// Real service reports a single applies value; the core contract is an array.
|
|
373
|
+
applies: descriptor.applies === undefined ? undefined : [descriptor.applies],
|
|
374
|
+
secrets: descriptor.secrets ?? [],
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
async replace(namespace, value, expectedRevision) {
|
|
378
|
+
await this.provider().replace(safeSettingsNamespace(namespace), value, expectedRevision);
|
|
379
|
+
}
|
|
380
|
+
async update(namespace, patch, expectedRevision) {
|
|
381
|
+
await this.provider().update(safeSettingsNamespace(namespace), patch, expectedRevision);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
/** Credentials facade over the real ctx.credentials (values never round-trip). */
|
|
385
|
+
class DshCredentialsFacade {
|
|
386
|
+
ctx;
|
|
387
|
+
constructor(ctx) {
|
|
388
|
+
this.ctx = ctx;
|
|
389
|
+
}
|
|
390
|
+
async describe(ref) {
|
|
391
|
+
const info = await this.ctx.credentials.describe(safeCredentialRef(ref));
|
|
392
|
+
return { configured: info.configured, source: info.source, writable: info.writable };
|
|
393
|
+
}
|
|
394
|
+
async set(ref, value) {
|
|
395
|
+
await this.ctx.credentials.set(safeCredentialRef(ref), value);
|
|
396
|
+
}
|
|
397
|
+
async unset(ref) {
|
|
398
|
+
await this.ctx.credentials.unset(safeCredentialRef(ref));
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
/** 包名 → patch 行 id slug(仿 marketplace ensureRow):去 @、非法字符→-、连续-合并、去首尾-。 */
|
|
402
|
+
export function slugOf(name) {
|
|
403
|
+
return name.replace(/^@/, '').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/-{2,}/g, '-').replace(/^-|-$/g, '');
|
|
404
|
+
}
|
|
405
|
+
/** 某 patch 行(raw)是否激活了指定包名(兼容单行与 insert 块成员)。 */
|
|
406
|
+
export function patchRowActivates(raw, name) {
|
|
407
|
+
if (raw === null || typeof raw !== 'object')
|
|
408
|
+
return false;
|
|
409
|
+
const obj = raw;
|
|
410
|
+
const entries = Array.isArray(obj['insert']) ? obj['insert'] : [obj];
|
|
411
|
+
return entries.some((e) => e !== null && typeof e === 'object' && e['name'] === name);
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* 非 bundle 插件安装成功后,幂等补 profile cordis.patch.yml 激活行
|
|
415
|
+
* ({id: pm-<slug>, name: <pkg>},仿 marketplace ensureRow)。bundle 包不写行
|
|
416
|
+
* (CLI 的 reconcile 已维护 dsh.profile.bundles)。
|
|
417
|
+
*/
|
|
418
|
+
export async function ensureActivationRow(patchFile, pkgDir, pkg) {
|
|
419
|
+
if (hasDshBundlePatch(pkgDir))
|
|
420
|
+
return;
|
|
421
|
+
const lines = await patchFile.readPatchLines(PROFILE_PATCH_FILE);
|
|
422
|
+
if (lines.some((l) => patchRowActivates(l.raw, pkg)))
|
|
423
|
+
return;
|
|
424
|
+
const id = `pm-${slugOf(pkg)}`;
|
|
425
|
+
await patchFile.applyPatchChanges(PROFILE_PATCH_FILE, [
|
|
426
|
+
{ lineId: id, raw: { id, name: pkg }, action: 'insert' },
|
|
427
|
+
]);
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Plugins facade:官方 dsh plugin CLI 通道(任何 profile 可用)+ profile 文件
|
|
431
|
+
* 实时清单 + 非 bundle 插件激活行幂等补写。不再依赖 web 专用
|
|
432
|
+
* pluginMarketplace / pluginInventory 服务。
|
|
433
|
+
*
|
|
434
|
+
* 导出 + runner 可注入:M5 单测用 mock runner 验证「无 marketplace 时 install
|
|
435
|
+
* 走 CLI 通道」的行为契约,不触发真实子进程;生产路径默认参数不变。
|
|
436
|
+
*/
|
|
437
|
+
export class DshPluginsFacade {
|
|
438
|
+
homeDir;
|
|
439
|
+
profile;
|
|
440
|
+
patchFile;
|
|
441
|
+
msg;
|
|
442
|
+
runner;
|
|
443
|
+
constructor(homeDir, profile, patchFile, runner = runDshPlugin, msg = zhMsg) {
|
|
444
|
+
this.homeDir = homeDir;
|
|
445
|
+
this.profile = profile;
|
|
446
|
+
this.patchFile = patchFile;
|
|
447
|
+
this.runner = runner;
|
|
448
|
+
this.msg = msg;
|
|
449
|
+
}
|
|
450
|
+
async listInstalled() {
|
|
451
|
+
return listInstalledPlugins(this.homeDir, this.profile);
|
|
452
|
+
}
|
|
453
|
+
async install(pkg, spec, signal) {
|
|
454
|
+
const profileDir = resolveProfileDir(this.homeDir, this.profile);
|
|
455
|
+
// 非 registry 来源(github:/git+/file: 等)按来源 spec 安装;registry 包按裸包名装
|
|
456
|
+
// npm 最新版(官方机制)。spec 丢失(旧备份)时退化为裸包名 → pnpm fetch-404,
|
|
457
|
+
// 由 installErrorFor 给出可操作诊断。
|
|
458
|
+
const result = await this.runner(profileDir, this.profile, ['add', installSpecFor(pkg, spec)], undefined, signal);
|
|
459
|
+
// 用户「跳过当前插件」:宿主 kill 了子进程 → 清理半装状态(删依赖行 + 删 node_modules/<pkg>,
|
|
460
|
+
// 防止「package.json 声明了依赖但没装全」导致 DSH 启动失败),再以跳过语义抛错。
|
|
461
|
+
if (result.aborted || (signal !== undefined && signal.aborted)) {
|
|
462
|
+
cleanupAbortedInstall(profileDir, pkg);
|
|
463
|
+
throw new ImportUserSkippedError(this.msg);
|
|
464
|
+
}
|
|
465
|
+
if (result.exitCode !== 0 || result.timedOut)
|
|
466
|
+
throw installErrorFor(pkg, result);
|
|
467
|
+
// 非 bundle 插件:CLI 只维护 bundles,需补 profile patch 激活行才能加载。
|
|
468
|
+
// 补写失败不吞:包已装但未激活,明确报错并允许重试(幂等补行)。
|
|
469
|
+
try {
|
|
470
|
+
await ensureActivationRow(this.patchFile, join(profileDir, 'node_modules', pkg), pkg);
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
474
|
+
throw new Error(this.msg('host.activationRowFailed', { pkg, reason }));
|
|
475
|
+
}
|
|
476
|
+
return { needsRestart: true };
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
/** Workspace facade over the real ctx.workspaceRegistry. */
|
|
480
|
+
class DshWorkspaceFacade {
|
|
481
|
+
ctx;
|
|
482
|
+
msg;
|
|
483
|
+
constructor(ctx, msg = zhMsg) {
|
|
484
|
+
this.ctx = ctx;
|
|
485
|
+
this.msg = msg;
|
|
486
|
+
}
|
|
487
|
+
registry() {
|
|
488
|
+
return readService(this.ctx, 'workspaceRegistry');
|
|
489
|
+
}
|
|
490
|
+
async listRecords() {
|
|
491
|
+
const registry = this.registry();
|
|
492
|
+
if (!registry)
|
|
493
|
+
return [];
|
|
494
|
+
return registry.list().map((w) => ({
|
|
495
|
+
id: String(w.id),
|
|
496
|
+
path: w.path,
|
|
497
|
+
title: w.title,
|
|
498
|
+
sessionIds: [...w.sessionIds].map(String),
|
|
499
|
+
createdAt: w.createdAt,
|
|
500
|
+
updatedAt: w.updatedAt,
|
|
501
|
+
}));
|
|
502
|
+
}
|
|
503
|
+
async writeRecord(record) {
|
|
504
|
+
const registry = this.registry();
|
|
505
|
+
if (!registry)
|
|
506
|
+
throw new Error(this.msg('host.workspaceUnavailable'));
|
|
507
|
+
const existing = registry.get(record.id);
|
|
508
|
+
if (existing) {
|
|
509
|
+
// DSH API 没有「整体覆盖」写通道:标题可更新;path/会话由 registry 依真实目录维护
|
|
510
|
+
if (record.title !== undefined && existing.title !== record.title)
|
|
511
|
+
await existing.setTitle(record.title);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
await registry.create(record.path, record.title);
|
|
515
|
+
}
|
|
516
|
+
async removeRecord(id) {
|
|
517
|
+
const registry = this.registry();
|
|
518
|
+
if (!registry)
|
|
519
|
+
return;
|
|
520
|
+
await registry.delete(id);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
/** Profile 目录内的 patch 文件(非 bundle 插件激活行写入处,marketplace 同款路径)。 */
|
|
524
|
+
const PROFILE_PATCH_FILE = 'cordis.patch.yml';
|
|
525
|
+
/** Patch-file facade:用户 patch 层($DSH_HOME/cordis.patch.yml)+ profile patch 层
|
|
526
|
+
* ($DSH_HOME/profiles/<name>/cordis.patch.yml),两者都在 home 根内。 */
|
|
527
|
+
class DshPatchFileFacade {
|
|
528
|
+
homeDir;
|
|
529
|
+
profile;
|
|
530
|
+
msg;
|
|
531
|
+
constructor(homeDir, profile, msg = zhMsg) {
|
|
532
|
+
this.homeDir = homeDir;
|
|
533
|
+
this.profile = profile;
|
|
534
|
+
this.msg = msg;
|
|
535
|
+
}
|
|
536
|
+
patchPath(file) {
|
|
537
|
+
if (file === USER_PATCH_FILE)
|
|
538
|
+
return join(this.homeDir, USER_PATCH_FILE);
|
|
539
|
+
if (file === PROFILE_PATCH_FILE)
|
|
540
|
+
return join(this.homeDir, 'profiles', this.profile, PROFILE_PATCH_FILE);
|
|
541
|
+
throw new Error(this.msg('host.patchUnsupported', { user: USER_PATCH_FILE, profile: PROFILE_PATCH_FILE, file }));
|
|
542
|
+
}
|
|
543
|
+
async readPatchLines(file) {
|
|
544
|
+
const p = this.patchPath(file);
|
|
545
|
+
let text;
|
|
546
|
+
try {
|
|
547
|
+
text = await fs.readFile(p, 'utf8');
|
|
548
|
+
}
|
|
549
|
+
catch {
|
|
550
|
+
return [];
|
|
551
|
+
}
|
|
552
|
+
let doc;
|
|
553
|
+
try {
|
|
554
|
+
doc = yaml.load(text);
|
|
555
|
+
}
|
|
556
|
+
catch {
|
|
557
|
+
return [];
|
|
558
|
+
}
|
|
559
|
+
if (!Array.isArray(doc))
|
|
560
|
+
return [];
|
|
561
|
+
const lines = [];
|
|
562
|
+
for (const item of doc) {
|
|
563
|
+
if (item === null || typeof item !== 'object')
|
|
564
|
+
continue;
|
|
565
|
+
const obj = item;
|
|
566
|
+
const insert = obj['insert'];
|
|
567
|
+
if (Array.isArray(insert)) {
|
|
568
|
+
for (const entry of insert) {
|
|
569
|
+
if (entry === null || typeof entry !== 'object')
|
|
570
|
+
continue;
|
|
571
|
+
const id = entry['id'];
|
|
572
|
+
if (typeof id === 'string' && id !== '')
|
|
573
|
+
lines.push({ lineId: id, raw: entry });
|
|
574
|
+
}
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
const id = obj['id'];
|
|
578
|
+
if (typeof id === 'string' && id !== '')
|
|
579
|
+
lines.push({ lineId: id, raw: obj });
|
|
580
|
+
}
|
|
581
|
+
return lines;
|
|
582
|
+
}
|
|
583
|
+
async applyPatchChanges(file, changes) {
|
|
584
|
+
const p = this.patchPath(file);
|
|
585
|
+
// 1. Load the current document into an ordered lineId → raw table.
|
|
586
|
+
const rows = new Map();
|
|
587
|
+
const order = [];
|
|
588
|
+
let doc;
|
|
589
|
+
try {
|
|
590
|
+
doc = yaml.load(await fs.readFile(p, 'utf8'));
|
|
591
|
+
}
|
|
592
|
+
catch {
|
|
593
|
+
doc = undefined;
|
|
594
|
+
}
|
|
595
|
+
if (Array.isArray(doc)) {
|
|
596
|
+
for (const item of doc) {
|
|
597
|
+
if (item === null || typeof item !== 'object')
|
|
598
|
+
continue;
|
|
599
|
+
const obj = item;
|
|
600
|
+
const insert = obj['insert'];
|
|
601
|
+
if (Array.isArray(insert)) {
|
|
602
|
+
for (const entry of insert) {
|
|
603
|
+
if (entry === null || typeof entry !== 'object')
|
|
604
|
+
continue;
|
|
605
|
+
const id = entry['id'];
|
|
606
|
+
if (typeof id === 'string' && id !== '' && !rows.has(id)) {
|
|
607
|
+
rows.set(id, entry);
|
|
608
|
+
order.push(id);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
const id = obj['id'];
|
|
614
|
+
if (typeof id === 'string' && id !== '' && !rows.has(id)) {
|
|
615
|
+
rows.set(id, obj);
|
|
616
|
+
order.push(id);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
// 2. Apply the changes.
|
|
621
|
+
for (const change of changes) {
|
|
622
|
+
if (change.action === 'remove') {
|
|
623
|
+
if (rows.delete(change.lineId)) {
|
|
624
|
+
const at = order.indexOf(change.lineId);
|
|
625
|
+
if (at >= 0)
|
|
626
|
+
order.splice(at, 1);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
else if (change.action === 'insert' || change.action === 'update') {
|
|
630
|
+
if (!rows.has(change.lineId))
|
|
631
|
+
order.push(change.lineId);
|
|
632
|
+
rows.set(change.lineId, change.raw);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
// 3. Rebuild: every id row is emitted as a top-level row. The loader treats
|
|
636
|
+
// a top-level { id, name } row exactly like an `- insert:` block member
|
|
637
|
+
// (dsh-base patch precedent), so the document stays semantically equal.
|
|
638
|
+
const out = [];
|
|
639
|
+
for (const id of order) {
|
|
640
|
+
const raw = rows.get(id);
|
|
641
|
+
if (raw !== undefined)
|
|
642
|
+
out.push(raw);
|
|
643
|
+
}
|
|
644
|
+
const text = '# rewritten by dsh-config-manager import (original comments not preserved)\n'
|
|
645
|
+
+ yaml.dump(out);
|
|
646
|
+
await atomicWriteFile(p, text);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
/** File facade over $DSH_HOME, confined to the home root. */
|
|
650
|
+
class DshFileSystemFacade {
|
|
651
|
+
homeDir;
|
|
652
|
+
msg;
|
|
653
|
+
constructor(homeDir, msg = zhMsg) {
|
|
654
|
+
this.homeDir = homeDir;
|
|
655
|
+
this.msg = msg;
|
|
656
|
+
}
|
|
657
|
+
abs(relPath) {
|
|
658
|
+
const target = resolve(isAbsolute(relPath) ? relPath : join(this.homeDir, relPath));
|
|
659
|
+
if (!isSameOrChild(target, this.homeDir))
|
|
660
|
+
throw new Error(this.msg('host.fsPathEscape', { path: relPath }));
|
|
661
|
+
return target;
|
|
662
|
+
}
|
|
663
|
+
async readFile(relPath) {
|
|
664
|
+
return fs.readFile(this.abs(relPath));
|
|
665
|
+
}
|
|
666
|
+
async writeFile(relPath, data) {
|
|
667
|
+
// 原子写(Phase 1):同目录 tmp + fsync + rename,覆盖所有经 HostContext.fs 的配置写
|
|
668
|
+
await atomicWriteFile(this.abs(relPath), data);
|
|
669
|
+
}
|
|
670
|
+
async exists(relPath) {
|
|
671
|
+
try {
|
|
672
|
+
await fs.access(this.abs(relPath));
|
|
673
|
+
return true;
|
|
674
|
+
}
|
|
675
|
+
catch {
|
|
676
|
+
return false;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
async copy(from, to) {
|
|
680
|
+
await atomicCopyFile(this.abs(from), this.abs(to));
|
|
681
|
+
}
|
|
682
|
+
async remove(relPath) {
|
|
683
|
+
await fs.rm(this.abs(relPath), { recursive: true, force: true });
|
|
684
|
+
}
|
|
685
|
+
/** 仅路径列表(既有契约):委托 listRecursiveDetailed,丢弃诊断。 */
|
|
686
|
+
async listRecursive(dir) {
|
|
687
|
+
return (await this.listRecursiveDetailed(dir)).paths;
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* 跟随 junction / 符号链接的遍历 + 被跳过链接清单(issue #37)。
|
|
691
|
+
* 实现下沉到 utils/recursive-walk.ts(可用真实临时目录直接单测)。
|
|
692
|
+
*/
|
|
693
|
+
async listRecursiveDetailed(dir) {
|
|
694
|
+
return listRecursiveFollowingLinks(this.abs(dir), this.homeDir);
|
|
695
|
+
}
|
|
696
|
+
async mkdir(dir) {
|
|
697
|
+
await fs.mkdir(this.abs(dir), { recursive: true });
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
/** The engine's HostContext over real DSH services. */
|
|
701
|
+
class ConfigManagerHostContext {
|
|
702
|
+
platform = process.platform;
|
|
703
|
+
arch = process.arch;
|
|
704
|
+
homeDir;
|
|
705
|
+
dshVersion;
|
|
706
|
+
profile;
|
|
707
|
+
log;
|
|
708
|
+
msg;
|
|
709
|
+
/** 应用语言(resolveAppLanguage;导出历史报告 locale 用) */
|
|
710
|
+
language;
|
|
711
|
+
settings;
|
|
712
|
+
credentials;
|
|
713
|
+
plugins;
|
|
714
|
+
workspace;
|
|
715
|
+
patchFile;
|
|
716
|
+
fs;
|
|
717
|
+
/** Phase 2 跨进程环境锁端口(宿主注入;测试 mock 不注入 → 无锁环境) */
|
|
718
|
+
mutationLock;
|
|
719
|
+
/** Phase 3 SAFE MODE:注入同步谓词(读内存标志,供 withMutationLock isBlocked 用;env-lock 不识 policy) */
|
|
720
|
+
safeModeIsBlocked;
|
|
721
|
+
/** Phase 3 恢复/事务(JournalStore + reconcile + SAFE MODE + runJournaled)。apply() 注入。 */
|
|
722
|
+
phase3Recovery;
|
|
723
|
+
constructor(ctx, homeDir, profile) {
|
|
724
|
+
this.homeDir = homeDir;
|
|
725
|
+
this.dshVersion = resolveDshVersion(homeDir);
|
|
726
|
+
this.profile = profile;
|
|
727
|
+
this.language = resolveAppLanguage(ctx);
|
|
728
|
+
this.msg = makeMsg(this.language);
|
|
729
|
+
const level = process.env.DSH_CONFIG_MANAGER_LOG_LEVEL;
|
|
730
|
+
this.log = createLogger({
|
|
731
|
+
level: level === 'debug' || level === 'info' || level === 'warn' || level === 'error' ? level : 'info',
|
|
732
|
+
});
|
|
733
|
+
this.settings = new DshSettingsFacade(ctx);
|
|
734
|
+
this.credentials = new DshCredentialsFacade(ctx);
|
|
735
|
+
this.patchFile = new DshPatchFileFacade(homeDir, profile, this.msg);
|
|
736
|
+
this.plugins = new DshPluginsFacade(homeDir, profile, this.patchFile, undefined, this.msg);
|
|
737
|
+
this.workspace = new DshWorkspaceFacade(ctx, this.msg);
|
|
738
|
+
this.fs = new DshFileSystemFacade(homeDir, this.msg);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
/* ---------------------------------------------------------------- routes */
|
|
742
|
+
/** Controlled staging roots guard. */
|
|
743
|
+
function isControlledPath(target, roots) {
|
|
744
|
+
const t = resolve(target);
|
|
745
|
+
return roots.some((root) => isSameOrChild(t, resolve(root)));
|
|
746
|
+
}
|
|
747
|
+
function dateStamp(d = new Date()) {
|
|
748
|
+
const y = d.getFullYear();
|
|
749
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
750
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
751
|
+
return `${y}-${m}-${day}`;
|
|
752
|
+
}
|
|
753
|
+
/** Minimal real dependency check (MCP §15): `which`/`where` probe. */
|
|
754
|
+
async function dependencyAvailable(command) {
|
|
755
|
+
const probe = process.platform === 'win32' ? 'where' : 'which';
|
|
756
|
+
try {
|
|
757
|
+
await promisify(execFile)(probe, [command], { windowsHide: true });
|
|
758
|
+
return true;
|
|
759
|
+
}
|
|
760
|
+
catch {
|
|
761
|
+
return false;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
/** 导出/导入执行超时(ms)。正常导出秒级完成;此上限只兜底「宿主卡死」场景,
|
|
765
|
+
* 让客户端拿到明确错误而不是永远停在进度条。 */
|
|
766
|
+
const ROUTE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
767
|
+
/** WebDAV 单请求超时(ms):慢速 WebDAV(如坚果云限速)上传大快照/读写索引
|
|
768
|
+
* 需要比 git 通道更宽裕的窗口;错误消息会带上实际 ms,便于用户判断。 */
|
|
769
|
+
const WEBDAV_TIMEOUT_MS = 120_000;
|
|
770
|
+
/** 带超时的 Promise:超时以明确错误拒绝(promise 自身由调用方负责,此处只计时)。 */
|
|
771
|
+
async function withTimeout(promise, ms, message) {
|
|
772
|
+
let timer;
|
|
773
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
774
|
+
timer = setTimeout(() => reject(new Error(message)), ms);
|
|
775
|
+
});
|
|
776
|
+
try {
|
|
777
|
+
return await Promise.race([promise, timeout]);
|
|
778
|
+
}
|
|
779
|
+
finally {
|
|
780
|
+
if (timer !== undefined)
|
|
781
|
+
clearTimeout(timer);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
/** Decrypt an encrypted backup's credentials (in-memory only; undefined when not applicable). */
|
|
785
|
+
async function tryDecryptCredentials(zipPath, password) {
|
|
786
|
+
if (password === undefined || password === '')
|
|
787
|
+
return undefined;
|
|
788
|
+
const raw = await fs.readFile(zipPath);
|
|
789
|
+
const archive = parseZip(raw);
|
|
790
|
+
if (!archive.has(MANIFEST_FILE))
|
|
791
|
+
return undefined;
|
|
792
|
+
let manifest;
|
|
793
|
+
try {
|
|
794
|
+
manifest = parseManifest(archive.readEntryText(MANIFEST_FILE));
|
|
795
|
+
}
|
|
796
|
+
catch {
|
|
797
|
+
return undefined;
|
|
798
|
+
}
|
|
799
|
+
if (!manifest.security.encrypted || manifest.security.encryption === null)
|
|
800
|
+
return undefined;
|
|
801
|
+
if (!archive.has('security/secrets.enc'))
|
|
802
|
+
return undefined;
|
|
803
|
+
const blob = archive.readEntry('security/secrets.enc');
|
|
804
|
+
const plaintext = await decryptCredentials(blob, manifest.security.encryption, password);
|
|
805
|
+
let parsed;
|
|
806
|
+
try {
|
|
807
|
+
parsed = yaml.load(plaintext);
|
|
808
|
+
}
|
|
809
|
+
catch {
|
|
810
|
+
return undefined;
|
|
811
|
+
}
|
|
812
|
+
const map = new Map();
|
|
813
|
+
if (parsed !== null && typeof parsed === 'object') {
|
|
814
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
815
|
+
if (typeof v === 'string' && v !== '')
|
|
816
|
+
map.set(k, v);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
return map;
|
|
820
|
+
}
|
|
821
|
+
/** 解密错误 → 用户可读文本:BAD_PASSWORD 只报「密码错误」(不泄内部细节),其余原文 */
|
|
822
|
+
function decryptErrorText(error, msg) {
|
|
823
|
+
if (error instanceof SecurityError && error.code === 'BAD_PASSWORD') {
|
|
824
|
+
return msg('import.encryptedPasswordWrong');
|
|
825
|
+
}
|
|
826
|
+
return error instanceof Error ? error.message : String(error);
|
|
827
|
+
}
|
|
828
|
+
/* -------------------------------------------------- sync 路由(m-sync-ui) */
|
|
829
|
+
/** 同步路由可预期的请求级错误(status 缺省 400;引擎/传输失败走 500) */
|
|
830
|
+
export class SyncRouteError extends Error {
|
|
831
|
+
status;
|
|
832
|
+
constructor(message, status = 400) {
|
|
833
|
+
super(message);
|
|
834
|
+
this.name = 'SyncRouteError';
|
|
835
|
+
this.status = status;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
/** 同步路由错误统一出口:SyncRouteError 用其 status,其余 500(GitTransport 错误消息已脱敏) */
|
|
839
|
+
export function writeSyncRouteError(res, error) {
|
|
840
|
+
if (error instanceof SyncRouteError) {
|
|
841
|
+
writeJson(res, error.status, { error: error.message });
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
845
|
+
writeJson(res, 500, { error: message });
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* 解析同步请求体,按 transport 分支返回归一化的 SyncConfig(可辨识联合,schemaVersion=2)。
|
|
849
|
+
* 请求体形状(flat,M4 契约):
|
|
850
|
+
* - git: { transport:'git', repoUrl, token? } —— token 非空写 SYNC_CREDENTIAL_REF;
|
|
851
|
+
* git 可执行文件固定使用系统 PATH 中的 git(不再接受自定义 gitBin)。
|
|
852
|
+
* - webdav: { transport:'webdav', url, username?, password? } —— password 非空写 SYNC_WEBDAV_CREDENTIAL_REF。
|
|
853
|
+
* 返回值不含任何 secret(password/token 只进 credentials,永不回传/落同步文件)。
|
|
854
|
+
*/
|
|
855
|
+
export async function parseSyncBody(body, deps) {
|
|
856
|
+
const transport = body['transport'] === 'webdav' ? 'webdav' : 'git';
|
|
857
|
+
if (transport === 'webdav') {
|
|
858
|
+
const url = typeof body['url'] === 'string' ? body['url'].trim() : '';
|
|
859
|
+
if (url === '')
|
|
860
|
+
throw new SyncRouteError('url is required for webdav');
|
|
861
|
+
const urlError = validateWebDavUrl(url);
|
|
862
|
+
if (urlError !== null)
|
|
863
|
+
throw new SyncRouteError(urlError);
|
|
864
|
+
const username = typeof body['username'] === 'string' && body['username'] !== '' ? body['username'] : undefined;
|
|
865
|
+
const password = typeof body['password'] === 'string' && body['password'] !== '' ? body['password'] : undefined;
|
|
866
|
+
if (password !== undefined) {
|
|
867
|
+
try {
|
|
868
|
+
await deps.credentials.set(credentialRef(SYNC_WEBDAV_CREDENTIAL_REF), password);
|
|
869
|
+
}
|
|
870
|
+
catch (error) {
|
|
871
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
872
|
+
throw new SyncRouteError(`WebDAV 口令写入 DSH credentials 失败:${reason}(请在 DSH 凭据管理里配置 ${SYNC_WEBDAV_CREDENTIAL_REF} 后重试)`);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
return {
|
|
876
|
+
schemaVersion: 2,
|
|
877
|
+
transport: 'webdav',
|
|
878
|
+
webdav: { url, ...(username !== undefined ? { username } : {}) },
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
// git 通道(沿用现有逻辑)
|
|
882
|
+
const repoUrl = typeof body['repoUrl'] === 'string' ? body['repoUrl'].trim() : '';
|
|
883
|
+
if (repoUrl === '')
|
|
884
|
+
throw new SyncRouteError('repoUrl is required');
|
|
885
|
+
const urlError = validateRepoUrl(repoUrl);
|
|
886
|
+
if (urlError !== null)
|
|
887
|
+
throw new SyncRouteError(urlError);
|
|
888
|
+
const token = typeof body['token'] === 'string' && body['token'] !== '' ? body['token'] : undefined;
|
|
889
|
+
if (token !== undefined) {
|
|
890
|
+
try {
|
|
891
|
+
await deps.credentials.set(credentialRef(SYNC_CREDENTIAL_REF), token);
|
|
892
|
+
}
|
|
893
|
+
catch (error) {
|
|
894
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
895
|
+
throw new SyncRouteError(`token 写入 DSH credentials 失败:${reason}(请在 DSH 凭据管理里配置 ${SYNC_CREDENTIAL_REF} 后重试)`);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
return {
|
|
899
|
+
schemaVersion: 2,
|
|
900
|
+
transport: 'git',
|
|
901
|
+
git: { repoUrl },
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
/** 由 SyncConfig 合成 WebDAV 通道 baseUrl(webdav.url,尾部规范化带 '/';git 通道返回 '')。 */
|
|
905
|
+
export function webdavBaseUrl(cfg) {
|
|
906
|
+
if (!isWebDavConfig(cfg))
|
|
907
|
+
return '';
|
|
908
|
+
return cfg.webdav.url.replace(/\/+$/, '') + '/';
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* 补全 webdav 配置缺失的 username(从持久化配置回填;纯函数,不修改入参)。
|
|
912
|
+
* 语义与 password 一致:请求未带 username(表单留空/挂载自动加载)→ 沿用已保存的值;
|
|
913
|
+
* 请求显式带 username → 原样保留(用户新输入优先)。非 webdav / 无持久化 → 原样返回。
|
|
914
|
+
*/
|
|
915
|
+
export function mergePersistedWebDavUsername(cfg, persisted) {
|
|
916
|
+
if (!isWebDavConfig(cfg) || (cfg.webdav.username !== undefined && cfg.webdav.username !== ''))
|
|
917
|
+
return cfg;
|
|
918
|
+
if (persisted !== null && isWebDavConfig(persisted)
|
|
919
|
+
&& typeof persisted.webdav.username === 'string' && persisted.webdav.username !== '') {
|
|
920
|
+
return { ...cfg, webdav: { ...cfg.webdav, username: persisted.webdav.username } };
|
|
921
|
+
}
|
|
922
|
+
return cfg;
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* 解析 push 请求体的分区选择(sections)——「高级/自定义导出」模式负载。
|
|
926
|
+
* - 缺省 / 非数组 / 空数组 → undefined(= 全部 portable 推荐分区,即「默认/快速导出」模式);
|
|
927
|
+
* - 元素必须是 knownIds(已知 adapter id)中的非空字符串,非法 → SyncRouteError(不静默吞错);
|
|
928
|
+
* - 返回去重后的数组(保持原顺序;重复分区不做重复导出)。
|
|
929
|
+
*/
|
|
930
|
+
export function extractSyncSections(body, knownIds) {
|
|
931
|
+
const raw = body['sections'];
|
|
932
|
+
if (!Array.isArray(raw) || raw.length === 0)
|
|
933
|
+
return undefined;
|
|
934
|
+
const out = [];
|
|
935
|
+
const seen = new Set();
|
|
936
|
+
for (const item of raw) {
|
|
937
|
+
if (typeof item !== 'string' || item === '') {
|
|
938
|
+
throw new SyncRouteError('sections must be an array of non-empty strings');
|
|
939
|
+
}
|
|
940
|
+
if (!knownIds.has(item)) {
|
|
941
|
+
throw new SyncRouteError(`unknown sync section: ${item}`);
|
|
942
|
+
}
|
|
943
|
+
if (!seen.has(item)) {
|
|
944
|
+
seen.add(item);
|
|
945
|
+
out.push(item);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
return out;
|
|
949
|
+
}
|
|
950
|
+
/** 需要人工决策的 PlanItemKind(一键同步 needsReview 判定 + 逐项确认标记)。
|
|
951
|
+
* 注意:'Install'(安装插件)不在此列 —— 同步拉取差异时插件按「自动安装」处理:
|
|
952
|
+
* 默认采纳、不逐项展示、无需手动选择(product requirement)。
|
|
953
|
+
* issue #35:'Warning' 必须**可见**——它承载「本次同步会剔除哪些无法满足的声明」这类
|
|
954
|
+
* 改变配置语义的信息;此前非决策项默认自动采用且不展示,用户只看到「同步成功」。 */
|
|
955
|
+
const REVIEW_KINDS = new Set([
|
|
956
|
+
'Conflict', 'MissingSecret', 'MissingDependency', 'Error', 'PathMapping', 'Warning',
|
|
957
|
+
]);
|
|
958
|
+
/**
|
|
959
|
+
* issue #35:会**改变工具链行为**的项 —— 只有 pnpm-workspace.yaml 在本次同步中
|
|
960
|
+
* 移除了无法满足的 patchedDependencies 声明时,才带 detail。
|
|
961
|
+
* 这类项此前属「非冲突项 → 自动采用且不展示」,用户即使已知风险也无法否决
|
|
962
|
+
* (issue #35 正是这条自动采用把目标机 pnpm 弄坏的)。
|
|
963
|
+
* 现在:进确认列表、可见、可取消;但**默认仍采用**(我们的 sanitize 结果严格更安全,
|
|
964
|
+
* 默认不采用反而会静默丢掉 allowBuilds / 冷静期配置)。
|
|
965
|
+
* 注意:与客户端 sync-view.ts 的同名判定必须保持一致(两侧刻意重复,避免跨端 import)。
|
|
966
|
+
*/
|
|
967
|
+
function isToolchainChangeItem(item) {
|
|
968
|
+
return item.itemId === 'plugins:pnpm-workspace' && item.detail !== undefined && item.detail !== '';
|
|
969
|
+
}
|
|
970
|
+
/** 把 ImportPlan 投影为逐项可确认的差异项(默认采用 Create/Update/Install;人工项默认不采用)。 */
|
|
971
|
+
function planToConfirmItems(plan) {
|
|
972
|
+
return plan.items.map((item) => {
|
|
973
|
+
const manual = REVIEW_KINDS.has(item.kind);
|
|
974
|
+
let conflict;
|
|
975
|
+
if (item.kind === 'Conflict') {
|
|
976
|
+
const c = item.conflict;
|
|
977
|
+
conflict = {
|
|
978
|
+
path: c?.path ?? '$',
|
|
979
|
+
kind: c?.kind === 'file' ? 'file' : c?.kind === 'section' ? 'section' : 'key',
|
|
980
|
+
...(c?.local !== undefined ? { local: c.local } : {}),
|
|
981
|
+
...(c?.remote !== undefined ? { remote: c.remote } : {}),
|
|
982
|
+
...(c?.ancestor !== undefined ? { ancestor: c.ancestor } : {}),
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
return {
|
|
986
|
+
itemId: item.id,
|
|
987
|
+
adapter: item.adapter,
|
|
988
|
+
kind: item.kind,
|
|
989
|
+
description: item.description,
|
|
990
|
+
detail: item.detail,
|
|
991
|
+
severity: item.severity,
|
|
992
|
+
defaultAdopt: !manual,
|
|
993
|
+
adopt: !manual,
|
|
994
|
+
...(conflict !== undefined ? { conflict } : {}),
|
|
995
|
+
...(item.target !== undefined ? { target: item.target } : {}),
|
|
996
|
+
};
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
/** autosync interval 类型守卫 */
|
|
1000
|
+
function isAutosyncInterval(v) {
|
|
1001
|
+
return v === '5m' || v === '15m' || v === '30m' || v === '60m' || v === '6h' || v === '12h' || v === '24h';
|
|
1002
|
+
}
|
|
1003
|
+
/** 自动同步状态响应(GET /sync/autosync 与 POST 回填;读盘计算 elapsedMs)。 */
|
|
1004
|
+
async function buildAutosyncStatus(dir, channel) {
|
|
1005
|
+
const cfg = await readAutosyncConfig(dir, channel);
|
|
1006
|
+
const elapsedMs = cfg.lastRunAt === undefined || cfg.lastRunAt === ''
|
|
1007
|
+
? -1
|
|
1008
|
+
: Math.max(0, Date.now() - Date.parse(cfg.lastRunAt));
|
|
1009
|
+
return {
|
|
1010
|
+
enabled: cfg.enabled,
|
|
1011
|
+
interval: cfg.interval,
|
|
1012
|
+
...(cfg.lastRunAt !== undefined ? { lastRunAt: cfg.lastRunAt } : {}),
|
|
1013
|
+
...(cfg.lastRunStatus !== undefined ? { lastRunStatus: cfg.lastRunStatus } : {}),
|
|
1014
|
+
...(cfg.lastRunMessage !== undefined ? { lastRunMessage: cfg.lastRunMessage } : {}),
|
|
1015
|
+
consecutiveFailures: cfg.consecutiveFailures,
|
|
1016
|
+
elapsedMs,
|
|
1017
|
+
...(cfg.lastRunHistoryId !== undefined ? { lastRunHistoryId: cfg.lastRunHistoryId } : {}),
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
/** 全部通道的自动同步状态(status 路由一次返回;UI 按当前 tab 取对应通道)。 */
|
|
1021
|
+
async function buildAutosyncStatusByChannel(dir) {
|
|
1022
|
+
const all = await readAllAutosyncConfigs(dir);
|
|
1023
|
+
const build = async (channel) => {
|
|
1024
|
+
const cfg = all[channel];
|
|
1025
|
+
const elapsedMs = cfg.lastRunAt === undefined || cfg.lastRunAt === ''
|
|
1026
|
+
? -1
|
|
1027
|
+
: Math.max(0, Date.now() - Date.parse(cfg.lastRunAt));
|
|
1028
|
+
return {
|
|
1029
|
+
enabled: cfg.enabled,
|
|
1030
|
+
interval: cfg.interval,
|
|
1031
|
+
...(cfg.lastRunAt !== undefined ? { lastRunAt: cfg.lastRunAt } : {}),
|
|
1032
|
+
...(cfg.lastRunStatus !== undefined ? { lastRunStatus: cfg.lastRunStatus } : {}),
|
|
1033
|
+
...(cfg.lastRunMessage !== undefined ? { lastRunMessage: cfg.lastRunMessage } : {}),
|
|
1034
|
+
consecutiveFailures: cfg.consecutiveFailures,
|
|
1035
|
+
elapsedMs,
|
|
1036
|
+
...(cfg.lastRunHistoryId !== undefined ? { lastRunHistoryId: cfg.lastRunHistoryId } : {}),
|
|
1037
|
+
};
|
|
1038
|
+
};
|
|
1039
|
+
return { git: await build('git'), webdav: await build('webdav') };
|
|
1040
|
+
}
|
|
1041
|
+
export function buildRestoreBody(body) {
|
|
1042
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
|
|
1043
|
+
return { ok: false, error: 'invalid JSON body' };
|
|
1044
|
+
}
|
|
1045
|
+
const record = body;
|
|
1046
|
+
const snapshotId = record['snapshotId'];
|
|
1047
|
+
if (typeof snapshotId !== 'string' || snapshotId === '') {
|
|
1048
|
+
return { ok: false, error: 'snapshotId is required' };
|
|
1049
|
+
}
|
|
1050
|
+
if (snapshotId === '.' || snapshotId === '..' || snapshotId.includes('/') || snapshotId.includes('\\')) {
|
|
1051
|
+
return { ok: false, error: zhMsg('restore.invalidSnapshotId') };
|
|
1052
|
+
}
|
|
1053
|
+
return { ok: true, value: { snapshotId, dryRun: record['dryRun'] === true } };
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* 按计划执行恢复动作(纯执行器;逐项 try/catch 不拖垮其余),
|
|
1057
|
+
* 返回与 CLI 一致的诚实报告。顺序 = 计划顺序(整文件 → 插件 → file 补偿)。
|
|
1058
|
+
* @param onAction - 每项动作执行回调(宿主路由埋点:更新 RunRegistry 进度;
|
|
1059
|
+
* index/1-based、total=计划动作数、detail=动作描述)
|
|
1060
|
+
*/
|
|
1061
|
+
export async function executeRestorePlan(plan, exec, onAction) {
|
|
1062
|
+
const report = {
|
|
1063
|
+
snapshotId: plan.snapshotId,
|
|
1064
|
+
restored: [],
|
|
1065
|
+
removedPlugins: [],
|
|
1066
|
+
manualHints: [],
|
|
1067
|
+
failed: [],
|
|
1068
|
+
skipped: [],
|
|
1069
|
+
};
|
|
1070
|
+
const total = plan.actions.length;
|
|
1071
|
+
let index = 0;
|
|
1072
|
+
for (const action of plan.actions) {
|
|
1073
|
+
index += 1;
|
|
1074
|
+
onAction?.({ index, total, detail: action.description });
|
|
1075
|
+
try {
|
|
1076
|
+
switch (action.kind) {
|
|
1077
|
+
case 'hostFileRestore':
|
|
1078
|
+
case 'fileRestore': {
|
|
1079
|
+
if (action.target === undefined || action.blobPath === undefined) {
|
|
1080
|
+
throw new Error('恢复动作缺少 target/blobPath');
|
|
1081
|
+
}
|
|
1082
|
+
if (await exec.existsHome(action.target))
|
|
1083
|
+
await exec.savePreRestore(action.target);
|
|
1084
|
+
await exec.writeHome(action.target, await exec.readBlob(action.blobPath));
|
|
1085
|
+
report.restored.push(action.target);
|
|
1086
|
+
break;
|
|
1087
|
+
}
|
|
1088
|
+
case 'hostFileRemove':
|
|
1089
|
+
case 'fileRemove': {
|
|
1090
|
+
if (action.target === undefined)
|
|
1091
|
+
throw new Error('恢复动作缺少 target');
|
|
1092
|
+
if (await exec.existsHome(action.target)) {
|
|
1093
|
+
await exec.savePreRestore(action.target);
|
|
1094
|
+
await exec.removeHome(action.target);
|
|
1095
|
+
}
|
|
1096
|
+
report.restored.push(action.target);
|
|
1097
|
+
break;
|
|
1098
|
+
}
|
|
1099
|
+
case 'pluginRemove': {
|
|
1100
|
+
if (action.pluginName === undefined)
|
|
1101
|
+
throw new Error('恢复动作缺少插件名');
|
|
1102
|
+
const result = await exec.uninstallPlugin(action.pluginName);
|
|
1103
|
+
if (result.ok) {
|
|
1104
|
+
report.removedPlugins.push(action.pluginName);
|
|
1105
|
+
}
|
|
1106
|
+
else {
|
|
1107
|
+
report.failed.push({ item: `plugin:${action.pluginName}`, reason: result.message ?? '卸载失败' });
|
|
1108
|
+
}
|
|
1109
|
+
break;
|
|
1110
|
+
}
|
|
1111
|
+
case 'credentialHint':
|
|
1112
|
+
report.manualHints.push(action.manualHint ?? action.description);
|
|
1113
|
+
break;
|
|
1114
|
+
case 'skip':
|
|
1115
|
+
report.skipped.push(action.description);
|
|
1116
|
+
break;
|
|
1117
|
+
default:
|
|
1118
|
+
report.skipped.push(`未知动作 ${String(action.kind)}: ${action.description}`);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
catch (err) {
|
|
1122
|
+
report.failed.push({
|
|
1123
|
+
item: action.target ?? action.pluginName ?? action.description,
|
|
1124
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
return report;
|
|
1129
|
+
}
|
|
1130
|
+
/** 宿主 restore 执行器装配:ctx.fs(home-relative)+ 快照目录(node fs)+ runDshPlugin。 */
|
|
1131
|
+
function makeRestoreExecutor(snapshotDir, host, profile) {
|
|
1132
|
+
const profileDir = resolveProfileDir(host.homeDir, profile);
|
|
1133
|
+
let seq = 0;
|
|
1134
|
+
return {
|
|
1135
|
+
readBlob: async (blobPath) => {
|
|
1136
|
+
const target = resolve(snapshotDir, blobPath);
|
|
1137
|
+
if (!isSameOrChild(target, snapshotDir))
|
|
1138
|
+
throw new Error(msgOf(host)('host.restoreBlobEscape', { blob: blobPath }));
|
|
1139
|
+
return fs.readFile(target);
|
|
1140
|
+
},
|
|
1141
|
+
savePreRestore: async (relPath) => {
|
|
1142
|
+
const data = await host.fs.readFile(relPath);
|
|
1143
|
+
seq += 1;
|
|
1144
|
+
const safe = relPath.replace(/[\\/:*?"<>|]/g, '_');
|
|
1145
|
+
await fs.mkdir(join(snapshotDir, 'pre-restore'), { recursive: true });
|
|
1146
|
+
await fs.writeFile(join(snapshotDir, 'pre-restore', `${String(seq).padStart(4, '0')}-${safe}`), data);
|
|
1147
|
+
},
|
|
1148
|
+
existsHome: (relPath) => host.fs.exists(relPath),
|
|
1149
|
+
writeHome: (relPath, data) => host.fs.writeFile(relPath, data),
|
|
1150
|
+
removeHome: (relPath) => host.fs.remove(relPath),
|
|
1151
|
+
uninstallPlugin: async (name) => {
|
|
1152
|
+
const result = await runDshPlugin(profileDir, profile, ['remove', name]);
|
|
1153
|
+
if (result.exitCode === 0)
|
|
1154
|
+
return { ok: true };
|
|
1155
|
+
const output = `${result.stderr}\n${result.stdout}`.trim();
|
|
1156
|
+
const tail = output.split('\n').slice(-8).join('\n') || msgOf(host)('host.restoreNoOutput');
|
|
1157
|
+
return { ok: false, message: msgOf(host)('host.restoreUninstallFailed', { name, code: String(result.exitCode), tail }) };
|
|
1158
|
+
},
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* 解析「一键上传/我的配置」请求体的 form 字段:仅 { name, description?, categories?, mode? }。
|
|
1163
|
+
* name 必填(非空字符串,trim 后取);description 可选字符串;categories 可选字符串数组;
|
|
1164
|
+
* mode 可选 'migrate' | 'share'(F6 分享模式,非法值忽略→缺省 migrate)。非法 → null(调用方返回 400)。
|
|
1165
|
+
*/
|
|
1166
|
+
function parseMeForm(raw) {
|
|
1167
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
1168
|
+
return null;
|
|
1169
|
+
const obj = raw;
|
|
1170
|
+
const name = typeof obj['name'] === 'string' ? obj['name'].trim() : '';
|
|
1171
|
+
if (name === '')
|
|
1172
|
+
return null;
|
|
1173
|
+
const form = { name };
|
|
1174
|
+
// update 模式的可选显式 id(「更新」按钮预填;upload 时省略)
|
|
1175
|
+
const idRaw = obj['id'];
|
|
1176
|
+
if (typeof idRaw === 'string' && idRaw.trim() !== '')
|
|
1177
|
+
form.id = idRaw.trim();
|
|
1178
|
+
const description = obj['description'];
|
|
1179
|
+
if (typeof description === 'string' && description.trim() !== '')
|
|
1180
|
+
form.description = description.trim();
|
|
1181
|
+
const categoriesRaw = obj['categories'];
|
|
1182
|
+
if (Array.isArray(categoriesRaw)) {
|
|
1183
|
+
const categories = categoriesRaw.filter((c) => typeof c === 'string' && c.trim() !== '');
|
|
1184
|
+
if (categories.length > 0)
|
|
1185
|
+
form.categories = categories;
|
|
1186
|
+
}
|
|
1187
|
+
// F6 分享模式:仅接受字面量 'share' / 'migrate'(其余忽略 → 缺省 migrate),随 form 透传 MyRepoService
|
|
1188
|
+
if (obj['mode'] === 'share' || obj['mode'] === 'migrate')
|
|
1189
|
+
form.mode = obj['mode'];
|
|
1190
|
+
return form;
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* GitHub 凭据「缺失或失效」判定(issue #29):`no_token`(credentials 里从未配置 token)与
|
|
1194
|
+
* `unauthorized`(401,token 过期/被撤销)对用户都是同一个「未登录」,必须走同一分支——
|
|
1195
|
+
* 否则 `no_token` 会落到 500,UI 把「未登录」渲染成「登录状态读取失败」的误导性横幅。
|
|
1196
|
+
* 其余分类(network_error / rate_limited / server_error / validation_failed / fork_timeout…)
|
|
1197
|
+
* 是真实故障,仍按 500 暴露,绝不伪装成「未登录」。
|
|
1198
|
+
*/
|
|
1199
|
+
export function isGitHubAuthMissing(error) {
|
|
1200
|
+
return error instanceof GitHubAuthError && (error.code === 'unauthorized' || error.code === 'no_token');
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* 读取插件诊断信息(issue #28):把「插件到底读了哪个目录 / 哪个 profile / 看到什么」变成
|
|
1204
|
+
* 用户可自查的数据——此前只存在于宿主内部,导致「装了插件却识别不到」无从定位。
|
|
1205
|
+
* best-effort:失败不抛出(诊断位缺失不应拖垮 /status)。
|
|
1206
|
+
*/
|
|
1207
|
+
async function readPluginDiagnostics(host) {
|
|
1208
|
+
try {
|
|
1209
|
+
const profileDir = resolveProfileDir(host.homeDir, host.profile ?? 'web');
|
|
1210
|
+
const manifest = readProfileManifest(profileDir);
|
|
1211
|
+
const installed = await host.plugins.listInstalled();
|
|
1212
|
+
const bundles = manifest?.dsh?.profile?.bundles;
|
|
1213
|
+
return {
|
|
1214
|
+
homeDir: host.homeDir,
|
|
1215
|
+
profile: host.profile ?? 'web',
|
|
1216
|
+
profileManifestReadable: manifest !== null,
|
|
1217
|
+
installedPluginCount: installed.length,
|
|
1218
|
+
installedPluginNames: installed.map((p) => p.name),
|
|
1219
|
+
bundles: Array.isArray(bundles) ? bundles : [],
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
catch (err) {
|
|
1223
|
+
host.log.warn(`plugin diagnostics unavailable: ${err instanceof Error ? err.message : String(err)}`);
|
|
1224
|
+
return {};
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
/** Build the /api/dsh-config-manager route family. */
|
|
1228
|
+
function makeRoutes(deps) {
|
|
1229
|
+
const { host, adapters, exportsDir, tmpDir, snapshotsDir, runs, syncDir, dataDir, credentials, githubClientId, githubClientSecret, history } = deps;
|
|
1230
|
+
/**
|
|
1231
|
+
* Phase 1 P0-1/P0-2:配置生命周期服务(自动快照 / 撤销 / 重做)。
|
|
1232
|
+
*
|
|
1233
|
+
* 与既有 `snapshotsDir`(导入前快照,plan 驱动)**分目录**:那里的快照只登记
|
|
1234
|
+
* 「本次导入将写入的目标」,无法回答「配置整体变没变」;本服务的快照是状态驱动,
|
|
1235
|
+
* 供撤销/重做与自动快照使用。两者保留策略与回放方式都不同,混用会产生错误语义。
|
|
1236
|
+
*
|
|
1237
|
+
* watchFactory 用真 fs.watch 注入(core 侧只依赖抽象 → 测试可完全驱动时序)。
|
|
1238
|
+
* 监听不在此处启动:由 apply() 在「启动 recovery 分类完成且 NORMAL」后启动,
|
|
1239
|
+
* 避免恢复进行中就开拍。
|
|
1240
|
+
*/
|
|
1241
|
+
const lifecycle = new ConfigLifecycle({
|
|
1242
|
+
dir: join(dataDir, 'config-snapshots'),
|
|
1243
|
+
adapters,
|
|
1244
|
+
ctx: host,
|
|
1245
|
+
profile: host.profile ?? 'web',
|
|
1246
|
+
applyOrder: APPLY_ORDER,
|
|
1247
|
+
onWarn: (message, detail) => {
|
|
1248
|
+
host.log.warn(`[lifecycle] ${message}${detail !== undefined ? `: ${detail instanceof Error ? detail.message : String(detail)}` : ''}`);
|
|
1249
|
+
},
|
|
1250
|
+
watchFactory: (dir, onEvent) => fsWatch(dir, (eventType, filename) => onEvent(eventType, filename)),
|
|
1251
|
+
});
|
|
1252
|
+
const roots = [exportsDir, tmpDir];
|
|
1253
|
+
/**
|
|
1254
|
+
* Phase 6:迁移历史 best-effort 追加(写失败不阻断操作,但记录/降级,不静默丢)。
|
|
1255
|
+
* 所有 destructive/migration 结果确定后调用。历史写盘 ms 级,失败仅日志 + 可选告警字段。
|
|
1256
|
+
*/
|
|
1257
|
+
const tryAppendHistory = async (raw) => {
|
|
1258
|
+
try {
|
|
1259
|
+
const res = await history.append(raw);
|
|
1260
|
+
if (!res.ok) {
|
|
1261
|
+
host.log.warn('迁移历史写入失败', { kind: raw.kind, error: res.error });
|
|
1262
|
+
return res.error;
|
|
1263
|
+
}
|
|
1264
|
+
return undefined;
|
|
1265
|
+
}
|
|
1266
|
+
catch (error) {
|
|
1267
|
+
host.log.warn('迁移历史写入异常', { kind: raw.kind, error: error instanceof Error ? error.message : String(error) });
|
|
1268
|
+
return error instanceof Error ? error.message : String(error);
|
|
1269
|
+
}
|
|
1270
|
+
};
|
|
1271
|
+
/**
|
|
1272
|
+
* 从快照目录读取 entries 的 adapter id 集(用于 restore / snapshot-prune 历史 sections)。
|
|
1273
|
+
* 读取失败 → 空数组(best-effort;sections 仅用于审计摘要,不影响功能)。
|
|
1274
|
+
*/
|
|
1275
|
+
const snapshotEntrySections = async (snapshotDir) => {
|
|
1276
|
+
try {
|
|
1277
|
+
const raw = await fs.readFile(join(snapshotDir, 'snapshot.json'), 'utf8');
|
|
1278
|
+
const parsed = JSON.parse(raw);
|
|
1279
|
+
if (parsed === null || typeof parsed !== 'object' || !Array.isArray(parsed.entries))
|
|
1280
|
+
return [];
|
|
1281
|
+
return Array.from(new Set(parsed.entries.map((e) => e.adapter).filter((s) => typeof s === 'string' && s !== '')));
|
|
1282
|
+
}
|
|
1283
|
+
catch {
|
|
1284
|
+
return [];
|
|
1285
|
+
}
|
|
1286
|
+
};
|
|
1287
|
+
/**
|
|
1288
|
+
* Phase 6:自动快照保留清理(snapshot-prune)迁移历史(best-effort)。
|
|
1289
|
+
* 由 FileSnapshotStore.prune 经 onPrune 回调触发;fire-and-forget 不阻塞保存。
|
|
1290
|
+
*/
|
|
1291
|
+
const tryAppendSnapshotPrune = async (removedIds) => {
|
|
1292
|
+
if (removedIds.length === 0)
|
|
1293
|
+
return;
|
|
1294
|
+
try {
|
|
1295
|
+
await history.append({
|
|
1296
|
+
kind: 'snapshot-prune',
|
|
1297
|
+
result: 'success',
|
|
1298
|
+
sections: [],
|
|
1299
|
+
source: 'api',
|
|
1300
|
+
summary: `自动保留清理删除 ${removedIds.length} 个旧快照`,
|
|
1301
|
+
});
|
|
1302
|
+
}
|
|
1303
|
+
catch (error) {
|
|
1304
|
+
host.log.warn('快照保留清理历史写入失败(best-effort)', { error: error instanceof Error ? error.message : String(error) });
|
|
1305
|
+
}
|
|
1306
|
+
};
|
|
1307
|
+
/** m-github-oauth:宿主侧设备码登记表 + auth 客户端(进程生命周期;device_code 只存内存) */
|
|
1308
|
+
const githubFlows = new DeviceFlowStore();
|
|
1309
|
+
const githubAuth = new GitHubAuthClient();
|
|
1310
|
+
const msg = host.msg;
|
|
1311
|
+
/** 导入 run 的「当前计划项」中止控制器(/execute 登记,/execute/skip 定位 abort)。
|
|
1312
|
+
* 进程生命周期内存登记;同 kind 并发被 RunRegistry 拒绝,单 run 恒只有一个当前项。 */
|
|
1313
|
+
const runAbortControllers = new Map();
|
|
1314
|
+
/** 已知 adapter id 集合(push 请求体 sections 校验用)。 */
|
|
1315
|
+
const knownSyncSectionIds = new Set(adapters.map((a) => a.id));
|
|
1316
|
+
/** 可同步分区目录(status 回填 UI「高级/自定义导出」勾选列表)。
|
|
1317
|
+
*
|
|
1318
|
+
* 含**全部已挂载分区**(不再按 portability 过滤):改造一取消了 portability 对同步范围的
|
|
1319
|
+
* 限制,所有分区都可勾选;这里若仍只列 portable,mcp/workspaces/credentialsStatus/
|
|
1320
|
+
* pluginFiles/sessions 就永远无法被勾选,用户既看不到也同步不了。
|
|
1321
|
+
* 列表顺序即 adapters 顺序;defaultIncluded 由 UI 用于「推荐分区」默认勾选与计数。 */
|
|
1322
|
+
const syncSectionCatalog = adapters
|
|
1323
|
+
.map((a) => ({ id: a.id, displayName: a.displayName, portability: a.portability, defaultIncluded: a.defaultIncluded }));
|
|
1324
|
+
const makeImporter = () => new Importer({
|
|
1325
|
+
ctx: host,
|
|
1326
|
+
adapters,
|
|
1327
|
+
snapshotStore: new FileSnapshotStore({
|
|
1328
|
+
dir: snapshotsDir,
|
|
1329
|
+
// Phase 4 F3:active/quarantine 未收敛 journal 引用的 snapshot 绝不自动 prune
|
|
1330
|
+
referencedSnapshotIds: () => host.phase3Recovery?.store.listReferencedSnapshotIds() ?? Promise.resolve(new Set()),
|
|
1331
|
+
// Phase 6:自动保留清理 → snapshot-prune 迁移历史(best-effort)
|
|
1332
|
+
onPrune: (removedIds) => { void tryAppendSnapshotPrune(removedIds); },
|
|
1333
|
+
}),
|
|
1334
|
+
parseZipOverride: createHardenedZipParser(),
|
|
1335
|
+
dependencyChecker: dependencyAvailable,
|
|
1336
|
+
msg,
|
|
1337
|
+
});
|
|
1338
|
+
/** m-profiles:配置档案管理器(<dataDir>/profiles/<name>/profile.json;切换复用同一快照/回滚管道) */
|
|
1339
|
+
const profiles = new ProfileManager({
|
|
1340
|
+
dataDir,
|
|
1341
|
+
ctx: host,
|
|
1342
|
+
adapters,
|
|
1343
|
+
snapshotStore: new FileSnapshotStore({
|
|
1344
|
+
dir: snapshotsDir,
|
|
1345
|
+
// Phase 4 F3:recovery 引用保护
|
|
1346
|
+
referencedSnapshotIds: () => host.phase3Recovery?.store.listReferencedSnapshotIds() ?? Promise.resolve(new Set()),
|
|
1347
|
+
// Phase 6:自动保留清理 → snapshot-prune 迁移历史(best-effort)
|
|
1348
|
+
onPrune: (removedIds) => { void tryAppendSnapshotPrune(removedIds); },
|
|
1349
|
+
}),
|
|
1350
|
+
});
|
|
1351
|
+
/** Fence + method guard (mirrors dsh-ssh). */
|
|
1352
|
+
const guard = (req, res, method) => {
|
|
1353
|
+
if (!isLoopbackRequest(req)) {
|
|
1354
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' });
|
|
1355
|
+
return false;
|
|
1356
|
+
}
|
|
1357
|
+
if (req.method !== method) {
|
|
1358
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` });
|
|
1359
|
+
return false;
|
|
1360
|
+
}
|
|
1361
|
+
return true;
|
|
1362
|
+
};
|
|
1363
|
+
/**
|
|
1364
|
+
* Phase 2 跨进程锁路由门(destructive 公共入口)。
|
|
1365
|
+
* 包裹一个 mutation handler:进入前 acquire GLOBAL 环境锁(无 lock 配置 → 直接放行),
|
|
1366
|
+
* 被另一进程/操作持有(含同进程另一操作)→ 409/423 拒绝;执行后 finally 释放。
|
|
1367
|
+
* 嵌套调用(rollback / applyItems 内部 executeImportPlan)在外层已持锁区域内运行,绝不 reacquire。
|
|
1368
|
+
* Phase 3 SAFE MODE:isBlocked 注入谓词(host.safeModeIsBlocked)被挡 → 423(不执行 destructive)。
|
|
1369
|
+
*/
|
|
1370
|
+
const withMutationGate = (op, handler, opts) => {
|
|
1371
|
+
return async (req, res) => {
|
|
1372
|
+
try {
|
|
1373
|
+
await runWithMutationLock(host.mutationLock, { op, isBlocked: () => host.safeModeIsBlocked?.() ?? false }, async (lockCtx) => {
|
|
1374
|
+
// Step 3 P0-A:所有被 gate 覆盖的 destructive 路由在已持锁下创建 durable journal
|
|
1375
|
+
// (runJournaled 不 double-acquire、不 release;锁由本 gate 的 finally 释放)。
|
|
1376
|
+
if (host.phase3Recovery !== undefined && (opts?.journaled ?? true) && lockCtx !== null) {
|
|
1377
|
+
await host.phase3Recovery.runJournaled({
|
|
1378
|
+
operationType: op,
|
|
1379
|
+
lockCtx,
|
|
1380
|
+
// Phase 4:生产 snapshot 接线。deferredSnapshot = plan 在 handler 内解析后,
|
|
1381
|
+
// 引擎创建 op-bound snapshot 并 bindSnapshot + markApplying(首个 destructive side effect 前)。
|
|
1382
|
+
deferredSnapshot: opts?.deferredSnapshot ?? false,
|
|
1383
|
+
fn: async (journalCtx) => { await handler(req, res, lockCtx, journalCtx); },
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
else {
|
|
1387
|
+
await handler(req, res, lockCtx ?? undefined, undefined);
|
|
1388
|
+
}
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
1391
|
+
catch (error) {
|
|
1392
|
+
if (error instanceof EnvironmentLockUnavailableError) {
|
|
1393
|
+
// 内部诊断(op/reason)进日志;用户只看到友好文案(error.message 恒为中文友好版,
|
|
1394
|
+
// 不暴露环境锁/op/路径等技术细节)。
|
|
1395
|
+
host.log.warn(`mutation lock blocked: op=${error.op} reason=${error.reason}${error.detail !== undefined ? ` detail=${error.detail}` : ''}`);
|
|
1396
|
+
writeJson(res, 423, { error: error.message, code: 'mutation-locked' });
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
// 非 423:若已由 runJournaled 置 SAFE MODE/失败,保持既有错误语义(400/500)
|
|
1400
|
+
if (error instanceof TransactionRecoveryRequiredError) {
|
|
1401
|
+
writeJson(res, 423, { error: error.message, code: 'transaction-recovery-required' });
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
throw error;
|
|
1405
|
+
}
|
|
1406
|
+
};
|
|
1407
|
+
};
|
|
1408
|
+
// ------------------------------------------------- sync 路由装配(m-sync-ui)
|
|
1409
|
+
// 请求级装配:每次 push/pull 从请求体取 repoUrl,token 非空先写入 DSH
|
|
1410
|
+
// credentials(只存值不落盘同步文件/日志),git 网络操作时经 resolve 现取 ——
|
|
1411
|
+
// 与 GitTransport「token 只从注入 provider 读取」的安全契约完全对齐。
|
|
1412
|
+
/**
|
|
1413
|
+
* 解析同步请求体并补全缺失字段(委托给导出的 parseSyncBody,便于单测)。
|
|
1414
|
+
* username 回退:webdav 请求体未带 username(如挂载时 snapshotsList 自动加载、
|
|
1415
|
+
* 表单留空后直接同步)时,从持久化 sync-config 回填已保存的 username——
|
|
1416
|
+
* 否则 WebDavTransport 构造会因空 username 抛错,导致「保存过配置仍无法列出快照」。
|
|
1417
|
+
* 语义与 password 一致:留空 = 沿用已保存凭据。
|
|
1418
|
+
*/
|
|
1419
|
+
const prepareSync = async (body) => {
|
|
1420
|
+
const cfg = await parseSyncBody(body, { credentials });
|
|
1421
|
+
if (isWebDavConfig(cfg) && (cfg.webdav.username === undefined || cfg.webdav.username === '')) {
|
|
1422
|
+
try {
|
|
1423
|
+
const persisted = await readSyncConfig(syncDir);
|
|
1424
|
+
return mergePersistedWebDavUsername(cfg, persisted);
|
|
1425
|
+
}
|
|
1426
|
+
catch {
|
|
1427
|
+
return cfg; // 读失败保持原值(空 username 由 WebDavTransport 构造校验兜底报错)
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
return cfg;
|
|
1431
|
+
};
|
|
1432
|
+
/** 同步分区选择缓存(按通道;sync-selection.json;makeSyncEngine 同步读取用,保存路由更新)。
|
|
1433
|
+
* 缺失通道 = 尚未加载(启动竞态窗口);读取/使用处兜底 defaultSyncSelection。 */
|
|
1434
|
+
const selectionCache = {};
|
|
1435
|
+
void readAllSyncSelections(syncDir).then((all) => {
|
|
1436
|
+
selectionCache.git = all.git;
|
|
1437
|
+
selectionCache.webdav = all.webdav;
|
|
1438
|
+
}).catch(() => { });
|
|
1439
|
+
/** 确保指定通道缓存已加载(status/save 路由调用;启动竞态兜底)。 */
|
|
1440
|
+
const ensureSelectionLoaded = async (channel) => {
|
|
1441
|
+
const cached = selectionCache[channel];
|
|
1442
|
+
if (cached !== undefined)
|
|
1443
|
+
return cached;
|
|
1444
|
+
try {
|
|
1445
|
+
const sel = await readSyncSelection(syncDir, channel);
|
|
1446
|
+
selectionCache[channel] = sel;
|
|
1447
|
+
return sel;
|
|
1448
|
+
}
|
|
1449
|
+
catch {
|
|
1450
|
+
const fallback = defaultSyncSelection();
|
|
1451
|
+
selectionCache[channel] = fallback;
|
|
1452
|
+
return fallback;
|
|
1453
|
+
}
|
|
1454
|
+
};
|
|
1455
|
+
/** 指定通道的分区选择视图({ mode, sections },无 schemaVersion)。 */
|
|
1456
|
+
const selectionView = async (channel) => {
|
|
1457
|
+
const sel = await ensureSelectionLoaded(channel);
|
|
1458
|
+
return { mode: sel.mode, sections: sel.sections };
|
|
1459
|
+
};
|
|
1460
|
+
/** 全部通道的分区选择视图(status 路由一次返回;UI 按当前 tab 取对应通道)。 */
|
|
1461
|
+
const selectionViewByChannel = async () => {
|
|
1462
|
+
const all = await readAllSyncSelections(syncDir);
|
|
1463
|
+
selectionCache.git = all.git;
|
|
1464
|
+
selectionCache.webdav = all.webdav;
|
|
1465
|
+
const view = (sel) => ({ mode: sel.mode, sections: sel.sections });
|
|
1466
|
+
return { git: view(all.git), webdav: view(all.webdav) };
|
|
1467
|
+
};
|
|
1468
|
+
/** 构造 SyncEngine:按 transport 分支构造对应传输(git → GitTransport;webdav → WebDavTransport)。
|
|
1469
|
+
* 同步范围(sections)来自持久化分区选择:advanced 模式 → 只处理勾选分区,
|
|
1470
|
+
* 自动同步(merge/apply/push 全链路)与手动 push 共用此配置。 */
|
|
1471
|
+
const makeSyncEngine = (cfg) => {
|
|
1472
|
+
let transport;
|
|
1473
|
+
if (isWebDavConfig(cfg)) {
|
|
1474
|
+
transport = new WebDavTransport({
|
|
1475
|
+
baseUrl: webdavBaseUrl(cfg),
|
|
1476
|
+
username: cfg.webdav.username ?? '',
|
|
1477
|
+
credentials: {
|
|
1478
|
+
getPassword: async () => {
|
|
1479
|
+
const resolved = await credentials.resolve(credentialRef(SYNC_WEBDAV_CREDENTIAL_REF));
|
|
1480
|
+
return resolved?.value ?? '';
|
|
1481
|
+
},
|
|
1482
|
+
},
|
|
1483
|
+
// 显式传超时:不依赖默认值,慢速 WebDAV 上传大快照有足够窗口
|
|
1484
|
+
timeoutMs: WEBDAV_TIMEOUT_MS,
|
|
1485
|
+
msg,
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1488
|
+
else {
|
|
1489
|
+
transport = new GitTransport({
|
|
1490
|
+
repoUrl: cfg.git.repoUrl,
|
|
1491
|
+
workDir: join(syncDir, 'work'),
|
|
1492
|
+
credentials: {
|
|
1493
|
+
getToken: async () => {
|
|
1494
|
+
const resolved = await credentials.resolve(credentialRef(SYNC_CREDENTIAL_REF));
|
|
1495
|
+
return resolved?.value ?? '';
|
|
1496
|
+
},
|
|
1497
|
+
},
|
|
1498
|
+
msg,
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
const channel = isWebDavConfig(cfg) ? 'webdav' : 'git';
|
|
1502
|
+
const sections = effectiveSections(selectionCache[channel] ?? defaultSyncSelection());
|
|
1503
|
+
return new SyncEngine({
|
|
1504
|
+
ctx: host,
|
|
1505
|
+
transport,
|
|
1506
|
+
stateDir: syncDir,
|
|
1507
|
+
adapters,
|
|
1508
|
+
importer: makeImporter(),
|
|
1509
|
+
localSnapshotsDir: join(syncDir, 'snapshots'),
|
|
1510
|
+
zipDir: tmpDir,
|
|
1511
|
+
msg,
|
|
1512
|
+
...(sections === undefined ? {} : { sections }),
|
|
1513
|
+
});
|
|
1514
|
+
};
|
|
1515
|
+
/** 一键同步差异确认会话存储(进程内存;/sync/sync 预览 → /sync/apply-items 逐项执行解耦) */
|
|
1516
|
+
const syncSessions = new SyncSessionStore();
|
|
1517
|
+
/** 自动同步后台调度器(宿主进程生命周期,不依赖浏览器) */
|
|
1518
|
+
const scheduler = new AutoSyncScheduler({
|
|
1519
|
+
syncDir,
|
|
1520
|
+
host,
|
|
1521
|
+
makeSyncEngine,
|
|
1522
|
+
msg,
|
|
1523
|
+
runs,
|
|
1524
|
+
mutationLock: host.mutationLock,
|
|
1525
|
+
isBlocked: () => host.safeModeIsBlocked?.() ?? false,
|
|
1526
|
+
phase3Recovery: host.phase3Recovery,
|
|
1527
|
+
// Phase 6:autosync 既写 sync-history.json(既有语义),也写统一迁移历史(COMPLETE 不变量)。
|
|
1528
|
+
appendHistoryFn: async (entry) => {
|
|
1529
|
+
await appendAutosyncEntry(syncDir, entry).catch(() => undefined);
|
|
1530
|
+
await history.append({
|
|
1531
|
+
kind: 'autosync',
|
|
1532
|
+
result: entry.status === 'success' ? 'success' : entry.status === 'skipped' ? 'skipped' : 'failed',
|
|
1533
|
+
sections: entry.appliedSections ?? [],
|
|
1534
|
+
source: 'autosync',
|
|
1535
|
+
summary: `自动同步 ${entry.direction}${entry.transport !== undefined ? `(${entry.transport})` : ''}`,
|
|
1536
|
+
error: entry.status === 'failed' ? (entry.error ?? entry.skipReason) : undefined,
|
|
1537
|
+
}).catch(() => undefined);
|
|
1538
|
+
},
|
|
1539
|
+
});
|
|
1540
|
+
// P1-B:调度器不再在 makeRoutes 内同步 start —— 由 apply() 在「启动 recovery 分类完成后、仅 NORMAL」时启动。
|
|
1541
|
+
// ============================================================ Phase 5 recovery orchestration
|
|
1542
|
+
// Recovery 路由**禁用 withMutationGate**(避免 double-journal:recovery 复用被恢复 operation 的
|
|
1543
|
+
// 现有 journal,不新建)。mutation 路由只经 withMutationLock(Phase 2 GLOBAL 锁)+ loopback fence,
|
|
1544
|
+
// **不传 isBlocked**(recovery 是解决 SAFE MODE 的机制,若被 SAFE MODE 阻断会死锁)。
|
|
1545
|
+
// 只读路由(status/preview)不持锁。权威 snapshotId 只来自 j.snapshotId(不接受请求体覆盖)。
|
|
1546
|
+
// 编排逻辑在 src/core/recovery-orchestrator.ts(可测纯编排层)。
|
|
1547
|
+
const recoveryOrchestrator = createRecoveryOrchestrator({
|
|
1548
|
+
store: host.phase3Recovery?.store ?? new JournalStore({ transactionsDir: join(dataDir, 'transactions') }),
|
|
1549
|
+
runs,
|
|
1550
|
+
snapshotsDir,
|
|
1551
|
+
host,
|
|
1552
|
+
msg,
|
|
1553
|
+
snapshotExists: async (snapshotId, binding) => {
|
|
1554
|
+
if (host.phase3Recovery === undefined)
|
|
1555
|
+
return false;
|
|
1556
|
+
return host.phase3Recovery.recoveryHooks.snapshotExists(snapshotId, binding);
|
|
1557
|
+
},
|
|
1558
|
+
// 动态 getter:环境指纹在 fire-and-forget 启动分类块(initFingerprint)完成后才就绪,
|
|
1559
|
+
// 创建期捕获会拿到 'unknown' 初值 → 后续 recovery API 误判 WRONG_ENVIRONMENT。
|
|
1560
|
+
// 改动态读取保证 API 调用时取到真实指纹(recovery-orchestrator 已改为 getter 语义)。
|
|
1561
|
+
getEnvironmentFingerprint: () => host.phase3Recovery?.recoveryEnvFingerprint ?? 'unknown',
|
|
1562
|
+
// 清除 SAFE MODE:同时重置内存标志(isBlocked 读它)与 durable 标记。
|
|
1563
|
+
// 仅当 recovery 成功且无其他未解决 incident 时由编排器调用(§5.3 / §10.2)。
|
|
1564
|
+
clearSafeMode: async () => {
|
|
1565
|
+
if (host.phase3Recovery !== undefined)
|
|
1566
|
+
await host.phase3Recovery.clearSafeMode();
|
|
1567
|
+
},
|
|
1568
|
+
// issue #31:环境锁只读探测 + 显式回收,供「事故恢复」面板显示/处理**残留锁**。
|
|
1569
|
+
// 残留锁不是 journal(journalId 恒 null、transactions/active 为空),旧面板因此恒空。
|
|
1570
|
+
inspectLockState: async () => {
|
|
1571
|
+
const port = host.mutationLock;
|
|
1572
|
+
if (port === undefined)
|
|
1573
|
+
return { state: 'FREE' };
|
|
1574
|
+
const insp = await port.inspectLockState();
|
|
1575
|
+
return { state: insp.state, ...(insp.detail !== undefined ? { detail: insp.detail } : {}) };
|
|
1576
|
+
},
|
|
1577
|
+
recoverStaleLock: async () => {
|
|
1578
|
+
const port = host.mutationLock;
|
|
1579
|
+
// 无锁端口(测试/未接线)→ 无可回收对象,诚实拒绝而非谎称成功
|
|
1580
|
+
if (port === undefined)
|
|
1581
|
+
return { ok: false, removed: false, state: 'FREE', detail: 'no lock port configured' };
|
|
1582
|
+
return port.recoverStaleLock();
|
|
1583
|
+
},
|
|
1584
|
+
});
|
|
1585
|
+
/** 构造 recovery 执行器(restore / rollback),供 execute/retry 注入(runId 用于进度埋点)。 */
|
|
1586
|
+
const makeRecoveryExecutors = (runId) => ({
|
|
1587
|
+
performRestore: async (snapshotId) => {
|
|
1588
|
+
const dir = join(snapshotsDir, snapshotId);
|
|
1589
|
+
const restoreOpts = {
|
|
1590
|
+
snapshotDir: dir, homeDir: host.homeDir, profile: host.profile, settingsPath: undefined, msg,
|
|
1591
|
+
snapshotsRoot: snapshotsDir, environmentFingerprint: host.phase3Recovery?.recoveryEnvFingerprint ?? 'unknown', requireOperationBound: true,
|
|
1592
|
+
};
|
|
1593
|
+
const plan = await planRestore(restoreOpts);
|
|
1594
|
+
const report = await executeRestorePlan(plan, makeRestoreExecutor(dir, host, host.profile), (info) => {
|
|
1595
|
+
runs.update(runId, { section: 'recovery', item: info.index, itemTotal: info.total, detail: info.detail });
|
|
1596
|
+
});
|
|
1597
|
+
return { full: report.failed.length === 0, failed: report.failed.map((f) => f.item) };
|
|
1598
|
+
},
|
|
1599
|
+
performRollback: async (snapshotId) => {
|
|
1600
|
+
const store = new FileSnapshotStore({ dir: snapshotsDir });
|
|
1601
|
+
const snap = await store.load(snapshotId);
|
|
1602
|
+
const report = await performRollback({ ctx: host, snapshot: snap, store, adapters });
|
|
1603
|
+
return { full: report.full, failed: report.failed.map((f) => f.item) };
|
|
1604
|
+
},
|
|
1605
|
+
});
|
|
1606
|
+
const routesList = [
|
|
1607
|
+
// ------------------------------------------------------------- status
|
|
1608
|
+
// ------------------------------------------------------------- export
|
|
1609
|
+
// ---------------------------------------------------- export-preview
|
|
1610
|
+
// P2-⑫:导出前只读预览(不落盘 ZIP):对选中分区逐个 adapter.export 收集 counts
|
|
1611
|
+
// (与真实导出一致的 secret 剥离,不导出任何值),估算 JSON 载荷大小,返回可展示摘要。
|
|
1612
|
+
// 零写入;loopback fence 必备。
|
|
1613
|
+
// ------------------------------------------------------------ download
|
|
1614
|
+
// -------------------------------------------------------------- upload
|
|
1615
|
+
// ------------------------------------------------------ decrypt-archive
|
|
1616
|
+
// 整体加密备份容器的解锁(只读,零写入到任何配置):用备份密码解密上传的加密容器,
|
|
1617
|
+
// 得到明文 ZIP 写入受控临时目录并返回新 zipPath,供 analyze/plan/execute 引用。
|
|
1618
|
+
// 导出时容器密码与内部 secrets.enc 密码同源(同一 password 派生两层加密),
|
|
1619
|
+
// 因此顺带在明文 ZIP 上解出内部凭据覆盖清单(refs,非值)一并返回——
|
|
1620
|
+
// 导入全程只需输入这一次密码,无需第二个密码校验页面。
|
|
1621
|
+
// 密码仅内存随请求体传入,绝不落盘/落日志;解出的明文 ZIP 亦为临时文件,导入结束后清理。
|
|
1622
|
+
// ------------------------------------------------------------- analyze
|
|
1623
|
+
// ---------------------------------------------------------------- plan
|
|
1624
|
+
// ------------------------------------------------------------ progress
|
|
1625
|
+
// m1:查询单个 run 的实时状态(轮询 / 刷新恢复用;runId 不可猜,走 loopback-only 守卫)
|
|
1626
|
+
// ----------------------------------------------------------------- runs
|
|
1627
|
+
// m1:列出当前活跃(running)的 run(刷新恢复时重新订阅进度用)
|
|
1628
|
+
// ------------------------------------------------------------- execute
|
|
1629
|
+
// -------------------------------------------------- execute/skip
|
|
1630
|
+
// 用户跳过当前计划项(导入中,目前仅插件安装):abort 当前项的中止控制器 → 引擎
|
|
1631
|
+
// 捕获 ImportUserSkippedError 记为 user-skipped,导入继续执行其余项。
|
|
1632
|
+
// ---------------------------------------------------------- snapshots
|
|
1633
|
+
// M4:列出快照元信息(id/createdAt/sourceZip/status/计数,createdAt 倒序)
|
|
1634
|
+
// ------------------------------------------------------------ restore
|
|
1635
|
+
// M4:快照恢复。dryRun=true 只返回动作计划(planRestore,零写入);
|
|
1636
|
+
// 真实执行 = 计划 → 宿主执行器(ctx.fs 整文件/文件还原 + runDshPlugin 卸载插件)
|
|
1637
|
+
// → 与 CLI 一致的诚实报告 { restored/removedPlugins/manualHints/failed/skipped }。
|
|
1638
|
+
//
|
|
1639
|
+
// **并发防护(P1-1)**:真实执行(dryRun=false)经 runs.register('restore') 登记——
|
|
1640
|
+
// 同 kind 已有 running 时抛 RunConflictError → 409 拒绝。这是宿主侧的权威防重
|
|
1641
|
+
// (前端 loading 只是 UX):即使两个 tab / 刷新后重复点击,同一时刻至多一个
|
|
1642
|
+
// restore 在执行(不同快照并发恢复会交错写文件,同快照并发会互相覆盖
|
|
1643
|
+
// pre-restore 双保险备份,都是真实数据风险)。进度经 onAction 埋点更新
|
|
1644
|
+
// RunRegistry(/progress 轮询 + /runs 刷新恢复可见);响应含 runId。
|
|
1645
|
+
// ------------------------------------------- snapshots/delete(P1-⑧)
|
|
1646
|
+
// 手动删除单个快照(危险操作:该导入前回滚点不可恢复)。loopback fence(guard);
|
|
1647
|
+
// 只接受合法快照 id(deleteSnapshot 内防穿越)。与自动保留清理不同:置顶快照
|
|
1648
|
+
// 只能在这里被用户手动删除。
|
|
1649
|
+
// --------------------------------------------- snapshots/pin(P1-⑧)
|
|
1650
|
+
// 置顶/取消置顶快照:置顶快照豁免「最多保留 N 个」的自动清理(只能手动删除)。
|
|
1651
|
+
// 纯元数据写(重写 snapshot.json 的 pinned 字段);loopback fence 必备。
|
|
1652
|
+
// -------------------------------------------------- m-profiles
|
|
1653
|
+
// 配置档案(Profile):保存当前 DSH 配置为多套可切换快照(Work/Personal…)。
|
|
1654
|
+
// 安全:Profile 名严格校验(ProfileManager 内部 isValidProfileName 防穿越);
|
|
1655
|
+
// 切换走「预览 → confirm → 快照 → 分阶段 apply → 失败回滚」与导入同一语义;
|
|
1656
|
+
// Save 复用 adapter.export(天然不含秘密值);全部路由 loopback fence。
|
|
1657
|
+
// -------------------------------------------------- backup-schedule
|
|
1658
|
+
// 定时全量备份设置(GET 读 / PUT 存 sync/backup-schedule.json;无敏感字段):
|
|
1659
|
+
// 保存后重排调度器(reload);恒不含 secret、不加密(与自动同步同语义)。
|
|
1660
|
+
// 与全仓一致:每个方法分支都过 loopback fence(guard)——其他 /api/dsh-config-manager/*
|
|
1661
|
+
// 路由全部首行 guard,新增路由不得遗漏(安全不变量:仅 loopback + 同源可访问)。
|
|
1662
|
+
// ------------------------------------------------- backup-schedule/run
|
|
1663
|
+
// 返回执行结果(status/zip/skipReason/error)+ 最新配置(含 lastRun 状态)。
|
|
1664
|
+
// 同全仓:loopback fence(guard)——远程调用方不得触发宿主写盘操作。
|
|
1665
|
+
// ------------------------------------------------------ backup-files
|
|
1666
|
+
// 导出产物管理(m-backup-files):列出 exports/*.zip(名称/大小/时间/来源,
|
|
1667
|
+
// 时间倒序)+ 删除单个备份文件。下载复用 /download(roots 已含 exportsDir)。
|
|
1668
|
+
// 安全:删除只接受文件名(服务端 basename 校验防路径穿越);恒 loopback guard。
|
|
1669
|
+
// ------------------------------------------------------ consult
|
|
1670
|
+
// Phase 7:迁移前咨询(只读健康评分 + 建议)。POST,loopback fence。
|
|
1671
|
+
// 对 4 种可迁移源(export-zip / local-snapshot / remote-snapshot / profile)生成
|
|
1672
|
+
// 统一咨询报告。**只读**:不写配置/快照/journal;临时 ZIP 用 try/finally 立即清理。
|
|
1673
|
+
// ------------------------------------------------------ sync/status
|
|
1674
|
+
// m-sync-ui:同步状态(通道配置 / 凭据状态 / 上次同步 / 分区数)。只读,无 secret 值。
|
|
1675
|
+
{
|
|
1676
|
+
kind: 'exact',
|
|
1677
|
+
path: API.syncStatus,
|
|
1678
|
+
handler: async (req, res) => {
|
|
1679
|
+
if (!guard(req, res, 'GET'))
|
|
1680
|
+
return;
|
|
1681
|
+
try {
|
|
1682
|
+
// 完整双命名空间配置:repoUrl / webdav.url 无论当前通道都回填,
|
|
1683
|
+
// 保证 UI 在 git ↔ webdav 间切换时另一通道的地址不丢失
|
|
1684
|
+
const full = await readFullSyncConfig(syncDir);
|
|
1685
|
+
const state = await loadSyncState(syncDir);
|
|
1686
|
+
const [cred, webdavCred] = await Promise.all([
|
|
1687
|
+
credentials.describe(credentialRef(SYNC_CREDENTIAL_REF)),
|
|
1688
|
+
credentials.describe(credentialRef(SYNC_WEBDAV_CREDENTIAL_REF)),
|
|
1689
|
+
]);
|
|
1690
|
+
const transport = full !== null && full.transport === 'webdav' ? 'webdav' : 'git';
|
|
1691
|
+
// m-self:插件 UI 偏好(上次选择的同步通道;ui-prefs.json,随 self 分区进备份)
|
|
1692
|
+
const uiPrefs = await readUiPrefs(syncDir);
|
|
1693
|
+
// webdav 配置视图(配置过即返回,与当前通道无关:供表单在 git ↔ webdav 切换时回填)
|
|
1694
|
+
const webdav = full?.webdav !== undefined
|
|
1695
|
+
? {
|
|
1696
|
+
url: full.webdav.url,
|
|
1697
|
+
// username 非敏感可回显,供表单回填
|
|
1698
|
+
username: full.webdav.username,
|
|
1699
|
+
usernameConfigured: typeof full.webdav.username === 'string' && full.webdav.username !== '',
|
|
1700
|
+
passwordConfigured: webdavCred.configured,
|
|
1701
|
+
}
|
|
1702
|
+
: undefined;
|
|
1703
|
+
writeJson(res, 200, {
|
|
1704
|
+
ok: true,
|
|
1705
|
+
configured: full !== null,
|
|
1706
|
+
transport,
|
|
1707
|
+
repoUrl: full?.git?.repoUrl,
|
|
1708
|
+
credentialConfigured: cred.configured,
|
|
1709
|
+
credentialWritable: cred.writable === true,
|
|
1710
|
+
// webdav 配置状态(无 secret 值:口令用 passwordConfigured 布尔标记)
|
|
1711
|
+
...(webdav !== undefined ? { webdav } : {}),
|
|
1712
|
+
lastSyncAt: state.lastSyncAt === '' ? undefined : state.lastSyncAt,
|
|
1713
|
+
sectionCount: Object.keys(state.sections).length,
|
|
1714
|
+
lastTransport: state.transport,
|
|
1715
|
+
// 上次选择的同步通道(磁盘 ui-prefs;UI 回填优先于此,localStorage 仅兜底)
|
|
1716
|
+
lastSyncChannel: uiPrefs.lastSyncChannel,
|
|
1717
|
+
// 可同步分区目录(「高级/自定义导出」勾选列表;只含 portable,无 secret 值)
|
|
1718
|
+
syncSections: syncSectionCatalog,
|
|
1719
|
+
// 当前分区选择(默认/高级模式 + 勾选分区;当前激活通道;UI 回填用,自动同步共用)
|
|
1720
|
+
syncSelection: await selectionView(transport),
|
|
1721
|
+
// 全部通道的分区选择(git/webdav 各自独立;UI 按当前 tab 取对应通道)
|
|
1722
|
+
syncSelectionByChannel: await selectionViewByChannel(),
|
|
1723
|
+
// 自动同步当前状态(当前激活通道;供 UI 顶部开关回填;§3.9)
|
|
1724
|
+
autosync: await buildAutosyncStatus(syncDir, transport),
|
|
1725
|
+
// 全部通道的自动同步状态(git/webdav 各自独立;UI 按当前 tab 取对应通道)
|
|
1726
|
+
autosyncByChannel: await buildAutosyncStatusByChannel(syncDir),
|
|
1727
|
+
});
|
|
1728
|
+
}
|
|
1729
|
+
catch (error) {
|
|
1730
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
1731
|
+
}
|
|
1732
|
+
},
|
|
1733
|
+
},
|
|
1734
|
+
// ------------------------------------------------------ sync/config
|
|
1735
|
+
// m-sync-config:保存同步通道配置(parseSyncBody 校验 + password/token 写 DSH credentials +
|
|
1736
|
+
// writeSyncConfig 落盘)。UI 表单自动保存 /「保存配置」按钮调用;响应为轻量状态视图
|
|
1737
|
+
// (仅凭据布尔,无 secret 值),供 UI 直接刷新徽章而不必重拉 status 覆盖正在编辑的表单。
|
|
1738
|
+
{
|
|
1739
|
+
kind: 'exact',
|
|
1740
|
+
path: API.syncConfig,
|
|
1741
|
+
handler: async (req, res) => {
|
|
1742
|
+
if (!guard(req, res, 'POST'))
|
|
1743
|
+
return;
|
|
1744
|
+
const body = await readJsonBody(req);
|
|
1745
|
+
if (body === undefined) {
|
|
1746
|
+
writeJson(res, 400, { error: 'invalid JSON body' });
|
|
1747
|
+
return;
|
|
1748
|
+
}
|
|
1749
|
+
try {
|
|
1750
|
+
const syncCfg = await prepareSync(body);
|
|
1751
|
+
await writeSyncConfig(syncDir, syncCfg);
|
|
1752
|
+
const [cred, webdavCred] = await Promise.all([
|
|
1753
|
+
credentials.describe(credentialRef(SYNC_CREDENTIAL_REF)),
|
|
1754
|
+
credentials.describe(credentialRef(SYNC_WEBDAV_CREDENTIAL_REF)),
|
|
1755
|
+
]);
|
|
1756
|
+
writeJson(res, 200, {
|
|
1757
|
+
ok: true,
|
|
1758
|
+
configured: true,
|
|
1759
|
+
transport: syncCfg.transport,
|
|
1760
|
+
credentialConfigured: cred.configured,
|
|
1761
|
+
webdav: isWebDavConfig(syncCfg)
|
|
1762
|
+
? {
|
|
1763
|
+
usernameConfigured: typeof syncCfg.webdav.username === 'string' && syncCfg.webdav.username !== '',
|
|
1764
|
+
passwordConfigured: webdavCred.configured,
|
|
1765
|
+
}
|
|
1766
|
+
: undefined,
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1769
|
+
catch (error) {
|
|
1770
|
+
writeSyncRouteError(res, error);
|
|
1771
|
+
}
|
|
1772
|
+
},
|
|
1773
|
+
},
|
|
1774
|
+
// ------------------------------------------------------ sync/ui-prefs
|
|
1775
|
+
// m-self:保存插件 UI 偏好(当前为上次选择的同步通道;ui-prefs.json,随 self 分区进备份)。
|
|
1776
|
+
// 纯偏好、无 secret;失败仅提示,不阻断同步主流程。
|
|
1777
|
+
// 经 updateUiPrefs 局部合并写:不覆盖其他端点(star-prompt)刚写入的字段。
|
|
1778
|
+
{
|
|
1779
|
+
kind: 'exact',
|
|
1780
|
+
path: API.syncUiPrefs,
|
|
1781
|
+
handler: async (req, res) => {
|
|
1782
|
+
if (!guard(req, res, 'POST'))
|
|
1783
|
+
return;
|
|
1784
|
+
const body = await readJsonBody(req);
|
|
1785
|
+
if (body === undefined) {
|
|
1786
|
+
writeJson(res, 400, { error: 'invalid JSON body' });
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1789
|
+
try {
|
|
1790
|
+
const channel = body['lastSyncChannel'] === 'webdav' ? 'webdav' : body['lastSyncChannel'] === 'git' ? 'git' : undefined;
|
|
1791
|
+
await updateUiPrefs(syncDir, { ...(channel !== undefined ? { lastSyncChannel: channel } : {}) });
|
|
1792
|
+
writeJson(res, 200, { ok: true, lastSyncChannel: channel });
|
|
1793
|
+
}
|
|
1794
|
+
catch (error) {
|
|
1795
|
+
writeSyncRouteError(res, error);
|
|
1796
|
+
}
|
|
1797
|
+
},
|
|
1798
|
+
},
|
|
1799
|
+
// ------------------------------------------------------ star-prompt
|
|
1800
|
+
// m-star-prompt:Star 引导弹窗状态(复用 ui-prefs.json;随 self 分区进备份)。
|
|
1801
|
+
// GET → 返回仓库地址 + 弹窗状态(UI 挂载时判定是否展示 / 是否补记首次使用时间);
|
|
1802
|
+
// POST → 局部更新(firstSeenAt / dismissed / clicked 白名单),经 updateUiPrefs
|
|
1803
|
+
// 合并写,不覆盖 sync/ui-prefs 的 lastSyncChannel。纯偏好、无 secret。
|
|
1804
|
+
// ------------------------------------------------------ release-notes-prompt
|
|
1805
|
+
// 版本更新内容弹窗状态(复用 ui-prefs.json;随 self 分区进备份)。
|
|
1806
|
+
// GET → 返回当前插件版本 + 上次已读版本 + 是否永不提示;
|
|
1807
|
+
// POST → 局部更新(lastSeenVersion / dismissed 白名单),经 updateUiPrefs 合并写。
|
|
1808
|
+
// ------------------------------------------------------ sync/push
|
|
1809
|
+
// m-sync-ui:推送(导出 portable 分区 → 提交私有仓库 → 更新 sync-state)。
|
|
1810
|
+
// token 可选:非空先写入 DSH credentials;成功则记忆仓库配置(回填表单用)。
|
|
1811
|
+
// sections 可选(高级/自定义导出):只推送勾选的分区;缺省 = 默认模式全部推荐分区。
|
|
1812
|
+
{
|
|
1813
|
+
kind: 'exact',
|
|
1814
|
+
path: API.syncPush,
|
|
1815
|
+
handler: withMutationGate('sync-push', async (req, res) => {
|
|
1816
|
+
if (!guard(req, res, 'POST'))
|
|
1817
|
+
return;
|
|
1818
|
+
const body = await readJsonBody(req);
|
|
1819
|
+
if (body === undefined) {
|
|
1820
|
+
writeJson(res, 400, { error: 'invalid JSON body' });
|
|
1821
|
+
return;
|
|
1822
|
+
}
|
|
1823
|
+
try {
|
|
1824
|
+
const syncCfg = await prepareSync(body);
|
|
1825
|
+
const engine = makeSyncEngine(syncCfg);
|
|
1826
|
+
const snapshotId = typeof body['snapshotId'] === 'string' && body['snapshotId'] !== '' ? body['snapshotId'] : undefined;
|
|
1827
|
+
const sections = extractSyncSections(body, knownSyncSectionIds);
|
|
1828
|
+
// P0-②:push 前只读预览(body.preview === true → 不写远端,只返回「将推送什么」)
|
|
1829
|
+
const preview = body['preview'] === true;
|
|
1830
|
+
// 分支调用以保证 withTimeout 的泛型结果类型正确(SyncPushReport | SyncPushPreview)
|
|
1831
|
+
const report = preview
|
|
1832
|
+
? await withTimeout(engine.previewPush({ ...(sections === undefined ? {} : { sections }) }), ROUTE_TIMEOUT_MS, msg('host.syncPushTimeout'))
|
|
1833
|
+
: await withTimeout(engine.push({
|
|
1834
|
+
...(snapshotId === undefined ? {} : { snapshotId }),
|
|
1835
|
+
...(sections === undefined ? {} : { sections }),
|
|
1836
|
+
}), ROUTE_TIMEOUT_MS, msg('host.syncPushTimeout'));
|
|
1837
|
+
await writeSyncConfig(syncDir, syncCfg);
|
|
1838
|
+
writeJson(res, 200, report);
|
|
1839
|
+
}
|
|
1840
|
+
catch (error) {
|
|
1841
|
+
writeSyncRouteError(res, error);
|
|
1842
|
+
}
|
|
1843
|
+
}),
|
|
1844
|
+
},
|
|
1845
|
+
// ------------------------------------------------------ sync/pull
|
|
1846
|
+
// m-sync-ui:拉取差异预览(只读:list/download → 转临时 ZIP → Importer 分析出计划摘要)。
|
|
1847
|
+
// 绝不直接写配置、绝不执行导入(executeImportPlan 由上层按用户确认驱动)。
|
|
1848
|
+
{
|
|
1849
|
+
kind: 'exact',
|
|
1850
|
+
path: API.syncPull,
|
|
1851
|
+
handler: async (req, res) => {
|
|
1852
|
+
if (!guard(req, res, 'POST'))
|
|
1853
|
+
return;
|
|
1854
|
+
const body = await readJsonBody(req);
|
|
1855
|
+
if (body === undefined) {
|
|
1856
|
+
writeJson(res, 400, { error: 'invalid JSON body' });
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
try {
|
|
1860
|
+
const syncCfg = await prepareSync(body);
|
|
1861
|
+
const engine = makeSyncEngine(syncCfg);
|
|
1862
|
+
// 缺省 replace:明文同步的产品语义是「远端值覆盖本地」,不做 diff/合并
|
|
1863
|
+
const strategy = body['strategy'] === 'merge' || body['strategy'] === 'skipExisting' ? body['strategy'] : 'replace';
|
|
1864
|
+
const snapshotId = typeof body['snapshotId'] === 'string' && body['snapshotId'] !== '' ? body['snapshotId'] : undefined;
|
|
1865
|
+
const report = await withTimeout(engine.pull({
|
|
1866
|
+
strategy,
|
|
1867
|
+
...(snapshotId === undefined ? {} : { snapshotId }),
|
|
1868
|
+
}), ROUTE_TIMEOUT_MS, msg('host.syncPullTimeout'));
|
|
1869
|
+
await writeSyncConfig(syncDir, syncCfg);
|
|
1870
|
+
writeJson(res, 200, report);
|
|
1871
|
+
}
|
|
1872
|
+
catch (error) {
|
|
1873
|
+
writeSyncRouteError(res, error);
|
|
1874
|
+
}
|
|
1875
|
+
},
|
|
1876
|
+
},
|
|
1877
|
+
// -------------------------------------------------- sync/github/start
|
|
1878
|
+
// m-github-oauth:发起 GitHub OAuth device flow。请求 GitHub 取设备码,宿主登记
|
|
1879
|
+
// (flowId → device_code 只存内存),返回 UI 展示用的 user_code + 授权页 URL。
|
|
1880
|
+
// client_id 来自插件配置;未配置时给出可操作指引(不会凭空认证)。
|
|
1881
|
+
{
|
|
1882
|
+
kind: 'exact',
|
|
1883
|
+
path: API.syncGithubStart,
|
|
1884
|
+
handler: async (req, res) => {
|
|
1885
|
+
if (!guard(req, res, 'POST'))
|
|
1886
|
+
return;
|
|
1887
|
+
if (githubClientId === undefined || githubClientId === '') {
|
|
1888
|
+
writeJson(res, 400, {
|
|
1889
|
+
error: msg('host.githubMissingClientId'),
|
|
1890
|
+
});
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
try {
|
|
1894
|
+
const started = await githubAuth.startDeviceFlow(githubClientId);
|
|
1895
|
+
const flowId = DeviceFlowStore.newFlowId();
|
|
1896
|
+
githubFlows.set(flowId, {
|
|
1897
|
+
deviceCode: started.deviceCode,
|
|
1898
|
+
clientId: githubClientId,
|
|
1899
|
+
clientSecret: githubClientSecret,
|
|
1900
|
+
interval: started.interval,
|
|
1901
|
+
expiresAt: Date.now() + started.expiresIn * 1000,
|
|
1902
|
+
});
|
|
1903
|
+
// device_code 绝不回传;只回 UI 需要的展示信息
|
|
1904
|
+
writeJson(res, 200, {
|
|
1905
|
+
flowId,
|
|
1906
|
+
userCode: started.userCode,
|
|
1907
|
+
verificationUri: started.verificationUri,
|
|
1908
|
+
expiresIn: started.expiresIn,
|
|
1909
|
+
interval: started.interval,
|
|
1910
|
+
});
|
|
1911
|
+
}
|
|
1912
|
+
catch (error) {
|
|
1913
|
+
writeSyncRouteError(res, error);
|
|
1914
|
+
}
|
|
1915
|
+
},
|
|
1916
|
+
},
|
|
1917
|
+
// -------------------------------------------------- sync/github/poll
|
|
1918
|
+
// m-github-oauth:轮询授权结果。凭 flowId 取回宿主登记的 device_code → GitHub 换 token
|
|
1919
|
+
// → 成功则立即写入 DSH credentials(SYNC_CREDENTIAL_REF,与手动 token 同槽),
|
|
1920
|
+
// token 绝不回传浏览器;pending 返回下次轮询延迟;终止态(denied/expired/error)清理登记。
|
|
1921
|
+
{
|
|
1922
|
+
kind: 'exact',
|
|
1923
|
+
path: API.syncGithubPoll,
|
|
1924
|
+
handler: async (req, res) => {
|
|
1925
|
+
if (!guard(req, res, 'POST'))
|
|
1926
|
+
return;
|
|
1927
|
+
const body = await readJsonBody(req);
|
|
1928
|
+
const flowId = typeof body?.['flowId'] === 'string' ? body['flowId'] : '';
|
|
1929
|
+
if (flowId === '') {
|
|
1930
|
+
writeJson(res, 400, { error: 'flowId is required' });
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
const flow = githubFlows.get(flowId);
|
|
1934
|
+
if (flow === undefined) {
|
|
1935
|
+
writeJson(res, 400, { error: msg('host.githubFlowGone') });
|
|
1936
|
+
return;
|
|
1937
|
+
}
|
|
1938
|
+
try {
|
|
1939
|
+
const result = await githubAuth.pollForToken({
|
|
1940
|
+
clientId: flow.clientId,
|
|
1941
|
+
deviceCode: flow.deviceCode,
|
|
1942
|
+
clientSecret: flow.clientSecret,
|
|
1943
|
+
interval: flow.interval,
|
|
1944
|
+
});
|
|
1945
|
+
if (result.status === 'success' && result.accessToken !== undefined) {
|
|
1946
|
+
await credentials.set(credentialRef(SYNC_CREDENTIAL_REF), result.accessToken);
|
|
1947
|
+
githubFlows.delete(flowId);
|
|
1948
|
+
host.log.info('GitHub OAuth 登录成功(token 已写入 DSH credentials)');
|
|
1949
|
+
writeJson(res, 200, { status: 'success', credentialConfigured: true });
|
|
1950
|
+
return;
|
|
1951
|
+
}
|
|
1952
|
+
if (result.status === 'pending') {
|
|
1953
|
+
writeJson(res, 200, { status: 'pending', pollDelayMs: result.pollDelayMs });
|
|
1954
|
+
return;
|
|
1955
|
+
}
|
|
1956
|
+
// 终止态:清理登记,把状态 + 可展示消息回给 UI(不含任何秘密)
|
|
1957
|
+
githubFlows.delete(flowId);
|
|
1958
|
+
writeJson(res, 200, {
|
|
1959
|
+
status: result.status,
|
|
1960
|
+
...(result.message !== undefined ? { message: result.message } : {}),
|
|
1961
|
+
...(result.errorCode !== undefined ? { errorCode: result.errorCode } : {}),
|
|
1962
|
+
});
|
|
1963
|
+
}
|
|
1964
|
+
catch (error) {
|
|
1965
|
+
writeSyncRouteError(res, error);
|
|
1966
|
+
}
|
|
1967
|
+
},
|
|
1968
|
+
},
|
|
1969
|
+
// -------------------------------------------------- sync/github/cancel
|
|
1970
|
+
// m-github-oauth:取消登录流程(丢弃宿主侧 device_code 登记,零副作用)。
|
|
1971
|
+
{
|
|
1972
|
+
kind: 'exact',
|
|
1973
|
+
path: API.syncGithubCancel,
|
|
1974
|
+
handler: async (req, res) => {
|
|
1975
|
+
if (!guard(req, res, 'POST'))
|
|
1976
|
+
return;
|
|
1977
|
+
const body = await readJsonBody(req);
|
|
1978
|
+
const flowId = typeof body?.['flowId'] === 'string' ? body['flowId'] : '';
|
|
1979
|
+
if (flowId === '') {
|
|
1980
|
+
writeJson(res, 400, { error: 'flowId is required' });
|
|
1981
|
+
return;
|
|
1982
|
+
}
|
|
1983
|
+
githubFlows.delete(flowId);
|
|
1984
|
+
writeJson(res, 200, { ok: true });
|
|
1985
|
+
},
|
|
1986
|
+
},
|
|
1987
|
+
// -------------------------------------------------- sync/github/validate
|
|
1988
|
+
// m-sync-github-valid:校验 SYNC_CREDENTIAL_REF 中已存 token 是否有效(GET /user),
|
|
1989
|
+
// 供 UI 判定「是否已登录」→ 已登录隐藏 GitHub 登录区块、token 失效则重新展示。
|
|
1990
|
+
// 只回布尔 + 登录名(非敏感),token 值绝不回传;仅 401(无效/过期)→ valid:false,
|
|
1991
|
+
// 其余错误(网络/限流)向上抛,由 UI 兜底(不误判登出)。
|
|
1992
|
+
{
|
|
1993
|
+
kind: 'exact',
|
|
1994
|
+
path: API.syncGithubValidate,
|
|
1995
|
+
handler: async (req, res) => {
|
|
1996
|
+
if (!guard(req, res, 'POST'))
|
|
1997
|
+
return;
|
|
1998
|
+
try {
|
|
1999
|
+
let configured = false;
|
|
2000
|
+
let valid = false;
|
|
2001
|
+
let login;
|
|
2002
|
+
const resolved = await credentials.resolve(credentialRef(SYNC_CREDENTIAL_REF));
|
|
2003
|
+
const token = resolved?.value ?? '';
|
|
2004
|
+
configured = token !== '';
|
|
2005
|
+
if (configured) {
|
|
2006
|
+
try {
|
|
2007
|
+
const user = await githubAuth.getUser(token);
|
|
2008
|
+
valid = true;
|
|
2009
|
+
login = user.login;
|
|
2010
|
+
}
|
|
2011
|
+
catch (error) {
|
|
2012
|
+
// 仅 401(token 无效/过期)→ 视为未登录;其余错误(网络/限流)向上抛
|
|
2013
|
+
if (!(error instanceof GitHubAuthError && error.code === 'unauthorized'))
|
|
2014
|
+
throw error;
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
writeJson(res, 200, {
|
|
2018
|
+
ok: true,
|
|
2019
|
+
configured,
|
|
2020
|
+
valid,
|
|
2021
|
+
...(login !== undefined ? { login } : {}),
|
|
2022
|
+
});
|
|
2023
|
+
}
|
|
2024
|
+
catch (error) {
|
|
2025
|
+
writeJson(res, 500, { error: redact(error instanceof Error ? error.message : String(error)) });
|
|
2026
|
+
}
|
|
2027
|
+
},
|
|
2028
|
+
},
|
|
2029
|
+
// ------------------------------------------------------ sync/history
|
|
2030
|
+
// 列出本地快照目录的 manifest.json(id/createdAt/sectionHashes/transport)。
|
|
2031
|
+
{
|
|
2032
|
+
kind: 'exact',
|
|
2033
|
+
path: API.syncHistory,
|
|
2034
|
+
handler: async (req, res) => {
|
|
2035
|
+
if (!guard(req, res, 'GET'))
|
|
2036
|
+
return;
|
|
2037
|
+
try {
|
|
2038
|
+
const localDir = join(syncDir, 'snapshots');
|
|
2039
|
+
const entries = await fs.readdir(localDir).catch(() => []);
|
|
2040
|
+
const rows = [];
|
|
2041
|
+
for (const name of entries) {
|
|
2042
|
+
const dir = join(localDir, name);
|
|
2043
|
+
const stat = await fs.stat(dir).catch(() => null);
|
|
2044
|
+
if (!stat?.isDirectory())
|
|
2045
|
+
continue;
|
|
2046
|
+
const manifestPath = join(dir, 'manifest.json');
|
|
2047
|
+
const raw = await fs.readFile(manifestPath, 'utf8').catch(() => null);
|
|
2048
|
+
if (raw === null)
|
|
2049
|
+
continue;
|
|
2050
|
+
try {
|
|
2051
|
+
const m = JSON.parse(raw);
|
|
2052
|
+
if (typeof m.id !== 'string' || typeof m.createdAt !== 'string')
|
|
2053
|
+
continue;
|
|
2054
|
+
const sectionCount = m.sectionHashes && typeof m.sectionHashes === 'object'
|
|
2055
|
+
? Object.keys(m.sectionHashes).length
|
|
2056
|
+
: 0;
|
|
2057
|
+
// 触发通道(push/apply 落盘时写入各快照 manifest.transport;旧快照为 undefined)
|
|
2058
|
+
const transport = m.manifest && typeof m.manifest === 'object' && typeof m.manifest.transport === 'string'
|
|
2059
|
+
? m.manifest.transport
|
|
2060
|
+
: undefined;
|
|
2061
|
+
rows.push({ id: m.id, createdAt: m.createdAt, sectionCount, reviewCount: 0, ...(transport !== undefined ? { transport } : {}) });
|
|
2062
|
+
}
|
|
2063
|
+
catch { /* skip malformed */ }
|
|
2064
|
+
}
|
|
2065
|
+
// reviewCount 恒 0:待审队列(sync-review-queue.json)已随合并逻辑一并删除,
|
|
2066
|
+
// 保留该字段仅为与客户端契约兼容。
|
|
2067
|
+
rows.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0));
|
|
2068
|
+
// 合并自动同步执行记录(sync-history.json)
|
|
2069
|
+
const hist = await readSyncHistory(syncDir);
|
|
2070
|
+
const merged = [
|
|
2071
|
+
...rows.map((r) => ({ ...r, kind: 'apply' })),
|
|
2072
|
+
...hist.autosyncEntries.map((e) => ({
|
|
2073
|
+
id: e.createdAt,
|
|
2074
|
+
createdAt: e.createdAt,
|
|
2075
|
+
kind: 'autosync',
|
|
2076
|
+
autosync: e,
|
|
2077
|
+
})),
|
|
2078
|
+
].sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0));
|
|
2079
|
+
writeJson(res, 200, { entries: merged });
|
|
2080
|
+
}
|
|
2081
|
+
catch (error) {
|
|
2082
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
2083
|
+
}
|
|
2084
|
+
},
|
|
2085
|
+
},
|
|
2086
|
+
// ------------------------------------------------------ sync/snapshots-list
|
|
2087
|
+
// m-sync-v2:远端历史快照列表(供「选择历史快照」下拉)。
|
|
2088
|
+
{
|
|
2089
|
+
kind: 'exact',
|
|
2090
|
+
path: API.syncSnapshotsList,
|
|
2091
|
+
handler: async (req, res) => {
|
|
2092
|
+
if (!guard(req, res, 'POST'))
|
|
2093
|
+
return;
|
|
2094
|
+
const body = await readJsonBody(req);
|
|
2095
|
+
if (body === undefined) {
|
|
2096
|
+
writeJson(res, 400, { error: 'invalid JSON body' });
|
|
2097
|
+
return;
|
|
2098
|
+
}
|
|
2099
|
+
try {
|
|
2100
|
+
const syncCfg = await prepareSync(body);
|
|
2101
|
+
const engine = makeSyncEngine(syncCfg);
|
|
2102
|
+
const metas = await withTimeout(engine.listSnapshots(), ROUTE_TIMEOUT_MS, msg('host.syncPullTimeout'));
|
|
2103
|
+
const snapshots = [...metas]
|
|
2104
|
+
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0))
|
|
2105
|
+
.map((m) => ({
|
|
2106
|
+
id: m.id,
|
|
2107
|
+
createdAt: m.createdAt,
|
|
2108
|
+
sectionCount: m.manifest.sectionIds.length,
|
|
2109
|
+
platform: m.manifest.platform,
|
|
2110
|
+
dshVersion: m.manifest.dshVersion,
|
|
2111
|
+
}));
|
|
2112
|
+
const state = await loadSyncState(syncDir);
|
|
2113
|
+
writeJson(res, 200, { ok: true, snapshots, currentSnapshotId: state.lastSnapshotId === '' ? undefined : state.lastSnapshotId });
|
|
2114
|
+
}
|
|
2115
|
+
catch (error) {
|
|
2116
|
+
writeSyncRouteError(res, error);
|
|
2117
|
+
}
|
|
2118
|
+
},
|
|
2119
|
+
},
|
|
2120
|
+
// ------------------------------------------------------ sync/sync
|
|
2121
|
+
// m-sync-v2:一键同步第一步 —— 拉取 → 差异确认会话(内存登记临时 ZIP + ImportPlan)。
|
|
2122
|
+
{
|
|
2123
|
+
kind: 'exact',
|
|
2124
|
+
path: API.syncSync,
|
|
2125
|
+
handler: async (req, res) => {
|
|
2126
|
+
if (!guard(req, res, 'POST'))
|
|
2127
|
+
return;
|
|
2128
|
+
const body = await readJsonBody(req);
|
|
2129
|
+
if (body === undefined) {
|
|
2130
|
+
writeJson(res, 400, { error: 'invalid JSON body' });
|
|
2131
|
+
return;
|
|
2132
|
+
}
|
|
2133
|
+
try {
|
|
2134
|
+
const syncCfg = await prepareSync(body);
|
|
2135
|
+
const engine = makeSyncEngine(syncCfg);
|
|
2136
|
+
const snapshotId = typeof body['snapshotId'] === 'string' && body['snapshotId'] !== '' ? body['snapshotId'] : undefined;
|
|
2137
|
+
const preview = await withTimeout(engine.preview({ ...(snapshotId === undefined ? {} : { snapshotId }) }), ROUTE_TIMEOUT_MS, msg('host.syncPullTimeout'));
|
|
2138
|
+
if (!preview.ok || preview.plan === null || preview.analysis === null) {
|
|
2139
|
+
writeJson(res, 200, { ok: false, syncSessionId: '', snapshotId: preview.snapshotId, items: [], needsReview: false, compatibility: 'unsupported', message: preview.message ?? '同步预览失败' });
|
|
2140
|
+
return;
|
|
2141
|
+
}
|
|
2142
|
+
const syncSessionId = syncSessions.set({
|
|
2143
|
+
zipPath: preview.zipPath,
|
|
2144
|
+
plan: preview.plan,
|
|
2145
|
+
analysis: preview.analysis,
|
|
2146
|
+
snapshotId: preview.snapshotId,
|
|
2147
|
+
config: syncCfg,
|
|
2148
|
+
});
|
|
2149
|
+
const items = planToConfirmItems(preview.plan);
|
|
2150
|
+
const needsReview = items.some((i) => REVIEW_KINDS.has(i.kind) || isToolchainChangeItem(i))
|
|
2151
|
+
|| preview.analysis.pathIssues.length > 0;
|
|
2152
|
+
writeJson(res, 200, {
|
|
2153
|
+
ok: true,
|
|
2154
|
+
syncSessionId,
|
|
2155
|
+
snapshotId: preview.snapshotId,
|
|
2156
|
+
items,
|
|
2157
|
+
needsReview,
|
|
2158
|
+
compatibility: preview.analysis.compatibility,
|
|
2159
|
+
});
|
|
2160
|
+
}
|
|
2161
|
+
catch (error) {
|
|
2162
|
+
writeSyncRouteError(res, error);
|
|
2163
|
+
}
|
|
2164
|
+
},
|
|
2165
|
+
},
|
|
2166
|
+
// ------------------------------------------------------ sync/apply-items
|
|
2167
|
+
// m-sync-v2:一键同步第二步 —— 按用户对差异项的逐项决策执行导入。
|
|
2168
|
+
{
|
|
2169
|
+
kind: 'exact',
|
|
2170
|
+
path: API.syncApplyItems,
|
|
2171
|
+
handler: withMutationGate('sync-apply', async (req, res, lockCtx, journalCtx) => {
|
|
2172
|
+
if (!guard(req, res, 'POST'))
|
|
2173
|
+
return;
|
|
2174
|
+
const body = await readJsonBody(req);
|
|
2175
|
+
if (body === undefined) {
|
|
2176
|
+
writeJson(res, 400, { error: 'invalid JSON body' });
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2179
|
+
try {
|
|
2180
|
+
const syncSessionId = typeof body['syncSessionId'] === 'string' ? body['syncSessionId'] : '';
|
|
2181
|
+
const session = syncSessions.get(syncSessionId);
|
|
2182
|
+
if (session === undefined) {
|
|
2183
|
+
writeJson(res, 400, { error: '同步会话不存在或已过期,请重新拉取预览' });
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2186
|
+
const adoptions = Array.isArray(body['adoptions']) ? body['adoptions'] : [];
|
|
2187
|
+
// 构造子计划(仅含采纳项)
|
|
2188
|
+
const byId = new Map();
|
|
2189
|
+
for (const a of adoptions) {
|
|
2190
|
+
if (typeof a?.['itemId'] !== 'string')
|
|
2191
|
+
continue;
|
|
2192
|
+
byId.set(a['itemId'], { adopt: a['adopt'] === true, resolution: typeof a['resolution'] === 'string' ? a['resolution'] : undefined });
|
|
2193
|
+
}
|
|
2194
|
+
// 构造子计划(仅含采纳项)。同步冲突决策 useRemote → 核心 importer 的
|
|
2195
|
+
// useImported(item 转成 Update,applyOne 才会真正写远端值),
|
|
2196
|
+
// keepLocal/skip 从子计划剔除(keepCurrent/skip 语义:不写)。
|
|
2197
|
+
// 与导入恢复向导(ConflictList keepCurrent/useImported)的决策语义完全一致。
|
|
2198
|
+
const subItems = session.plan.items.flatMap((item) => {
|
|
2199
|
+
const d = byId.get(item.id);
|
|
2200
|
+
if (d === undefined || !d.adopt)
|
|
2201
|
+
return [];
|
|
2202
|
+
// Conflict 项必须有 resolution;keepLocal/skip 不写入本地 → 剔除
|
|
2203
|
+
if (item.kind === 'Conflict') {
|
|
2204
|
+
if (d.resolution === undefined)
|
|
2205
|
+
throw new SyncRouteError(`冲突项 ${item.id} 必须提供 resolution(useRemote/keepLocal/skip)`);
|
|
2206
|
+
if (d.resolution === 'keepLocal' || d.resolution === 'skip')
|
|
2207
|
+
return [];
|
|
2208
|
+
// useRemote → 转成 Update 计划项(镜像 analyzer.applyItemResolution 的
|
|
2209
|
+
// useImported 分支),applyOne 才会把远端值真正写进本地。
|
|
2210
|
+
const c = item.conflict;
|
|
2211
|
+
return [{
|
|
2212
|
+
...item,
|
|
2213
|
+
kind: 'Update',
|
|
2214
|
+
severity: 'info',
|
|
2215
|
+
conflict: { itemId: c?.itemId ?? item.id, resolution: 'useImported' },
|
|
2216
|
+
}];
|
|
2217
|
+
}
|
|
2218
|
+
return [item];
|
|
2219
|
+
});
|
|
2220
|
+
const subPlan = {
|
|
2221
|
+
...session.plan,
|
|
2222
|
+
items: subItems,
|
|
2223
|
+
};
|
|
2224
|
+
// 消费会话(同一 session 只允许一次 apply-items)
|
|
2225
|
+
syncSessions.delete(syncSessionId);
|
|
2226
|
+
let engine;
|
|
2227
|
+
let report;
|
|
2228
|
+
try {
|
|
2229
|
+
engine = makeSyncEngine(session.config);
|
|
2230
|
+
report = await engine.applyItems(session.zipPath, subPlan, {
|
|
2231
|
+
onItem: (info) => { },
|
|
2232
|
+
snapshotBinding: journalCtx,
|
|
2233
|
+
});
|
|
2234
|
+
}
|
|
2235
|
+
finally {
|
|
2236
|
+
// 用完再清理临时 ZIP(此前在 applyItems 读取前就删除 → ENOENT:无法读取备份文件)
|
|
2237
|
+
await fs.rm(dirname(session.zipPath), { recursive: true, force: true }).catch(() => { });
|
|
2238
|
+
}
|
|
2239
|
+
const historyError = await tryAppendHistory({
|
|
2240
|
+
kind: 'sync-apply',
|
|
2241
|
+
result: report.ok ? 'success' : 'failed',
|
|
2242
|
+
sections: Array.isArray(report.applied) ? report.applied.filter((s) => typeof s === 'string') : subItems.map((i) => i.adapter).filter((s) => typeof s === 'string' && s !== ''),
|
|
2243
|
+
operationId: journalCtx?.operationId,
|
|
2244
|
+
snapshotId: report.restoreId ?? undefined,
|
|
2245
|
+
source: 'api',
|
|
2246
|
+
summary: `一键同步应用:${(Array.isArray(report.applied) ? report.applied.length : subItems.length)} 项${report.rolledBack === true ? '(已回滚)' : ''}`,
|
|
2247
|
+
error: report.ok ? undefined : '同步应用未完全成功',
|
|
2248
|
+
});
|
|
2249
|
+
writeJson(res, 200, historyError === undefined ? {
|
|
2250
|
+
ok: report.ok,
|
|
2251
|
+
applied: report.applied,
|
|
2252
|
+
skipped: subItems.map((i) => i.id),
|
|
2253
|
+
needsRestart: report.needsRestart === true,
|
|
2254
|
+
warnings: report.warnings,
|
|
2255
|
+
restoreId: report.restoreId,
|
|
2256
|
+
rolledBack: report.rolledBack,
|
|
2257
|
+
failed: report.failed,
|
|
2258
|
+
result: report.result,
|
|
2259
|
+
} : {
|
|
2260
|
+
ok: report.ok,
|
|
2261
|
+
applied: report.applied,
|
|
2262
|
+
skipped: subItems.map((i) => i.id),
|
|
2263
|
+
needsRestart: report.needsRestart === true,
|
|
2264
|
+
warnings: report.warnings,
|
|
2265
|
+
restoreId: report.restoreId,
|
|
2266
|
+
rolledBack: report.rolledBack,
|
|
2267
|
+
failed: report.failed,
|
|
2268
|
+
result: report.result,
|
|
2269
|
+
historyWriteError: historyError,
|
|
2270
|
+
});
|
|
2271
|
+
}
|
|
2272
|
+
catch (error) {
|
|
2273
|
+
writeSyncRouteError(res, error);
|
|
2274
|
+
}
|
|
2275
|
+
}, { deferredSnapshot: true }),
|
|
2276
|
+
},
|
|
2277
|
+
// ------------------------------------------------------ sync/cancel
|
|
2278
|
+
// m-sync-v2:取消 / 清理差异确认会话(丢弃临时 ZIP,零副作用)。
|
|
2279
|
+
{
|
|
2280
|
+
kind: 'exact',
|
|
2281
|
+
path: API.syncCancel,
|
|
2282
|
+
handler: async (req, res) => {
|
|
2283
|
+
if (!guard(req, res, 'POST'))
|
|
2284
|
+
return;
|
|
2285
|
+
const body = await readJsonBody(req);
|
|
2286
|
+
if (body === undefined) {
|
|
2287
|
+
writeJson(res, 400, { error: 'invalid JSON body' });
|
|
2288
|
+
return;
|
|
2289
|
+
}
|
|
2290
|
+
try {
|
|
2291
|
+
const syncSessionId = typeof body['syncSessionId'] === 'string' ? body['syncSessionId'] : '';
|
|
2292
|
+
if (syncSessionId !== '') {
|
|
2293
|
+
const session = syncSessions.get(syncSessionId);
|
|
2294
|
+
if (session !== undefined) {
|
|
2295
|
+
await fs.rm(dirname(session.zipPath), { recursive: true, force: true }).catch(() => { });
|
|
2296
|
+
}
|
|
2297
|
+
syncSessions.delete(syncSessionId);
|
|
2298
|
+
}
|
|
2299
|
+
writeJson(res, 200, { ok: true });
|
|
2300
|
+
}
|
|
2301
|
+
catch (error) {
|
|
2302
|
+
writeSyncRouteError(res, error);
|
|
2303
|
+
}
|
|
2304
|
+
},
|
|
2305
|
+
},
|
|
2306
|
+
// ------------------------------------------------------ sync/autosync
|
|
2307
|
+
// m-sync-v2:自动同步配置读写(按通道:git/webdav 各自的开关 + 间隔 + 启动阈值 + 状态)。
|
|
2308
|
+
// GET = 读全部通道状态({ git, webdav });POST = 写指定通道(body.transport,缺省 git)。
|
|
2309
|
+
// 同一路径注册为一个 exact 路由(方法内部分发),避免 webserver 对重复 exact 路径报错。
|
|
2310
|
+
{
|
|
2311
|
+
kind: 'exact',
|
|
2312
|
+
path: API.syncAutosync,
|
|
2313
|
+
handler: async (req, res) => {
|
|
2314
|
+
if (req.method === 'GET') {
|
|
2315
|
+
if (!guard(req, res, 'GET'))
|
|
2316
|
+
return;
|
|
2317
|
+
try {
|
|
2318
|
+
writeJson(res, 200, await buildAutosyncStatusByChannel(syncDir));
|
|
2319
|
+
}
|
|
2320
|
+
catch (error) {
|
|
2321
|
+
writeSyncRouteError(res, error);
|
|
2322
|
+
}
|
|
2323
|
+
return;
|
|
2324
|
+
}
|
|
2325
|
+
if (!guard(req, res, 'POST'))
|
|
2326
|
+
return;
|
|
2327
|
+
const body = await readJsonBody(req);
|
|
2328
|
+
if (body === undefined) {
|
|
2329
|
+
writeJson(res, 400, { error: 'invalid JSON body' });
|
|
2330
|
+
return;
|
|
2331
|
+
}
|
|
2332
|
+
try {
|
|
2333
|
+
// 按通道读写:git/webdav 各自的自动同步配置与运行状态独立(缺省 git 兜底)
|
|
2334
|
+
const channel = body['transport'] === 'webdav' ? 'webdav' : 'git';
|
|
2335
|
+
const cfg = await readAutosyncConfig(syncDir, channel);
|
|
2336
|
+
if (typeof body['enabled'] === 'boolean')
|
|
2337
|
+
cfg.enabled = body['enabled'];
|
|
2338
|
+
if (typeof body['interval'] === 'string' && isAutosyncInterval(body['interval']))
|
|
2339
|
+
cfg.interval = body['interval'];
|
|
2340
|
+
if (typeof body['startupMinIntervalMs'] === 'number' && Number.isFinite(body['startupMinIntervalMs']) && body['startupMinIntervalMs'] > 0) {
|
|
2341
|
+
cfg.startupMinIntervalMs = body['startupMinIntervalMs'];
|
|
2342
|
+
}
|
|
2343
|
+
await writeAutosyncConfig(syncDir, channel, cfg);
|
|
2344
|
+
if (scheduler)
|
|
2345
|
+
scheduler.reload().catch(() => { });
|
|
2346
|
+
writeJson(res, 200, await buildAutosyncStatus(syncDir, channel));
|
|
2347
|
+
}
|
|
2348
|
+
catch (error) {
|
|
2349
|
+
writeSyncRouteError(res, error);
|
|
2350
|
+
}
|
|
2351
|
+
},
|
|
2352
|
+
},
|
|
2353
|
+
// ------------------------------------------------------ sync/selection
|
|
2354
|
+
// m-sync-selection:保存同步分区选择(按通道:git/webdav 各自的模式 + 勾选分区)。
|
|
2355
|
+
// 持久化到 sync-selection.json;自动同步调度器与手动 push 共用(makeSyncEngine 注入)。
|
|
2356
|
+
// sections 元素必须是可同步(portable)分区 id;mode 非法 → 回退 default。
|
|
2357
|
+
{
|
|
2358
|
+
kind: 'exact',
|
|
2359
|
+
path: API.syncSelection,
|
|
2360
|
+
handler: async (req, res) => {
|
|
2361
|
+
if (!guard(req, res, 'POST'))
|
|
2362
|
+
return;
|
|
2363
|
+
const body = await readJsonBody(req);
|
|
2364
|
+
if (body === undefined) {
|
|
2365
|
+
writeJson(res, 400, { error: 'invalid JSON body' });
|
|
2366
|
+
return;
|
|
2367
|
+
}
|
|
2368
|
+
try {
|
|
2369
|
+
// 按通道读写:git/webdav 各自的模式与分区勾选独立(缺省 git 兜底)
|
|
2370
|
+
const channel = body['transport'] === 'webdav' ? 'webdav' : 'git';
|
|
2371
|
+
const mode = body['mode'] === 'advanced' ? 'advanced' : 'default';
|
|
2372
|
+
const rawSections = Array.isArray(body['sections']) ? body['sections'] : [];
|
|
2373
|
+
// 目录已含全部分区(不再按 portability 过滤),变量名不沿用 portable*
|
|
2374
|
+
const catalogSectionIds = new Set(syncSectionCatalog.map((s) => s.id));
|
|
2375
|
+
for (const s of rawSections) {
|
|
2376
|
+
if (typeof s !== 'string' || s === '') {
|
|
2377
|
+
writeJson(res, 400, { error: 'sections must be an array of non-empty strings' });
|
|
2378
|
+
return;
|
|
2379
|
+
}
|
|
2380
|
+
if (!catalogSectionIds.has(s)) {
|
|
2381
|
+
writeJson(res, 400, { error: `unknown sync section: ${s}` });
|
|
2382
|
+
return;
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
const next = {
|
|
2386
|
+
schemaVersion: SYNC_SELECTION_SCHEMA_VERSION,
|
|
2387
|
+
mode,
|
|
2388
|
+
sections: [...new Set(rawSections)],
|
|
2389
|
+
};
|
|
2390
|
+
await writeSyncSelection(syncDir, channel, next);
|
|
2391
|
+
selectionCache[channel] = next;
|
|
2392
|
+
writeJson(res, 200, { ok: true, transport: channel, mode: next.mode, sections: next.sections });
|
|
2393
|
+
}
|
|
2394
|
+
catch (error) {
|
|
2395
|
+
writeSyncRouteError(res, error);
|
|
2396
|
+
}
|
|
2397
|
+
},
|
|
2398
|
+
},
|
|
2399
|
+
// ------------------------------------------------------ sync/rollback
|
|
2400
|
+
// P2:UI 一键回滚入口(按 apply 返回的 restoreId 调用 backup→rollback)。
|
|
2401
|
+
{
|
|
2402
|
+
kind: 'exact',
|
|
2403
|
+
path: API.syncRollback,
|
|
2404
|
+
handler: withMutationGate('sync-rollback', async (req, res) => {
|
|
2405
|
+
if (!guard(req, res, 'POST'))
|
|
2406
|
+
return;
|
|
2407
|
+
const body = await readJsonBody(req);
|
|
2408
|
+
if (body === undefined) {
|
|
2409
|
+
writeJson(res, 400, { error: 'invalid JSON body' });
|
|
2410
|
+
return;
|
|
2411
|
+
}
|
|
2412
|
+
try {
|
|
2413
|
+
const restoreId = typeof body['restoreId'] === 'string' ? body['restoreId'] : '';
|
|
2414
|
+
if (restoreId === '') {
|
|
2415
|
+
writeJson(res, 400, { error: 'restoreId required' });
|
|
2416
|
+
return;
|
|
2417
|
+
}
|
|
2418
|
+
const store = new FileSnapshotStore({ dir: join(syncDir, 'snapshots') });
|
|
2419
|
+
const snap = await store.load(restoreId);
|
|
2420
|
+
const report = await performRollback({ ctx: host, snapshot: snap, store, adapters });
|
|
2421
|
+
const historyError = await tryAppendHistory({
|
|
2422
|
+
kind: 'rollback',
|
|
2423
|
+
result: 'success',
|
|
2424
|
+
sections: [],
|
|
2425
|
+
snapshotId: restoreId,
|
|
2426
|
+
source: 'api',
|
|
2427
|
+
summary: `一键同步回滚(${restoreId})`,
|
|
2428
|
+
});
|
|
2429
|
+
writeJson(res, 200, historyError === undefined ? { ok: true, full: report.full } : { ok: true, full: report.full, historyWriteError: historyError });
|
|
2430
|
+
}
|
|
2431
|
+
catch (error) {
|
|
2432
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
2433
|
+
}
|
|
2434
|
+
}),
|
|
2435
|
+
},
|
|
2436
|
+
// ---------------------------------------------------- market/status
|
|
2437
|
+
// 内置单市场(只读、不可编辑):恒返回内置仓库摘要。无 add/remove —— 市场绑定内置仓库。
|
|
2438
|
+
// ---------------------------------------------------- market/refresh
|
|
2439
|
+
// ---------------------------------------------------- market/browse
|
|
2440
|
+
// ---------------------------------------------------- market/download
|
|
2441
|
+
// 拉取 manifest + config.zip → 安全校验(§6)→ valid 则落受控临时区 + dry-run 分析/计划。
|
|
2442
|
+
// 真正落盘由用户确认后走既有 POST /execute(zipPath + plan)。零写入到确认。
|
|
2443
|
+
// ---------------------------------------------------- market/prepare
|
|
2444
|
+
// 发布向导:由「用户上传的配置 zip + 用户填写元数据」生成市场条目包
|
|
2445
|
+
// (L2 manifest + config.zip SHA-256 + sections),供 UI 展示/复制与引导推送。
|
|
2446
|
+
// 零写入配置:只在受控临时区生成发布目录;插件不做任何 git 写操作、不持有凭据。
|
|
2447
|
+
// ---------------------------------------------------- me/status
|
|
2448
|
+
// 「一键上传 / 我的配置」登录状态:resolve SYNC_CREDENTIAL_REF token → GET /user。
|
|
2449
|
+
// 401 → loggedIn:false(未登录);token 值不出模块外,只回传 login 用户名。
|
|
2450
|
+
// ---------------------------------------------------- me/upload
|
|
2451
|
+
// 一键上传:zipPath 必须来自受控上传临时区(复用 /market/prepare 规则);
|
|
2452
|
+
// form 仅 { name, description?, categories? }(name 必填);元数据全自动由 MyRepoService 生成。
|
|
2453
|
+
// ---------------------------------------------------- me/items
|
|
2454
|
+
// 查看已上传:读用户仓库 index.json + 收录状态(未收录 / PR 待审核 / 已收录)。
|
|
2455
|
+
// 401(token 过期)→ 401 + 脱敏错误,UI 引导重新登录。
|
|
2456
|
+
// ---------------------------------------------------- me/update
|
|
2457
|
+
// 一键更新:同 upload 时序;version 纯自动 +1、id 不变;PR 未合并 force push 更新 / 已合并基于最新 main 重开。
|
|
2458
|
+
// ---------------------------------------------------- me/listing
|
|
2459
|
+
// 查询收录/下架任务状态(结果卡轮询):任务表命中 → 直接返回;未命中 → 回退 GitHub 实况推导;
|
|
2460
|
+
// 无任务且无实况 → 200 null。401(token 过期)→ 401,UI 引导重新登录。
|
|
2461
|
+
// ---------------------------------------------------- me/relist
|
|
2462
|
+
// 重新提交收录(收录失败 / 进程重启丢失后的一键重试):幂等复用已存在 fork/open PR。
|
|
2463
|
+
// ---------------------------------------------------- me/delete
|
|
2464
|
+
// 删除条目:同步删用户仓库索引 + items/<id>/ 文件;已收录 → 后台异步提下架 PR;待审核 → 关闭收录 PR。
|
|
2465
|
+
// ------------------------------------------------------------ history
|
|
2466
|
+
// Phase 6:迁移历史审计(统一历史引擎)。只读 GET:列表(过滤)+ 导出。
|
|
2467
|
+
// loopback fence(guard)与全仓一致——仅同源 + loopback 可访问。
|
|
2468
|
+
// ------------------------------------------------------------ recovery
|
|
2469
|
+
// Phase 5:recovery 编排(prefix 路由,内部按 path 分发)。
|
|
2470
|
+
// 禁用 withMutationGate(避免 double-journal);mutation 路由经 withMutationLock + loopback fence。
|
|
2471
|
+
// ------------------------------------------------------------ Phase 1 P0
|
|
2472
|
+
// 配置生命周期(自动快照 / 撤销 / 重做):prefix 路由,内部按 path 分发。
|
|
2473
|
+
// mutation 动作经 withMutationGate(GLOBAL 锁 + SAFE MODE 闸门);status 只读不加锁。
|
|
2474
|
+
// 崩溃归因(P0-5):只读。上次启动是否异常 + 归因 + 建议动作 + last-good 快照。
|
|
2475
|
+
// 启动救援模式(P0-3):on = 备份三处原件后,把 profile patch 改写成只挂载本插件的最小内容、
|
|
2476
|
+
// 置空 home patch,并把 dsh.profile.bundles 收窄为 DSH 核心(@deepseek-ai/*)与本插件自身
|
|
2477
|
+
// —— 其余用户插件本次启动不挂载(这才是「禁用其它插件」,只中和 patch 层救不了
|
|
2478
|
+
// 「插件代码自己把 DSH 搞挂」);off = 从备份完整还原。两侧都只动
|
|
2479
|
+
// cordis.patch.yml / package.json / state.json,可完全回退。
|
|
2480
|
+
];
|
|
2481
|
+
return { routes: routesList, scheduler, makeSyncEngine, lifecycle };
|
|
2482
|
+
}
|
|
2483
|
+
/* ------------------------------------------------------------------ apply */
|
|
2484
|
+
/**
|
|
2485
|
+
* Mount the config-manager engine: host context, adapters, and the
|
|
2486
|
+
* /api/dsh-config-manager routes (when a webServer is present).
|
|
2487
|
+
* @param ctx - host plugin context carrying settings/credentials/webServer.
|
|
2488
|
+
* @param config - resolved plugin config.
|
|
2489
|
+
*/
|
|
2490
|
+
export function apply(ctx, config) {
|
|
2491
|
+
if (config?.enabled === false)
|
|
2492
|
+
return;
|
|
2493
|
+
const homeDir = resolveDshHome();
|
|
2494
|
+
const dataDir = config?.dataDir !== undefined && config.dataDir !== ''
|
|
2495
|
+
? resolve(config.dataDir)
|
|
2496
|
+
: dshHomePath('dsh-config-manager');
|
|
2497
|
+
const exportsDir = join(dataDir, 'exports');
|
|
2498
|
+
const tmpDir = join(dataDir, 'tmp');
|
|
2499
|
+
const snapshotsDir = join(dataDir, 'snapshots');
|
|
2500
|
+
const syncDir = join(dataDir, 'sync');
|
|
2501
|
+
// Phase 6:迁移历史审计目录(统一历史引擎;加入 RESERVED_INTERNAL_PREFIXES 防 F23 投毒链)
|
|
2502
|
+
const historyDir = join(dataDir, MIGRATION_HISTORY_DIR);
|
|
2503
|
+
mkdirSync(exportsDir, { recursive: true });
|
|
2504
|
+
/**
|
|
2505
|
+
* Phase 1 P0-5 崩溃归因:启动即写 boot-state(ok:false),待启动分类完成后翻 ok:true。
|
|
2506
|
+
* 崩溃瞬间不写文件 —— 靠「下一次启动发现上次 ok!==true」归因。上次若有崩溃且尚无归因,
|
|
2507
|
+
* 此处扫一次日志尾部并把 crashReason 持久化(日志会被滚动覆盖,错过就没了)。
|
|
2508
|
+
* best-effort:任何失败都不得影响插件挂载。
|
|
2509
|
+
*/
|
|
2510
|
+
const bootStateDir = join(dataDir, 'config-snapshots');
|
|
2511
|
+
const bootBegin = async () => {
|
|
2512
|
+
try {
|
|
2513
|
+
const prev = await readBootState(bootStateDir);
|
|
2514
|
+
const next = beginBoot(process.pid, prev);
|
|
2515
|
+
if (prev !== null && prev.ok !== true && next.crashReason === null) {
|
|
2516
|
+
try {
|
|
2517
|
+
const alert = computeBootAlert(prev, await readCrashLogTail(await listCandidateLogs(homeDir)));
|
|
2518
|
+
if (alert.crashReason !== null)
|
|
2519
|
+
next.crashReason = alert.crashReason;
|
|
2520
|
+
}
|
|
2521
|
+
catch { /* 归因失败不影响启动 */ }
|
|
2522
|
+
}
|
|
2523
|
+
await writeBootState(bootStateDir, next);
|
|
2524
|
+
}
|
|
2525
|
+
catch (error) {
|
|
2526
|
+
host.log.warn('boot-state 写入失败(不影响挂载)', { error: error instanceof Error ? error.message : String(error) });
|
|
2527
|
+
}
|
|
2528
|
+
};
|
|
2529
|
+
// 灾备总开关关闭时不写 boot-state(崩溃归因整体下线,也不留观测残留)。
|
|
2530
|
+
if (LIFECYCLE_ENABLED)
|
|
2531
|
+
void bootBegin();
|
|
2532
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
2533
|
+
mkdirSync(snapshotsDir, { recursive: true });
|
|
2534
|
+
mkdirSync(syncDir, { recursive: true });
|
|
2535
|
+
mkdirSync(historyDir, { recursive: true });
|
|
2536
|
+
const host = new ConfigManagerHostContext(ctx, homeDir, resolveProfileName(config));
|
|
2537
|
+
// issue #30:出站代理(插件私有,不改全局)。仅在检测到 HTTP(S)_PROXY 时生效;未配置则完全直连。
|
|
2538
|
+
// 打一条脱敏日志,便于用户确认「插件当前到底走没走代理」(凭据不进日志)。
|
|
2539
|
+
const proxySummary = activeProxySummary();
|
|
2540
|
+
if (proxySummary !== null) {
|
|
2541
|
+
host.log.info(`出站请求经代理 / outbound requests via proxy: http=${proxySummary.http ?? '(none)'} https=${proxySummary.https ?? '(none)'} noProxyEntries=${proxySummary.noProxyEntries}`);
|
|
2542
|
+
}
|
|
2543
|
+
// Phase 2 跨进程环境锁:全局唯一 GLOBAL EXCLUSIVE MUTATION LOCK(<dataDir>/locks/environment.lock)。
|
|
2544
|
+
// 所有 destructive mutation 入口经 runWithMutationLock(host.mutationLock, …) 获取;跨进程/跨 kind 互斥。
|
|
2545
|
+
// 随插件生命周期停止:停止 heartbeat 并清除本进程持有(release 由各入口 finally 保证;这里无需额外清理)。
|
|
2546
|
+
host.mutationLock = new EnvironmentLockManager({
|
|
2547
|
+
dataDir,
|
|
2548
|
+
op: 'config-manager',
|
|
2549
|
+
target: 'global-mutation',
|
|
2550
|
+
lockVersion: PLUGIN_VERSION,
|
|
2551
|
+
});
|
|
2552
|
+
const envLockManager = host.mutationLock;
|
|
2553
|
+
// Phase 3:启动 reconcile(只读)+ SAFE MODE。宿主 apply() 为同步 →
|
|
2554
|
+
// ① 先同步探测 durable SAFE MODE 标记(scheduler.start() 前即被阻断),
|
|
2555
|
+
// ② 再异步跑完整只读 reconcile,刷新标志与 durable 标记。不自动 recover stale lock(Rev 3 P1-NEW-2)。
|
|
2556
|
+
// Phase 4 F21/F11:注入真实 snapshotExists 正向校验——journal 引用的 snapshot 存在 + READY +
|
|
2557
|
+
// verified(manifest/blob hash)+ op/env/owner binding 匹配 journal,才视为可回滚的有效 recovery 证据。
|
|
2558
|
+
const phase3Recovery = new Phase3Recovery({
|
|
2559
|
+
dataDir,
|
|
2560
|
+
packageVersion: PLUGIN_VERSION,
|
|
2561
|
+
snapshotExists: async (snapshotId, binding) => {
|
|
2562
|
+
if (snapshotId === null || snapshotId === '')
|
|
2563
|
+
return false;
|
|
2564
|
+
if (!isValidSnapshotId(snapshotId))
|
|
2565
|
+
return false;
|
|
2566
|
+
const v = await verifySnapshot(snapshotsDir, snapshotId);
|
|
2567
|
+
if (!v.ok)
|
|
2568
|
+
return false;
|
|
2569
|
+
// binding 校验:journal 引用必须与快照双向一致(operationId/ownerInstanceId/environmentFingerprint)
|
|
2570
|
+
const snap = await new FileSnapshotStore({ dir: snapshotsDir }).load(snapshotId).catch(() => null);
|
|
2571
|
+
if (snap === null)
|
|
2572
|
+
return false;
|
|
2573
|
+
if (snap.readiness !== 'READY')
|
|
2574
|
+
return false;
|
|
2575
|
+
if (binding?.operationId !== undefined && snap.operationId !== binding.operationId)
|
|
2576
|
+
return false;
|
|
2577
|
+
if (binding?.ownerInstanceId !== undefined && snap.ownerInstanceId !== binding.ownerInstanceId)
|
|
2578
|
+
return false;
|
|
2579
|
+
if (binding?.environmentFingerprint !== undefined && snap.environmentFingerprint !== binding.environmentFingerprint)
|
|
2580
|
+
return false;
|
|
2581
|
+
return true;
|
|
2582
|
+
},
|
|
2583
|
+
});
|
|
2584
|
+
host.safeModeIsBlocked = () => phase3Recovery.safeModeActive;
|
|
2585
|
+
host.phase3Recovery = phase3Recovery;
|
|
2586
|
+
phase3Recovery.probeSafeModeSync();
|
|
2587
|
+
if (phase3Recovery.safeModeActive) {
|
|
2588
|
+
host.log.warn('Phase 3 SAFE MODE 激活:存在未恢复的 transaction,destructive 操作被阻断(如需恢复请先显式处理)');
|
|
2589
|
+
}
|
|
2590
|
+
// P1-B:启动 recovery 分类 barrier。调度器(AutoSync/Backup)只在分类完成且 state=NORMAL 时启动。
|
|
2591
|
+
// schedulerGate.start 由 makeRoutes 返回 scheduler 后赋值;apply 为同步,
|
|
2592
|
+
// 故在该异步分类块 await 完成前,schedulerGate.start 通常已就绪。fail-closed:分类抛错 → 不启动调度器。
|
|
2593
|
+
const schedulerGate = { start: null };
|
|
2594
|
+
let startupStateResolved = false;
|
|
2595
|
+
let shouldStartSchedulers = false;
|
|
2596
|
+
void (async () => {
|
|
2597
|
+
try {
|
|
2598
|
+
await phase3Recovery.initFingerprint();
|
|
2599
|
+
const lockInsp = await envLockManager.inspectLockState();
|
|
2600
|
+
// P1-A:启动 barrier 前捕获 crashed stale ownership 证据(environment.lock owner.instanceId),
|
|
2601
|
+
// 并将其作为 expectedOwnershipInstanceId 传入分类 env → 激活 journal↔ownership binding 校验。
|
|
2602
|
+
const staleOwnerId = lockInsp.state === 'STALE_LOCK_DETECTED' || lockInsp.state === 'UNKNOWN_STATE'
|
|
2603
|
+
? await phase3Recovery.captureStaleOwnershipInstanceId()
|
|
2604
|
+
: null;
|
|
2605
|
+
const startupState = classifyStartup({
|
|
2606
|
+
store: phase3Recovery.store,
|
|
2607
|
+
hooks: phase3Recovery.recoveryHooks,
|
|
2608
|
+
env: {
|
|
2609
|
+
environmentFingerprint: phase3Recovery.recoveryEnvFingerprint,
|
|
2610
|
+
isLiveOwner: async () => false,
|
|
2611
|
+
...(staleOwnerId ? { expectedOwnershipInstanceId: staleOwnerId } : {}),
|
|
2612
|
+
},
|
|
2613
|
+
lockState: mapLockStateForStartup(lockInsp.state),
|
|
2614
|
+
});
|
|
2615
|
+
const { state } = await startupState.classify();
|
|
2616
|
+
startupStateResolved = true;
|
|
2617
|
+
phase3Recovery.safeModeActive = phase3Recovery.safeModeActive || ['RECOVERY_REQUIRED', 'NEEDS_ATTENTION', 'UNKNOWN_STATE'].includes(state.kind);
|
|
2618
|
+
shouldStartSchedulers = (state.kind === 'NORMAL');
|
|
2619
|
+
if (state.kind === 'RECOVERY_REQUIRED' || state.kind === 'NEEDS_ATTENTION') {
|
|
2620
|
+
host.log.warn(`Phase 3 ${state.kind}:上次 destructive operation 崩溃残留,需显式恢复;destructive 调度器未启动(read-only host 存活)`);
|
|
2621
|
+
}
|
|
2622
|
+
else if (shouldStartSchedulers && schedulerGate.start !== null) {
|
|
2623
|
+
schedulerGate.start();
|
|
2624
|
+
}
|
|
2625
|
+
// Phase 1 P0-5:启动分类已得出结论 → 本次启动判定为成功(推进 lastGoodAt)。
|
|
2626
|
+
// 灾备总开关关闭时不写 boot-state。
|
|
2627
|
+
if (LIFECYCLE_ENABLED) {
|
|
2628
|
+
try {
|
|
2629
|
+
await writeBootState(bootStateDir, markBootOk((await readBootState(bootStateDir)) ?? beginBoot(process.pid, null)));
|
|
2630
|
+
}
|
|
2631
|
+
catch (error) {
|
|
2632
|
+
host.log.warn('boot-state 标记成功失败', { error: error instanceof Error ? error.message : String(error) });
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
}
|
|
2636
|
+
catch (err) {
|
|
2637
|
+
// fail-closed:inspectStartup 抛错不默认 NORMAL → 不启动调度器(read-only host 存活)
|
|
2638
|
+
startupStateResolved = true;
|
|
2639
|
+
shouldStartSchedulers = false;
|
|
2640
|
+
// fail-closed 也是「本次启动成功了」:host 存活(read-only),不该被判为崩溃。
|
|
2641
|
+
if (LIFECYCLE_ENABLED) {
|
|
2642
|
+
try {
|
|
2643
|
+
await writeBootState(bootStateDir, markBootOk((await readBootState(bootStateDir)) ?? beginBoot(process.pid, null)));
|
|
2644
|
+
}
|
|
2645
|
+
catch { /* best-effort */ }
|
|
2646
|
+
}
|
|
2647
|
+
host.log.warn('Phase 3 启动 reconcile 失败(fail-closed:destructive 调度器不启动)', {
|
|
2648
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2649
|
+
});
|
|
2650
|
+
}
|
|
2651
|
+
})();
|
|
2652
|
+
// 缓存自动清理:启动即清一次 + 每 24h 定时清一次。
|
|
2653
|
+
// 只清「可重建/一次性」缓存与临时文件(tmp 暂存、exports 导出副本、market cache/work),
|
|
2654
|
+
// 保留期内的文件不删(供刷新恢复导入/下载等窗口继续消费);snapshots 与 sync 属用户数据/安全网不动。
|
|
2655
|
+
// 尽力而为:任何失败仅记日志,不影响插件挂载与其他功能。
|
|
2656
|
+
const runCacheCleanup = () => {
|
|
2657
|
+
void cleanupCaches({
|
|
2658
|
+
tmpDir,
|
|
2659
|
+
exportsDir,
|
|
2660
|
+
})
|
|
2661
|
+
.then((report) => {
|
|
2662
|
+
if (report.removed > 0) {
|
|
2663
|
+
host.log.info('缓存自动清理完成', { removed: report.removed, freedBytes: report.freedBytes });
|
|
2664
|
+
}
|
|
2665
|
+
})
|
|
2666
|
+
.catch((error) => {
|
|
2667
|
+
host.log.warn('缓存自动清理失败', { error: error instanceof Error ? error.message : String(error) });
|
|
2668
|
+
});
|
|
2669
|
+
};
|
|
2670
|
+
runCacheCleanup();
|
|
2671
|
+
const cacheCleanupTimer = setInterval(runCacheCleanup, CACHE_CLEANUP_INTERVAL_MS);
|
|
2672
|
+
ctx.effect(() => () => clearInterval(cacheCleanupTimer), 'config-manager: cache cleanup scheduler');
|
|
2673
|
+
// self 分区目录(相对 ~/.dsh 根):dataDir 在 homeDir 下 → 用相对路径挂载 self adapter;
|
|
2674
|
+
// 自定义 dataDir 位于 ~/.dsh 之外时 Host fs 门面无法覆盖(confined to home root),
|
|
2675
|
+
// 不挂 self 分区并告警(其余分区不受影响)。
|
|
2676
|
+
const selfRel = relative(homeDir, dataDir);
|
|
2677
|
+
const selfDir = !selfRel.startsWith('..') && !isAbsolute(selfRel) && selfRel !== '' ? selfRel : '';
|
|
2678
|
+
if (selfDir === '') {
|
|
2679
|
+
host.log.warn(`dataDir 不在 ~/.dsh 之下(${dataDir}),self 分区(插件自身配置备份)不挂载`);
|
|
2680
|
+
}
|
|
2681
|
+
const adapters = createAdapters({
|
|
2682
|
+
// Namespace list = everything the settings service has registered.
|
|
2683
|
+
namespaces: async () => (await ctx.settings.describe({ redactSecrets: true })).map((d) => String(d.ns)),
|
|
2684
|
+
// Sessions 分区默认关(含敏感内容):挂载 adapter 供 Custom Export 显式勾选(§3.3/§15)。
|
|
2685
|
+
includeSessions: true,
|
|
2686
|
+
// 导出 plugins 分区时不列本插件自身,避免备份中的自引用条目。
|
|
2687
|
+
selfPluginName: PLUGIN_NAME,
|
|
2688
|
+
// pluginFiles 扩展:额外白名单文件 + 约定配置目录(都相对 ~/.dsh 根),支持导出更多插件配置。
|
|
2689
|
+
pluginFiles: config?.pluginFiles,
|
|
2690
|
+
pluginFilesDir: config?.pluginFilesDir,
|
|
2691
|
+
// self 分区:插件自身配置(sync-*.json / market-config.json / ui-prefs.json);'' = 不挂载
|
|
2692
|
+
selfDir,
|
|
2693
|
+
// T1:本地源(link:/file:)插件打包 —— 这些 spec 指向本机路径,换机后必然不可达,
|
|
2694
|
+
// 曾导致插件被静默丢失。导出时用 npm pack 把源码包一并放进备份,
|
|
2695
|
+
// 导入时用解包出的绝对路径重写 spec(见 adapters/plugins.ts 的 applyItem)。
|
|
2696
|
+
localPluginPack: createLocalPluginPackHook({ homeDir, dataDir }),
|
|
2697
|
+
});
|
|
2698
|
+
host.log.info('config-manager 已挂载', {
|
|
2699
|
+
homeDir,
|
|
2700
|
+
dataDir,
|
|
2701
|
+
dshVersion: host.dshVersion,
|
|
2702
|
+
adapters: adapters.map((a) => a.id),
|
|
2703
|
+
});
|
|
2704
|
+
// Phase 6:迁移历史引擎(统一审计史;per-file append-only 存储于 <dataDir>/migration-history)
|
|
2705
|
+
const historyStore = new MigrationStore({ dir: historyDir });
|
|
2706
|
+
const runs = new RunRegistry({ msg: host.msg });
|
|
2707
|
+
const secretScanner = createConfiguredSecretScanner(config?.personalPatterns);
|
|
2708
|
+
const { routes, scheduler, makeSyncEngine, lifecycle } = makeRoutes({
|
|
2709
|
+
host,
|
|
2710
|
+
adapters,
|
|
2711
|
+
exportsDir,
|
|
2712
|
+
tmpDir,
|
|
2713
|
+
snapshotsDir,
|
|
2714
|
+
runs,
|
|
2715
|
+
syncDir,
|
|
2716
|
+
dataDir,
|
|
2717
|
+
// F2:部署者 personalPatterns → 强化 secret 扫描器(未配置 = 默认行为)。
|
|
2718
|
+
// 该扫描器实现了 scanText(文件类分区文本级扫描,G-09 只告警不改写)——
|
|
2719
|
+
// 换成任何没有 scanText 的扫描器都会静默关闭 G-09,改这里务必先看 exporter.ts 的 scanFileSectionText。
|
|
2720
|
+
scanner: secretScanner, // M1:与同步路径共用同一实例(见上方 secretScanner 构造)
|
|
2721
|
+
credentials: ctx.credentials,
|
|
2722
|
+
githubClientId: config?.githubClientId ?? DEFAULT_GITHUB_CLIENT_ID,
|
|
2723
|
+
githubClientSecret: config?.githubClientSecret,
|
|
2724
|
+
history: historyStore,
|
|
2725
|
+
});
|
|
2726
|
+
// Agent 可调用的模型工具(P0-1):复用 src/core 引擎与同一 makeSyncEngine 来源。
|
|
2727
|
+
// 不依赖 webServer:host 侧能力在无 Web 部署时仍可用;tools 服务未组合时内部守卫跳过。
|
|
2728
|
+
registerModelTools(ctx, {
|
|
2729
|
+
host,
|
|
2730
|
+
adapters,
|
|
2731
|
+
syncDir,
|
|
2732
|
+
makeSyncEngine,
|
|
2733
|
+
});
|
|
2734
|
+
// P1-B:调度器不同步 start —— 由启动 recovery 分类完成后(仅 NORMAL)启动。
|
|
2735
|
+
schedulerGate.start = () => {
|
|
2736
|
+
scheduler.start();
|
|
2737
|
+
// Phase 1 P0-1:配置变更自动快照。放在同一闸门内,确保恢复/事务进行中不开拍。
|
|
2738
|
+
// 灾备总开关关闭时不启动监听(否则后台持续采集全部分区并刷「超出上限」告警)。
|
|
2739
|
+
if (LIFECYCLE_ENABLED)
|
|
2740
|
+
lifecycle.startAutoSnapshot();
|
|
2741
|
+
};
|
|
2742
|
+
// 若启动分类已在此构造完成前解析为 NORMAL(罕见竞态),立即补启动。
|
|
2743
|
+
if (startupStateResolved && shouldStartSchedulers && schedulerGate.start !== null) {
|
|
2744
|
+
schedulerGate.start();
|
|
2745
|
+
}
|
|
2746
|
+
// 自动同步调度器随插件生命周期停止:插件重载/卸载时清理定时器,
|
|
2747
|
+
// 避免旧调度器残留导致重复后台同步。
|
|
2748
|
+
ctx.effect(() => () => scheduler.stop(), 'config-manager: autosync scheduler');
|
|
2749
|
+
// Phase 1 P0-1:停止配置变更监听(dispose 后不再产生自动快照)。
|
|
2750
|
+
ctx.effect(() => () => lifecycle.dispose(), 'config-manager: config lifecycle watcher');
|
|
2751
|
+
const webServer = readService(ctx, 'webServer');
|
|
2752
|
+
if (webServer === undefined) {
|
|
2753
|
+
host.log.warn('webServer 服务不可用:跳过 /api/dsh-config-manager 路由注册(引擎能力仍可用)');
|
|
2754
|
+
return;
|
|
2755
|
+
}
|
|
2756
|
+
ctx.effect(() => {
|
|
2757
|
+
const disposers = routes.map((route) => webServer.register(route));
|
|
2758
|
+
return () => {
|
|
2759
|
+
for (const dispose of disposers)
|
|
2760
|
+
dispose();
|
|
2761
|
+
};
|
|
2762
|
+
}, 'config-manager: routes');
|
|
2763
|
+
}
|
|
2764
|
+
//# sourceMappingURL=index.js.map
|